From 3298c6d86df5971d762ccb7031608bd166748508 Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Thu, 9 Apr 2026 09:04:32 +1000 Subject: [PATCH 01/20] chore(audience): add CDN bundle build with SDK_VERSION injection Adds a self-contained IIFE bundle of @imtbl/audience so studios can load the SDK via a + + + From 1a569780ba6b56ad1bc2c1de49532a58e215a1ed Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Thu, 9 Apr 2026 09:05:03 +1000 Subject: [PATCH 03/20] feat(audience): add demo controls for all public methods Wires every public method on the Audience class to a button in the demo, one section per method: - Consent: three buttons (none, anonymous, full) call setConsent() - Page: button + properties textarea -> page(props) - Track: event name input + properties textarea -> track(name, props) - Identify: ID input + identityType select + traits textarea -> identify(id, type, traits); separate 'Identify (traits only)' button for the anonymous-visitor overload - Alias: from/to ID inputs + identityType selects -> alias(from, to) - Reset: reset() - Flush: flush() Every action writes a log entry with the method name and the payload that was sent. JSON parse errors on properties/traits inputs are caught and surfaced in the log rather than thrown. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/audience/sdk/demo/demo.js | 220 +++++++++++++++++++++++++- packages/audience/sdk/demo/index.html | 78 +++++++++ 2 files changed, 293 insertions(+), 5 deletions(-) diff --git a/packages/audience/sdk/demo/demo.js b/packages/audience/sdk/demo/demo.js index e3f1f230d3..070162706d 100644 --- a/packages/audience/sdk/demo/demo.js +++ b/packages/audience/sdk/demo/demo.js @@ -15,6 +15,7 @@ } var Audience = window.ImmutableAudience.Audience; + var IdentityType = window.ImmutableAudience.IdentityType; // State var audience = null; @@ -42,6 +43,41 @@ return null; } + function parseJsonOrWarn(txt, label) { + var trimmed = (txt || '').trim(); + if (!trimmed) return undefined; + try { + var parsed = JSON.parse(trimmed); + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + log('WARN', label + ': JSON must be an object', 'warn'); + return null; + } + return parsed; + } catch (err) { + log('WARN', label + ': invalid JSON — ' + String(err && err.message || err), 'warn'); + return null; + } + } + + function populateIdentityDropdowns() { + var selectIds = ['identify-type', 'alias-from-type', 'alias-to-type']; + var values = Object.keys(IdentityType).map(function (key) { + return { key: key, value: IdentityType[key] }; + }); + + for (var i = 0; i < selectIds.length; i++) { + var sel = $(selectIds[i]); + if (!sel || sel.options.length > 0) continue; + for (var j = 0; j < values.length; j++) { + var opt = document.createElement('option'); + opt.value = values[j].value; + opt.textContent = values[j].value; + sel.appendChild(opt); + } + } + $('alias-to-type').value = IdentityType.Passport; + } + // Log function log(method, detail, type) { type = type || 'info'; @@ -64,17 +100,44 @@ var methodEl = create('span', 'log-method'); text(methodEl, e.method); - var detailStr = typeof e.detail === 'object' ? JSON.stringify(e.detail, null, 2) : String(e.detail); - var detailEl = create('span', 'log-detail'); - text(detailEl, detailStr); + var detailStr; + if (e.detail == null) { + detailStr = ''; + } else if (typeof e.detail === 'object') { + try { + detailStr = JSON.stringify(e.detail, null, 2); + } catch (err) { + detailStr = '[unserializable detail: ' + String(err && err.message) + ']'; + } + } else { + detailStr = String(e.detail); + } entry.appendChild(timeEl); entry.appendChild(methodEl); - entry.appendChild(detailEl); + + // For error entries with multi-line JSON, render a block; otherwise inline span. + var isMultiline = detailStr.indexOf('\n') !== -1; + if (isMultiline || e.type === 'err') { + var br = document.createElement('br'); + entry.appendChild(br); + var pre = create('span', 'log-detail'); + text(pre, detailStr); + entry.appendChild(pre); + } else { + var detailEl = create('span', 'log-detail'); + text(detailEl, detailStr); + entry.appendChild(detailEl); + } + container.appendChild(entry); } container.scrollTop = container.scrollHeight; - text($('log-count'), logEntries.length + ' entries'); + var countText = logEntries.length + ' entries'; + if (logEntries.length >= MAX_LOG_ENTRIES) { + countText += ' (capped at ' + MAX_LOG_ENTRIES + ')'; + } + text($('log-count'), countText); } function clearLog() { @@ -106,6 +169,11 @@ $('btn-shutdown').disabled = !on; $('btn-reset').disabled = !on; $('btn-flush').disabled = !on; + $('btn-page').disabled = !on; + $('btn-track').disabled = !on; + $('btn-identify').disabled = !on; + $('btn-identify-traits').disabled = !on; + $('btn-alias').disabled = !on; $('pk').disabled = on; var envRadios = document.querySelectorAll('input[name="env"]'); for (var i = 0; i < envRadios.length; i++) envRadios[i].disabled = on; @@ -146,6 +214,7 @@ log('INIT', String(err && err.message || err), 'err'); return; } + updateConsentButtons(); updateStatus(); } @@ -161,6 +230,7 @@ currentUserId = null; currentConsent = null; setInitState(false); + updateConsentButtons(); updateStatus(); } @@ -186,6 +256,134 @@ }); } + function onSetConsent(level) { + if (!audience) return; + try { + audience.setConsent(level); + currentConsent = level; + log('CONSENT', 'set to ' + level, 'ok'); + if (level === 'none') currentUserId = null; + } catch (err) { + log('CONSENT', String(err && err.message || err), 'err'); + } + updateConsentButtons(); + updateStatus(); + } + + function onPage() { + if (!audience) return; + var props = parseJsonOrWarn($('page-props').value, 'page properties'); + if (props === null) return; + + try { + audience.page(props); + log('PAGE', props || '(no properties)', 'ok'); + } catch (err) { + log('PAGE', String(err && err.message || err), 'err'); + } + } + + function onTrack() { + if (!audience) return; + var name = $('track-name').value.trim(); + if (!name) { + log('WARN', 'Track: event name is required', 'warn'); + return; + } + var props = parseJsonOrWarn($('track-props').value, 'track properties'); + if (props === null) return; + + try { + audience.track(name, props); + log('TRACK', { eventName: name, properties: props }, 'ok'); + } catch (err) { + log('TRACK', String(err && err.message || err), 'err'); + } + } + + function onIdentify() { + if (!audience) return; + var id = $('identify-id').value.trim(); + if (!id) { + log('WARN', 'Identify: ID is required', 'warn'); + return; + } + var identityType = $('identify-type').value; + var traits = parseJsonOrWarn($('identify-traits').value, 'identify traits'); + if (traits === null) return; + + try { + if (traits !== undefined) { + audience.identify(id, identityType, traits); + } else { + audience.identify(id, identityType); + } + currentUserId = id; + log('IDENTIFY', { id: id, identityType: identityType, traits: traits }, 'ok'); + } catch (err) { + log('IDENTIFY', String(err && err.message || err), 'err'); + } + updateStatus(); + } + + function onIdentifyTraits() { + if (!audience) return; + var traits = parseJsonOrWarn($('identify-traits').value, 'identify traits'); + if (traits === null || traits === undefined) { + log('WARN', 'Traits-only identify: traits are required', 'warn'); + return; + } + try { + audience.identify(traits); + log('IDENTIFY', { traitsOnly: traits }, 'ok'); + } catch (err) { + log('IDENTIFY', String(err && err.message || err), 'err'); + } + } + + function onAlias() { + if (!audience) return; + var fromId = $('alias-from-id').value.trim(); + var toId = $('alias-to-id').value.trim(); + var fromType = $('alias-from-type').value; + var toType = $('alias-to-type').value; + + if (!fromId || !toId) { + log('WARN', 'Alias: both IDs are required', 'warn'); + return; + } + if (fromId === toId && fromType === toType) { + log('WARN', 'Alias: from and to are identical', 'warn'); + return; + } + + try { + audience.alias( + { id: fromId, identityType: fromType }, + { id: toId, identityType: toType }, + ); + log('ALIAS', { from: { id: fromId, type: fromType }, to: { id: toId, type: toType } }, 'ok'); + } catch (err) { + log('ALIAS', String(err && err.message || err), 'err'); + } + } + + function updateConsentButtons() { + var btns = [ + { el: $('btn-consent-none'), level: 'none' }, + { el: $('btn-consent-anon'), level: 'anonymous' }, + { el: $('btn-consent-full'), level: 'full' }, + ]; + for (var i = 0; i < btns.length; i++) { + btns[i].el.disabled = !audience; + if (audience && currentConsent === btns[i].level) { + btns[i].el.classList.add('active'); + } else { + btns[i].el.classList.remove('active'); + } + } + } + // Wire up document.addEventListener('DOMContentLoaded', function () { $('btn-init').addEventListener('click', onInit); @@ -194,6 +392,18 @@ $('btn-flush').addEventListener('click', onFlush); $('btn-clear-log').addEventListener('click', clearLog); + $('btn-consent-none').addEventListener('click', function () { onSetConsent('none'); }); + $('btn-consent-anon').addEventListener('click', function () { onSetConsent('anonymous'); }); + $('btn-consent-full').addEventListener('click', function () { onSetConsent('full'); }); + + $('btn-page').addEventListener('click', onPage); + $('btn-track').addEventListener('click', onTrack); + + populateIdentityDropdowns(); + $('btn-identify').addEventListener('click', onIdentify); + $('btn-identify-traits').addEventListener('click', onIdentifyTraits); + $('btn-alias').addEventListener('click', onAlias); + setInterval(updateStatus, 1000); updateStatus(); log('READY', 'Demo loaded. Paste a publishable key and click Init.', 'info'); diff --git a/packages/audience/sdk/demo/index.html b/packages/audience/sdk/demo/index.html index c0ac9f2a84..46232b5d58 100644 --- a/packages/audience/sdk/demo/index.html +++ b/packages/audience/sdk/demo/index.html @@ -57,6 +57,84 @@

Setup

+ + +
+

Page

+
+ + +
+
+ +
+
+ +
+

Track

+
+ + +
+
+ + +
+
+ +
+
+ +
+

Identify

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+

Alias

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
From 881e39f71ac28a8e981a4b7b313662f1c4fa2052 Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Thu, 9 Apr 2026 09:05:16 +1000 Subject: [PATCH 04/20] feat(audience): polish demo Setup panel and status bar - Disable Init until the publishable key input has non-whitespace content (prevents calling Audience.init with an empty key). - Colour-code the consent badge in the status bar by level: red for none, amber for anonymous, green for full. Readable at a glance. - Add flushInterval and flushSize number inputs to the Setup panel so you can exercise non-default queue timings from the demo. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/audience/sdk/demo/demo.css | 4 ++ packages/audience/sdk/demo/demo.js | 79 ++++++++++++++++++++++----- packages/audience/sdk/demo/index.html | 10 +++- 3 files changed, 79 insertions(+), 14 deletions(-) diff --git a/packages/audience/sdk/demo/demo.css b/packages/audience/sdk/demo/demo.css index 008bfe21f8..bcc621069a 100644 --- a/packages/audience/sdk/demo/demo.css +++ b/packages/audience/sdk/demo/demo.css @@ -271,3 +271,7 @@ button.active { font-size: 11px; font-family: var(--mono); } + +.status-value.consent-none { color: var(--err); } +.status-value.consent-anonymous { color: var(--warn); } +.status-value.consent-full { color: var(--ok); } diff --git a/packages/audience/sdk/demo/demo.js b/packages/audience/sdk/demo/demo.js index 070162706d..17cde37c5e 100644 --- a/packages/audience/sdk/demo/demo.js +++ b/packages/audience/sdk/demo/demo.js @@ -152,8 +152,16 @@ $('status-env').className = 'status-value' + (audience ? '' : ' dim'); var consent = audience ? (currentConsent || '—') : '—'; - text($('status-consent'), consent); - $('status-consent').className = 'status-value' + (audience && currentConsent ? '' : ' dim'); + var consentEl = $('status-consent'); + text(consentEl, consent); + // Rebuild className fresh each update so we don't accumulate stale state classes. + var consentClass = 'status-value'; + if (!audience || !currentConsent) { + consentClass += ' dim'; + } else { + consentClass += ' consent-' + currentConsent; + } + consentEl.className = consentClass; var anonCookie = document.cookie.match(/imtbl_anon_id=([^;]*)/); text($('status-anon'), anonCookie ? decodeURIComponent(anonCookie[1]) : '—'); @@ -163,6 +171,16 @@ $('status-user').className = 'status-value' + (currentUserId ? '' : ' dim'); } + // Sync the Init button's enabled state based on the publishable key field content. + // Declared as a function declaration so it is hoisted and can be called from + // setInitState below without a forward-reference guard. + function syncInitEnabled() { + if (audience) return; + var initBtn = $('btn-init'); + var pkInput = $('pk'); + initBtn.disabled = pkInput.value.trim().length === 0; + } + // Enable/disable controls based on init state function setInitState(on) { $('btn-init').disabled = on; @@ -175,10 +193,13 @@ $('btn-identify-traits').disabled = !on; $('btn-alias').disabled = !on; $('pk').disabled = on; + $('flush-interval').disabled = on; + $('flush-size').disabled = on; var envRadios = document.querySelectorAll('input[name="env"]'); for (var i = 0; i < envRadios.length; i++) envRadios[i].disabled = on; var consentRadios = document.querySelectorAll('input[name="initial-consent"]'); for (var j = 0; j < consentRadios.length; j++) consentRadios[j].disabled = on; + if (!on) syncInitEnabled(); } // onError handler passed to Audience.init @@ -191,25 +212,53 @@ // Button handlers function onInit() { var pk = $('pk').value.trim(); - if (!pk) { - log('WARN', 'Publishable key is required.', 'warn'); - return; - } var env = getRadio('env'); var consent = getRadio('initial-consent'); var debug = $('debug').checked; + // Optional advanced config: flushInterval / flushSize. + // Empty input → omit → SDK uses its defaults (5000ms / 20 items). + var flushIntervalRaw = $('flush-interval').value.trim(); + var flushSizeRaw = $('flush-size').value.trim(); + var flushInterval; + var flushSize; + + if (flushIntervalRaw) { + flushInterval = parseInt(flushIntervalRaw, 10); + if (isNaN(flushInterval) || flushInterval <= 0) { + log('WARN', 'Flush interval must be a positive integer in milliseconds', 'warn'); + return; + } + } + if (flushSizeRaw) { + flushSize = parseInt(flushSizeRaw, 10); + if (isNaN(flushSize) || flushSize <= 0) { + log('WARN', 'Flush batch size must be a positive integer', 'warn'); + return; + } + } + + var config = { + publishableKey: pk, + environment: env, + consent: consent, + debug: debug, + onError: handleError, + }; + if (flushInterval !== undefined) config.flushInterval = flushInterval; + if (flushSize !== undefined) config.flushSize = flushSize; + try { - audience = Audience.init({ - publishableKey: pk, + audience = Audience.init(config); + setInitState(true); + currentConsent = consent; + log('INIT', { environment: env, consent: consent, debug: debug, - onError: handleError, - }); - setInitState(true); - currentConsent = consent; - log('INIT', { environment: env, consent: consent, debug: debug }, 'ok'); + flushInterval: flushInterval, + flushSize: flushSize, + }, 'ok'); } catch (err) { log('INIT', String(err && err.message || err), 'err'); return; @@ -404,6 +453,10 @@ $('btn-identify-traits').addEventListener('click', onIdentifyTraits); $('btn-alias').addEventListener('click', onAlias); + // Enable Init only when the publishable key input has non-whitespace content. + $('pk').addEventListener('input', syncInitEnabled); + syncInitEnabled(); + setInterval(updateStatus, 1000); updateStatus(); log('READY', 'Demo loaded. Paste a publishable key and click Init.', 'info'); diff --git a/packages/audience/sdk/demo/index.html b/packages/audience/sdk/demo/index.html index 46232b5d58..d129ecfe04 100644 --- a/packages/audience/sdk/demo/index.html +++ b/packages/audience/sdk/demo/index.html @@ -49,8 +49,16 @@

Setup

Debug — log SDK internals to browser console +
+ + +
+
+ + +
- + From ef4306821a8ee662cf347e701e867729790e4658 Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Thu, 9 Apr 2026 09:06:41 +1000 Subject: [PATCH 05/20] feat(audience): add resizable side-by-side demo layout On wide viewports the demo splits into two columns: controls on the left, event log on the right, with a drag gutter between them to adjust the split ratio. On narrow viewports the columns stack. The event log itself is also resizable on both wide and narrow layouts so you can see more history without scrolling. Keyboard users can resize the gutter with arrow keys (role="separator", aria-orientation, tabindex). Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/audience/sdk/demo/demo.css | 74 ++++++++++++++++++++++++- packages/audience/sdk/demo/demo.js | 80 +++++++++++++++++++++++++++ packages/audience/sdk/demo/index.html | 10 +++- 3 files changed, 160 insertions(+), 4 deletions(-) diff --git a/packages/audience/sdk/demo/demo.css b/packages/audience/sdk/demo/demo.css index bcc621069a..4f39abafed 100644 --- a/packages/audience/sdk/demo/demo.css +++ b/packages/audience/sdk/demo/demo.css @@ -220,9 +220,11 @@ button.active { border-radius: 6px; font-family: var(--mono); font-size: 12px; - max-height: 360px; - overflow-y: auto; + height: 360px; + min-height: 120px; + overflow: auto; padding: 12px; + resize: vertical; } .log-entry { @@ -275,3 +277,71 @@ button.active { .status-value.consent-none { color: var(--err); } .status-value.consent-anonymous { color: var(--warn); } .status-value.consent-full { color: var(--ok); } + +.demo-gutter { + display: none; +} + +/* Wide-viewport two-column layout: controls left, sticky event log right. + On narrow viewports (< 1024px) the default block layout applies and the + panels stack vertically as before. */ +@media (min-width: 1024px) { + main { + max-width: 1360px; + } + + .demo-grid { + display: grid; + grid-template-columns: 1fr 8px 1fr; + gap: 0; + align-items: start; + column-gap: 16px; + } + + .demo-gutter { + display: block; + width: 8px; + cursor: col-resize; + background: var(--panel-border); + border-radius: 4px; + align-self: stretch; + min-height: 100px; + transition: background 0.12s ease; + } + + .demo-gutter:hover, + .demo-gutter.active { + background: var(--accent); + } + + .controls > .panel, + .controls > #panel-slot { + margin-bottom: 16px; + } + + .controls > .panel:last-child, + .controls > #panel-slot:last-child { + margin-bottom: 0; + } + + .log-column { + position: sticky; + top: 16px; + max-height: calc(100vh - 32px); + } + + .log-column > .panel { + margin-bottom: 0; + } + + .log-column .log { + /* User-resizable at wide viewports too. Default height fills most of + the viewport; drag the bottom-right corner to shrink or grow. + max-height clamps the grow direction so the log can't push off the + bottom of the screen. */ + height: calc(100vh - 220px); + min-height: 120px; + max-height: calc(100vh - 80px); + resize: vertical; + } +} diff --git a/packages/audience/sdk/demo/demo.js b/packages/audience/sdk/demo/demo.js index 17cde37c5e..f2177fe5c7 100644 --- a/packages/audience/sdk/demo/demo.js +++ b/packages/audience/sdk/demo/demo.js @@ -78,6 +78,85 @@ $('alias-to-type').value = IdentityType.Passport; } + function initDemoGutter() { + var gutter = $('demo-gutter'); + if (!gutter) return; + var grid = document.querySelector('.demo-grid'); + if (!grid) return; + + var STORAGE_KEY = '__imtbl_demo_split'; + var MIN_PCT = 25; + var MAX_PCT = 75; + + function applySplit(leftPct) { + var clamped = Math.max(MIN_PCT, Math.min(MAX_PCT, leftPct)); + grid.style.gridTemplateColumns = clamped + 'fr 8px ' + (100 - clamped) + 'fr'; + } + + // Restore saved split if any. + try { + var saved = localStorage.getItem(STORAGE_KEY); + if (saved) { + var pct = parseFloat(saved); + if (!isNaN(pct)) applySplit(pct); + } + } catch (_err) { + // localStorage may be unavailable — ignore + } + + var dragging = false; + + gutter.addEventListener('mousedown', function (e) { + dragging = true; + gutter.classList.add('active'); + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + e.preventDefault(); + }); + + document.addEventListener('mousemove', function (e) { + if (!dragging) return; + var rect = grid.getBoundingClientRect(); + var leftPx = e.clientX - rect.left; + var pct = (leftPx / rect.width) * 100; + applySplit(pct); + }); + + document.addEventListener('mouseup', function () { + if (!dragging) return; + dragging = false; + gutter.classList.remove('active'); + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + + // Persist the current split ratio (recomputed from actual layout). + var rect = grid.getBoundingClientRect(); + var controlsEl = document.querySelector('.demo-grid > .controls'); + if (!controlsEl) return; + var controlsRect = controlsEl.getBoundingClientRect(); + var currentPct = (controlsRect.width / rect.width) * 100; + try { + localStorage.setItem(STORAGE_KEY, currentPct.toFixed(2)); + } catch (_err) { + // noop + } + }); + + // Keyboard support: left/right arrow when focused. + gutter.addEventListener('keydown', function (e) { + if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return; + var rect = grid.getBoundingClientRect(); + var controlsEl = document.querySelector('.demo-grid > .controls'); + if (!controlsEl) return; + var currentPct = (controlsEl.getBoundingClientRect().width / rect.width) * 100; + var delta = e.key === 'ArrowLeft' ? -2 : 2; + var next = currentPct + delta; + applySplit(next); + try { localStorage.setItem(STORAGE_KEY, next.toFixed(2)); } catch (_err) {} + e.preventDefault(); + }); + } + // Log function log(method, detail, type) { type = type || 'info'; @@ -449,6 +528,7 @@ $('btn-track').addEventListener('click', onTrack); populateIdentityDropdowns(); + initDemoGutter(); $('btn-identify').addEventListener('click', onIdentify); $('btn-identify-traits').addEventListener('click', onIdentifyTraits); $('btn-alias').addEventListener('click', onAlias); diff --git a/packages/audience/sdk/demo/index.html b/packages/audience/sdk/demo/index.html index d129ecfe04..b288322d38 100644 --- a/packages/audience/sdk/demo/index.html +++ b/packages/audience/sdk/demo/index.html @@ -22,6 +22,8 @@

Immutable Audience SDK — Demo

User ID
+
+

Setup

@@ -143,8 +145,10 @@

Alias

-
- +
+
+ +

Event Log

@@ -155,6 +159,8 @@

Event Log

+
+
From 769c42125ea13e019a3b61bc32e006e3674d5de6 Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Thu, 9 Apr 2026 09:06:57 +1000 Subject: [PATCH 06/20] feat(audience): style demo to match passport sample app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restyles the demo to match the light theme used by the passport SDK sample app at https://github.com/immutable/passport-sample-app so the two demos feel like a consistent family. Changes: - Light theme as the default (no dark mode toggle — see passport sample app for the design reference) - Typography refined: passport sample app font stack, adjusted sizes and line-heights for readability - Elevation applied to panels with subtle shadows and borders - Primary button colour matched to the passport sample app (no more cyan accent) - Input styling (including number inputs) normalised across the Setup panel and the method panels - Header subtitle removed — redundant given the page title Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/audience/sdk/demo/demo.css | 124 ++++++++++++++++---------- packages/audience/sdk/demo/index.html | 1 - 2 files changed, 75 insertions(+), 50 deletions(-) diff --git a/packages/audience/sdk/demo/demo.css b/packages/audience/sdk/demo/demo.css index 4f39abafed..bd9b293b99 100644 --- a/packages/audience/sdk/demo/demo.css +++ b/packages/audience/sdk/demo/demo.css @@ -1,21 +1,32 @@ :root { - --bg: #0b0d10; - --panel-bg: #13171c; - --panel-border: #1f2630; - --panel-border-hover: #2a3440; - --text: #e4e7eb; - --text-muted: #8b96a4; - --text-dim: #5c6876; - --accent: #4b8eff; - --accent-hover: #6aa1ff; - --accent-active: #3573dc; - --ok: #3cc48a; - --warn: #f5b13b; - --err: #ef5c6e; - --input-bg: #0b0e13; - --input-border: #24303e; - --mono: ui-monospace, 'SF Mono', Menlo, Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, monospace; - --sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; + /* Palette from @biom3/design-tokens base-onLight.global.css */ + --bg: #f7f8fa; /* was #ffffff — subtle cool off-white so panels can float */ + --panel-bg: #ffffff; /* was #f1f1f1 — panels are pure white to pop against bg */ + --panel-border: #e6e8ec; /* was #e7e7e7 — slightly cooler */ + --panel-border-hover: #0d0d0d1a; /* translucent-standard-200 */ + --text: #131313; /* brand-1 */ + --text-muted: #868686; /* brand-4 */ + --text-dim: #a8a8a8; /* mid-gray — brand-3 (#E0E0E0) is illegible on white */ + --accent: #131313; /* near-black, matches biom3 brand-1 / Button primary */ + --accent-hover: #2a2a2a; /* slightly lighter black on hover */ + --accent-active: #000000; /* pure black on active */ + --ok: #148530; /* status-success-bright (onLight) */ + --warn: #a07a00; /* attention amber — FBFF6D is illegible on white; dark amber for legibility */ + --err: #bb3049; /* status-fatal-bright (onLight) */ + --input-bg: #ffffff; /* was dark — inputs are white like cards */ + --input-border: #d4d7dd; /* was dark — light gray border */ + --mono: 'Roboto Mono', ui-monospace, 'SF Mono', Menlo, Monaco, Consolas, monospace; + --sans: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; + + /* Elevation / shadow — synthesized to match biom3 light-mode aesthetic. + biom3 exposes these via component props, not design tokens, so we + approximate them with the conventional material-design-esque + subtle-light-blur values. */ + --shadow-panel: 0 1px 3px rgba(17, 24, 39, 0.06), 0 1px 2px rgba(17, 24, 39, 0.04); + --shadow-panel-hover: 0 4px 12px rgba(17, 24, 39, 0.08); + --shadow-button: 0 1px 2px rgba(17, 24, 39, 0.06); + --shadow-button-hover: 0 2px 6px rgba(17, 24, 39, 0.1); + --shadow-input-inset: inset 0 1px 2px rgba(17, 24, 39, 0.03); } * { box-sizing: border-box; } @@ -41,28 +52,24 @@ header { } h1 { - font-size: 22px; - font-weight: 600; - margin: 0 0 4px; -} - -.subtitle { - color: var(--text-muted); - font-size: 12px; - font-family: var(--mono); + font-size: 28px; + font-weight: 700; + letter-spacing: -0.01em; + margin: 0 0 6px; } .status-bar { background: var(--panel-bg); border: 1px solid var(--panel-border); - border-radius: 8px; - padding: 12px 16px; + border-radius: 12px; + padding: 14px 20px; margin-bottom: 16px; display: flex; flex-wrap: wrap; - gap: 16px; + gap: 20px; font-size: 13px; font-family: var(--mono); + box-shadow: var(--shadow-panel); } .status-bar > div { @@ -90,18 +97,19 @@ h1 { .panel { background: var(--panel-bg); border: 1px solid var(--panel-border); - border-radius: 8px; - padding: 16px; + border-radius: 12px; + padding: 20px 24px; margin-bottom: 16px; + box-shadow: var(--shadow-panel); } .panel-title { - font-size: 12px; - font-weight: 600; + font-size: 11px; + font-weight: 700; text-transform: uppercase; - letter-spacing: 0.06em; + letter-spacing: 0.08em; color: var(--text-muted); - margin: 0 0 12px; + margin: 0 0 16px; } .field { @@ -114,26 +122,34 @@ h1 { .field label { font-size: 12px; color: var(--text-muted); + font-weight: 500; + letter-spacing: 0.01em; + text-transform: uppercase; } .field input[type="text"], +.field input[type="number"], .field textarea, .field select { background: var(--input-bg); border: 1px solid var(--input-border); color: var(--text); - padding: 8px 10px; - border-radius: 6px; + padding: 10px 12px; + border-radius: 8px; font-family: var(--mono); font-size: 13px; + box-shadow: var(--shadow-input-inset); + transition: border-color 0.15s ease, box-shadow 0.15s ease; resize: vertical; } .field input[type="text"]:focus, +.field input[type="number"]:focus, .field textarea:focus, .field select:focus { outline: none; border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(19, 19, 19, 0.1); } .field textarea { @@ -174,57 +190,67 @@ h1 { button { background: var(--accent); - color: #fff; + color: #ffffff; border: none; - padding: 8px 16px; - border-radius: 6px; + padding: 9px 18px; + border-radius: 8px; font-family: var(--sans); font-size: 13px; - font-weight: 500; + font-weight: 600; cursor: pointer; - transition: background 0.12s ease; + box-shadow: var(--shadow-button); + transition: background 0.15s ease, box-shadow 0.15s ease, transform 0.08s ease; } button:hover:not(:disabled) { background: var(--accent-hover); + box-shadow: var(--shadow-button-hover); } button:active:not(:disabled) { background: var(--accent-active); + box-shadow: var(--shadow-button); + transform: translateY(1px); } button:disabled { background: var(--panel-border); color: var(--text-dim); cursor: not-allowed; + box-shadow: none; } button.secondary { - background: transparent; - border: 1px solid var(--panel-border-hover); + background: var(--panel-bg); + border: 1px solid var(--panel-border); color: var(--text); + box-shadow: var(--shadow-button); } button.secondary:hover:not(:disabled) { - background: var(--panel-border); + background: #f3f4f6; + border-color: #d4d7dd; + box-shadow: var(--shadow-button-hover); } button.active { background: var(--accent-active); - box-shadow: 0 0 0 2px var(--accent) inset; + color: #ffffff; + box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.25) inset, var(--shadow-button); } .log { background: var(--input-bg); - border: 1px solid var(--input-border); - border-radius: 6px; + border: 1px solid var(--panel-border); + border-radius: 8px; font-family: var(--mono); font-size: 12px; height: 360px; min-height: 120px; overflow: auto; - padding: 12px; + padding: 14px 16px; resize: vertical; + box-shadow: var(--shadow-input-inset); } .log-entry { diff --git a/packages/audience/sdk/demo/index.html b/packages/audience/sdk/demo/index.html index b288322d38..7465e4c330 100644 --- a/packages/audience/sdk/demo/index.html +++ b/packages/audience/sdk/demo/index.html @@ -12,7 +12,6 @@

Immutable Audience SDK — Demo

-
Interactive harness for every public method of @imtbl/audience
From ca6a102bab42cd76d548aa8d57b476ca329c75e0 Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Thu, 9 Apr 2026 09:08:51 +1000 Subject: [PATCH 07/20] feat(audience): add demo footer, event log polish, and alias validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final UX polish for the demo: Footer and accessibility - Footer renders the SDK version (read from SDK_VERSION exported from cdn.ts — adds the const + a cdn.test.ts for the guard) - Event log marked with aria-live="polite" so screen readers announce new entries Event log - Copy button copies the full session's log to the clipboard (named just 'Copy' — clearer than a longer label) - Auto-scroll to bottom on new entries, but only while the user is already at the bottom — if they scroll up to inspect older events, auto-scroll locks so the view doesn't jump away Alias validation - Real-time check on the Alias button: disabled while either ID is empty or (fromId, fromType) === (toId, toType). Mirrors core's isAliasValid() so the user gets immediate feedback instead of discovering the problem after clicking Cleanup - Remove dead #panel-slot selectors from demo.css and the empty
from index.html (never used) Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/audience/sdk/demo/demo.css | 31 ++++++++-- packages/audience/sdk/demo/demo.js | 87 ++++++++++++++++++++++++++- packages/audience/sdk/demo/index.html | 11 +++- packages/audience/sdk/src/cdn.test.ts | 3 + packages/audience/sdk/src/cdn.ts | 3 + 5 files changed, 128 insertions(+), 7 deletions(-) diff --git a/packages/audience/sdk/demo/demo.css b/packages/audience/sdk/demo/demo.css index bd9b293b99..276953615d 100644 --- a/packages/audience/sdk/demo/demo.css +++ b/packages/audience/sdk/demo/demo.css @@ -340,13 +340,11 @@ button.active { background: var(--accent); } - .controls > .panel, - .controls > #panel-slot { + .controls > .panel { margin-bottom: 16px; } - .controls > .panel:last-child, - .controls > #panel-slot:last-child { + .controls > .panel:last-child { margin-bottom: 0; } @@ -371,3 +369,28 @@ button.active { resize: vertical; } } + +footer { + text-align: center; + color: var(--text-muted); + font-size: 12px; + padding: 24px 0 8px; + font-family: var(--mono); +} + +footer a { + color: var(--text-muted); + text-decoration: none; + border-bottom: 1px dotted var(--text-muted); + transition: color 0.12s ease, border-color 0.12s ease; +} + +footer a:hover { + color: var(--text); + border-bottom-color: var(--text); +} + +footer .footer-sep { + margin: 0 8px; + color: var(--text-dim); +} diff --git a/packages/audience/sdk/demo/demo.js b/packages/audience/sdk/demo/demo.js index f2177fe5c7..735387b7e4 100644 --- a/packages/audience/sdk/demo/demo.js +++ b/packages/audience/sdk/demo/demo.js @@ -16,6 +16,7 @@ var Audience = window.ImmutableAudience.Audience; var IdentityType = window.ImmutableAudience.IdentityType; + var SDK_VERSION = (window.ImmutableAudience && window.ImmutableAudience.version) || 'unknown'; // State var audience = null; @@ -24,6 +25,12 @@ var logEntries = []; var MAX_LOG_ENTRIES = 500; + // Track whether the user has scrolled up inside the event log. When true, + // renderLog stops auto-pinning to the bottom so new entries don't yank the + // user away from whatever they were reading. + var logAutoScroll = true; + var LOG_BOTTOM_THRESHOLD = 20; // px from bottom still counts as "at the bottom" + // DOM helpers function $(id) { return document.getElementById(id); } @@ -35,6 +42,12 @@ return el; } + function isLogAtBottom() { + var el = $('log'); + if (!el) return true; + return el.scrollHeight - el.scrollTop - el.clientHeight <= LOG_BOTTOM_THRESHOLD; + } + function getRadio(name) { var radios = document.querySelectorAll('input[name="' + name + '"]'); for (var i = 0; i < radios.length; i++) { @@ -211,7 +224,9 @@ container.appendChild(entry); } - container.scrollTop = container.scrollHeight; + if (logAutoScroll) { + container.scrollTop = container.scrollHeight; + } var countText = logEntries.length + ' entries'; if (logEntries.length >= MAX_LOG_ENTRIES) { countText += ' (capped at ' + MAX_LOG_ENTRIES + ')'; @@ -219,8 +234,43 @@ text($('log-count'), countText); } + function onCopyLog() { + var btn = $('btn-copy-log'); + if (!btn) return; + var originalText = btn.textContent; + function flashLabel(msg) { + btn.textContent = msg; + setTimeout(function () { + btn.textContent = originalText; + }, 1500); + } + + var payload; + try { + payload = JSON.stringify(logEntries, null, 2); + } catch (err) { + log('WARN', 'Copy session failed: ' + String(err && err.message || err), 'warn'); + flashLabel('Copy failed'); + return; + } + + if (!navigator.clipboard || !navigator.clipboard.writeText) { + log('WARN', 'Clipboard API unavailable in this browser', 'warn'); + flashLabel('Not supported'); + return; + } + + navigator.clipboard.writeText(payload).then(function () { + flashLabel('Copied!'); + }).catch(function (err) { + log('WARN', 'Copy session failed: ' + String(err && err.message || err), 'warn'); + flashLabel('Copy failed'); + }); + } + function clearLog() { logEntries = []; + logAutoScroll = true; renderLog(); } @@ -260,6 +310,21 @@ initBtn.disabled = pkInput.value.trim().length === 0; } + // Sync the Alias button's enabled state based on from/to inputs. + // Mirrors core's isAliasValid: button is disabled if the SDK is not initialised, + // if either ID is empty, or if (fromId, fromType) === (toId, toType). + function syncAliasButton() { + var btn = $('btn-alias'); + if (!audience) { btn.disabled = true; return; } + var fromId = $('alias-from-id').value.trim(); + var toId = $('alias-to-id').value.trim(); + var fromType = $('alias-from-type').value; + var toType = $('alias-to-type').value; + if (!fromId || !toId) { btn.disabled = true; return; } + if (fromId === toId && fromType === toType) { btn.disabled = true; return; } + btn.disabled = false; + } + // Enable/disable controls based on init state function setInitState(on) { $('btn-init').disabled = on; @@ -279,6 +344,10 @@ var consentRadios = document.querySelectorAll('input[name="initial-consent"]'); for (var j = 0; j < consentRadios.length; j++) consentRadios[j].disabled = on; if (!on) syncInitEnabled(); + // Alias button needs the finer-grained check (inputs + equality). Called + // unconditionally because syncAliasButton handles both the enabled and + // disabled cases internally. + syncAliasButton(); } // onError handler passed to Audience.init @@ -518,7 +587,11 @@ $('btn-shutdown').addEventListener('click', onShutdown); $('btn-reset').addEventListener('click', onReset); $('btn-flush').addEventListener('click', onFlush); + $('btn-copy-log').addEventListener('click', onCopyLog); $('btn-clear-log').addEventListener('click', clearLog); + $('log').addEventListener('scroll', function () { + logAutoScroll = isLogAtBottom(); + }); $('btn-consent-none').addEventListener('click', function () { onSetConsent('none'); }); $('btn-consent-anon').addEventListener('click', function () { onSetConsent('anonymous'); }); @@ -527,12 +600,24 @@ $('btn-page').addEventListener('click', onPage); $('btn-track').addEventListener('click', onTrack); + var versionEl = $('sdk-version'); + if (versionEl) versionEl.textContent = SDK_VERSION; + populateIdentityDropdowns(); initDemoGutter(); $('btn-identify').addEventListener('click', onIdentify); $('btn-identify-traits').addEventListener('click', onIdentifyTraits); $('btn-alias').addEventListener('click', onAlias); + // Real-time alias validity: disable the button when either ID is empty or + // when (fromId, fromType) === (toId, toType). Matches the design spec and + // mirrors the SDK's isAliasValid() — user gets immediate feedback instead + // of discovering the problem only after clicking. + $('alias-from-id').addEventListener('input', syncAliasButton); + $('alias-to-id').addEventListener('input', syncAliasButton); + $('alias-from-type').addEventListener('change', syncAliasButton); + $('alias-to-type').addEventListener('change', syncAliasButton); + // Enable Init only when the publishable key input has non-whitespace content. $('pk').addEventListener('input', syncInitEnabled); syncInitEnabled(); diff --git a/packages/audience/sdk/demo/index.html b/packages/audience/sdk/demo/index.html index 7465e4c330..025b7e0fb7 100644 --- a/packages/audience/sdk/demo/index.html +++ b/packages/audience/sdk/demo/index.html @@ -144,7 +144,6 @@

Alias

-
@@ -154,12 +153,20 @@

Event Log

0 entries
+
-
+
+ diff --git a/packages/audience/sdk/src/cdn.test.ts b/packages/audience/sdk/src/cdn.test.ts index 23d46a85cf..710b8b1dec 100644 --- a/packages/audience/sdk/src/cdn.test.ts +++ b/packages/audience/sdk/src/cdn.test.ts @@ -24,12 +24,15 @@ describe('cdn entry point', () => { Audience: { init: Function }; AudienceError: typeof Error; IdentityType: Record; + version: string; }; }).ImmutableAudience; expect(g).toBeDefined(); expect(typeof g!.Audience.init).toBe('function'); expect(g!.IdentityType.Passport).toBe('passport'); + expect(typeof g!.version).toBe('string'); + expect(g!.version.length).toBeGreaterThan(0); expect(g!.IdentityType.Steam).toBe('steam'); expect(g!.IdentityType.Custom).toBe('custom'); diff --git a/packages/audience/sdk/src/cdn.ts b/packages/audience/sdk/src/cdn.ts index d834d38864..1f590b2ea2 100644 --- a/packages/audience/sdk/src/cdn.ts +++ b/packages/audience/sdk/src/cdn.ts @@ -13,11 +13,13 @@ import { AudienceError, IdentityType } from '@imtbl/audience-core'; import { Audience } from './sdk'; +import { LIBRARY_VERSION } from './config'; type GlobalShape = { Audience: typeof Audience; AudienceError: typeof AudienceError; IdentityType: typeof IdentityType; + version: string; }; // globalThis is ES2020; tsup targets es2018, so provide a runtime fallback @@ -36,5 +38,6 @@ if (globalObj.ImmutableAudience) { Audience, AudienceError, IdentityType, + version: LIBRARY_VERSION, }; } From e87e32c7326f224eb0c1575116c51da01bb0f34a Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Wed, 8 Apr 2026 23:48:16 +1000 Subject: [PATCH 08/20] docs(audience): add package and demo READMEs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two READMEs: packages/audience/sdk/README.md Package-level usage doc. Install (npm + CDN), quick start example, public method list with short signatures, consent level behaviour table, auto-tracked event list, cookie reference, and a pointer to the demo. packages/audience/sdk/demo/README.md Demo harness usage doc. How to run (pnpm demo → serves localhost:3456), test publishable keys for dev + sandbox, a step-by-step 'what to try' script, environments table, troubleshooting for the common issues (bundle failing to load, 400/403 from the API, no BigQuery data), and a files layout section. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/audience/sdk/README.md | 145 +++++++++++++++++++++++++++ packages/audience/sdk/demo/README.md | 67 +++++++++++++ 2 files changed, 212 insertions(+) create mode 100644 packages/audience/sdk/README.md create mode 100644 packages/audience/sdk/demo/README.md diff --git a/packages/audience/sdk/README.md b/packages/audience/sdk/README.md new file mode 100644 index 0000000000..c853c17a4d --- /dev/null +++ b/packages/audience/sdk/README.md @@ -0,0 +1,145 @@ +# @imtbl/audience + +Consent-aware event tracking and identity resolution for Immutable studios. + +> **Pre-release.** This package is at version `0.0.0`. The API is stabilizing but breaking changes may still land before the first npm publish. + +## Install + +```sh +npm install @imtbl/audience +# or +pnpm add @imtbl/audience +# or +yarn add @imtbl/audience +``` + +Once published to npm, you'll be able to load the package via CDN (no bundler required): + +```html + + + +``` + +Until the first npm release, you can build the CDN bundle locally from this repo: `cd packages/audience/sdk && pnpm build`. The output is at `dist/cdn/imtbl-audience.global.js`. See `demo/README.md` for the interactive demo that loads it. + +## Quickstart + +```ts +import { Audience, IdentityType } from '@imtbl/audience'; + +const audience = Audience.init({ + publishableKey: 'pk_imapik-...', + environment: 'sandbox', + consent: 'anonymous', // or 'none' until the user opts in + debug: true, + onError: (err) => { + console.error('[audience]', err.code, err.status, err.responseBody); + }, +}); + +// Track a page view +audience.page({ section: 'marketplace' }); + +// Track a custom event +audience.track('purchase_completed', { sku: 'pack-1', usd: 9.99 }); + +// Upgrade consent and identify the user +audience.setConsent('full'); +audience.identify('player-7721', IdentityType.Passport, { plan: 'premium' }); + +// Link a previous identity +audience.alias( + { id: '76561198012345', identityType: IdentityType.Steam }, + { id: 'player-7721', identityType: IdentityType.Passport }, +); + +// On logout +audience.reset(); + +// On app unmount +audience.shutdown(); +``` + +## API + +### `Audience.init(config): Audience` + +Creates and starts the SDK. `config` is an `AudienceConfig`: + +| Field | Type | Required | Description | +|---|---|---|---| +| `publishableKey` | `string` | yes | Publishable API key from Immutable Hub (prefix: `pk_imapik-`). | +| `environment` | `'dev' \| 'sandbox' \| 'production'` | yes | Backend to target. | +| `consent` | `'none' \| 'anonymous' \| 'full'` | no | Initial consent level. Defaults to `'none'`. | +| `debug` | `boolean` | no | Log every SDK call and flush to the browser console. | +| `cookieDomain` | `string` | no | Cookie domain for cross-subdomain sharing (e.g. `.studio.com`). | +| `flushInterval` | `number` | no | Queue flush interval in ms. Defaults to `5000`. | +| `flushSize` | `number` | no | Batch size that triggers an automatic flush. Defaults to `20`. | +| `onError` | `(err: AudienceError) => void` | no | Called when a flush or consent sync fails. | + +### Methods + +- **`page(properties?)`** — record a page view. Call on every route change. +- **`track(eventName, properties?)`** — record a custom event. +- **`identify(id, identityType, traits?)`** — tell the SDK who this player is. Requires `full` consent. +- **`identify(traits)`** — traits-only overload for anonymous profile updates. +- **`alias({id, identityType}, {id, identityType})`** — link two identities that belong to the same player. +- **`setConsent(status)`** — update the consent level in response to a banner. +- **`reset()`** — call on logout; rotates the anonymous ID and clears state. +- **`flush()`** — force-send queued events. +- **`shutdown()`** — stop the SDK and drain the queue. + +## Identity types + +The `identityType` argument to `identify()` and `alias()` must be one of: + +| Value | Description | +|---|---| +| `passport` | Immutable Passport ID | +| `steam` | Steam ID (64-bit) | +| `epic` | Epic Games account ID | +| `google` | Google account ID | +| `apple` | Apple ID | +| `discord` | Discord user ID | +| `email` | Email address | +| `custom` | Studio-defined custom ID | + +Import the `IdentityType` enum to reference these at runtime: + +```ts +import { IdentityType } from '@imtbl/audience'; + +IdentityType.Passport; // 'passport' +``` + +## Error handling + +`AudienceConfig.onError` receives an `AudienceError` with these fields: + +```ts +class AudienceError extends Error { + readonly code: 'FLUSH_FAILED' | 'CONSENT_SYNC_FAILED' | 'NETWORK_ERROR' | 'VALIDATION_REJECTED'; + readonly status: number; // HTTP status, 0 for network failure + readonly endpoint: string; // full URL that failed + readonly responseBody?: unknown; // parsed JSON body from the backend + readonly cause?: unknown; // original fetch error on network failure +} +``` + +Errors are delivered asynchronously (after the failing flush completes). Throwing from `onError` is safe — the SDK catches and suppresses callback exceptions. + +## Demo + +There's an interactive demo under `demo/` that exercises every public method against the real backend. See `demo/README.md` for instructions. + +## License + +Apache-2.0 diff --git a/packages/audience/sdk/demo/README.md b/packages/audience/sdk/demo/README.md new file mode 100644 index 0000000000..3c361eb170 --- /dev/null +++ b/packages/audience/sdk/demo/README.md @@ -0,0 +1,67 @@ +# @imtbl/audience — Demo + +Single-page interactive harness that exercises every public method on the `Audience` class against the real Immutable backend. + +## Run + +```sh +cd packages/audience/sdk +pnpm demo +``` + +This runs `pnpm build` (ESM + CDN bundle + types) then serves the package root on `http://localhost:3456`. Open: + +``` +http://localhost:3456/demo/ +``` + +Stop the server with `Ctrl+C`. + +## Test publishable keys + +These are test-only keys registered for audience tracking. Safe to commit and share. + +| Environment | Key | +|---|---| +| `dev` | `pk_imapik-test-Xei06NzJZClzQogVXlKQ` | +| `sandbox` | `pk_imapik-test-5ss4GpFy-n@$$Ye3LSox` | + +## What to try + +1. Paste a key, pick `sandbox`, set initial consent to `anonymous`, click **Init**. +2. Watch the event log: you'll see `INIT`, `TRACK session_start`, and `FLUSH ok`. Check the browser DevTools Network tab — `POST /v1/audience/messages` should return 200. +3. Click **Call page()** with no properties → `PAGE` entry + 200 response. +4. Enter `{"section":"marketplace"}` in the page properties textarea → `PAGE {section: marketplace}`. +5. Track a custom event with properties → `TRACK`. +6. Set consent to `full` → `PUT /v1/audience/tracking-consent` returns 204. +7. Identify a user (any made-up ID, type `passport`, optional traits) → status bar's User ID updates. +8. Try Alias with a Steam ID → Passport ID → `ALIAS` entry. +9. Click **Reset** → anonymous ID rotates, session end + start fire. +10. Click **Shutdown** → session end fires, buttons flip off. + +## Environments + +| Env | API URL | Consent PUT | +|---|---|---| +| `dev` | `api.dev.immutable.com` | **known broken — returns 500.** Use `sandbox` to exercise consent sync. | +| `sandbox` | `api.sandbox.immutable.com` | works | + +## Troubleshooting + +- **`window.ImmutableAudience is undefined`** in the demo page: the CDN bundle failed to load. Re-run `pnpm build` from `packages/audience/sdk` and confirm `dist/cdn/imtbl-audience.global.js` exists. +- **`POST /v1/audience/messages` returns 400**: the publishable key format is wrong. Must start with `pk_imapik-`. +- **`POST /v1/audience/messages` returns 403**: the key isn't registered for audience tracking on the backend. Use one of the keys in the table above. +- **Identify button is a no-op**: consent is not `full`. Click **Set full** first. +- **No events in BigQuery after 30s**: events go through SQS → Pub/Sub → BigQuery. BQ access requires `roles/bigquery.dataViewer` on `dev-im-cdp`. If you don't have it, the API ack (`POST /messages` 200) is your E2E confirmation. + +## Files + +``` +demo/ + index.html # single page, loads ../dist/cdn/imtbl-audience.global.js + demo.js # vanilla ES2020, no modules; reads window.ImmutableAudience + demo.css # light theme, hand-written CSS, no external deps + README.md # this file +``` + +Security: all user-controlled inputs (event names, traits, publishable keys) are rendered via `textContent` / `createElement`. No `innerHTML` anywhere on user data. CSP meta tag restricts `connect-src` to the dev and sandbox API origins. From 60c8827aa4d09d8804b8df2319f6036660fd14a1 Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Thu, 9 Apr 2026 09:10:20 +1000 Subject: [PATCH 09/20] fix(audience): set demo CSP to dev and sandbox audience API only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Locks the demo's Content-Security-Policy to the minimum needed to run — audience API endpoints only, nothing else. - default-src 'self' - script-src 'self' (no inline scripts, no eval) - style-src 'self' (no inline styles) - connect-src limited to https://api.dev.immutable.com and https://api.sandbox.immutable.com Explicitly NOT in connect-src: api.immutable.com. The @imtbl/metrics SDK bundled into the CDN posts its own telemetry there, and those calls will be blocked by the browser with a CSP violation log. That is intentional — the demo is a harness, not a product, and the metrics bundle travelling along with the audience SDK shouldn't phone home from a localhost demo page. The violations do not affect demo behaviour; the audience calls still succeed. README's Security section explains this so the CSP violation lines in the console aren't mistaken for a bug. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/audience/sdk/demo/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/audience/sdk/demo/README.md b/packages/audience/sdk/demo/README.md index 3c361eb170..4f7403c336 100644 --- a/packages/audience/sdk/demo/README.md +++ b/packages/audience/sdk/demo/README.md @@ -64,4 +64,4 @@ demo/ README.md # this file ``` -Security: all user-controlled inputs (event names, traits, publishable keys) are rendered via `textContent` / `createElement`. No `innerHTML` anywhere on user data. CSP meta tag restricts `connect-src` to the dev and sandbox API origins. +Security: all user-controlled inputs (event names, traits, publishable keys) are rendered via `textContent` / `createElement`. No `innerHTML` anywhere on user data. The CSP meta tag restricts `connect-src` to the dev and sandbox audience API origins only (`api.dev.immutable.com`, `api.sandbox.immutable.com`). `@imtbl/metrics` SDK telemetry is bundled into the CDN and posts to `api.immutable.com`; those calls will be blocked by the browser with a CSP violation log, which is intentional and does not affect demo behavior. From 28d0c5c44209987bbb60ea3555d262edece4d4bd Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Thu, 9 Apr 2026 22:16:51 +1000 Subject: [PATCH 10/20] refactor(audience): move demo to sibling sdk-sample-app package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review feedback on #2837 from @nattb8: the interactive demo should live in its own workspace package (matching the repo convention used by passport/sdk-sample-app, checkout/sdk-sample-app, dex/sdk-sample-app, bridge/bridge-sample-app) rather than inside the published @imtbl/audience package directory. Why this matters beyond aesthetics: - @imtbl/audience is a published npm package with a dedicated build pipeline (#2838): local tsup.config.js, prepack/postpack scripts that strip workspace deps from package.json, rollup-plugin-dts to inline type re-exports. The sdk package directory should stay focused on shipping artifacts; a demo harness is not one. - The demo was vanilla ES2020 (no TS, no modules, loaded via a script tag) while the sdk package is pure TypeScript. Co-locating them forced sdk/.eslintignore + an .eslintrc.cjs override block just to keep lint-staged from trying to parse demo/*.js with the TS parser. Both pieces of config disappear with this move. - The existing repo-wide root .eslintignore already has a `**sample-app**/` glob (for passport/sdk-sample-app and friends), so the new directory is automatically excluded from root lint with zero local config. Addresses the reviewer's secondary concern — "this is included in the CDN bundle too" — at the structural level. For the record, verified the demo was never literally bundled into dist/cdn/imtbl-audience.global.js: src/cdn.ts imports only ./sdk, ./config, and @imtbl/audience-core, and `files: ["dist"]` in package.json already excluded demo/ from the npm tarball. Confirmed by packing the sdk and inspecting the tarball — it only contains dist/browser, dist/cdn, dist/node, dist/types, plus README.md, LICENSE.md, and package.json. Changes: New package — packages/audience/sdk-sample-app/ - package.json: private, @imtbl/audience as a workspace:* devDep, engines node >= 20.11, `pnpm dev` builds @imtbl/audience then runs the local serve script - serve.mjs: ~90-line Node static server using only the stdlib. Serves the sample-app's own files from ./, and routes /vendor/ to ../sdk/dist/cdn/ so the HTML can load the CDN bundle via a same-origin URL (keeps the demo's CSP happy). Blocks serve.mjs, package.json, and node_modules from being served, plus path traversal attempts via decodeURIComponent + a resolve/startsWith guard. Verified with curl: 200 for /, /demo.css, /demo.js and /vendor/imtbl-audience.global.js(.map); 403 for /package.json, /serve.mjs, /vendor/../../package.json, /%2e%2e/secret; 404 for /nonexistent.html. - index.html, demo.js, demo.css, README.md: git-renamed from packages/audience/sdk/demo/. The only content change is in index.html — the + diff --git a/packages/audience/sdk-sample-app/package.json b/packages/audience/sdk-sample-app/package.json new file mode 100644 index 0000000000..70f446c33a --- /dev/null +++ b/packages/audience/sdk-sample-app/package.json @@ -0,0 +1,17 @@ +{ + "name": "@imtbl/audience-sdk-sample-app", + "description": "Interactive demo harness for @imtbl/audience. Exercises every public method on the Audience class against the real dev/sandbox backend.", + "version": "0.0.0", + "author": "Immutable", + "private": true, + "engines": { + "node": ">=20.11.0" + }, + "devDependencies": { + "@imtbl/audience": "workspace:*" + }, + "scripts": { + "dev": "pnpm --filter @imtbl/audience run build && node ./serve.mjs", + "start": "pnpm dev" + } +} diff --git a/packages/audience/sdk-sample-app/serve.mjs b/packages/audience/sdk-sample-app/serve.mjs new file mode 100644 index 0000000000..a82f7aee0a --- /dev/null +++ b/packages/audience/sdk-sample-app/serve.mjs @@ -0,0 +1,94 @@ +#!/usr/bin/env node +/* + * Static file server for the audience SDK sample app. + * + * Serves the sample-app's own files from this directory, and exposes the + * CDN bundle (built into ../sdk/dist/cdn/) under /vendor/. This keeps the + * sdk's dist/ as the single source of truth — no copy step, no gitignored + * artifacts — while letting the demo's ``` -Until the first npm release, you can build the CDN bundle locally from this repo: `cd packages/audience/sdk && pnpm build`. The output is at `dist/cdn/imtbl-audience.global.js`. See `demo/README.md` for the interactive demo that loads it. +Until the first npm release, you can build the CDN bundle locally from this repo: `pnpm --filter @imtbl/audience run build`. The output is at `dist/cdn/imtbl-audience.global.js`. See `../sdk-sample-app/README.md` for the interactive demo that loads it. ## Quickstart @@ -138,7 +138,7 @@ Errors are delivered asynchronously (after the failing flush completes). Throwin ## Demo -There's an interactive demo under `demo/` that exercises every public method against the real backend. See `demo/README.md` for instructions. +There's an interactive demo in the sibling workspace package `@imtbl/audience-sdk-sample-app` that exercises every public method against the real backend. See `../sdk-sample-app/README.md` for instructions. ## License diff --git a/packages/audience/sdk/package.json b/packages/audience/sdk/package.json index 1bf3ffcf00..23e2678baa 100644 --- a/packages/audience/sdk/package.json +++ b/packages/audience/sdk/package.json @@ -56,7 +56,6 @@ "transpile": "tsup --config tsup.config.js", "transpile:cdn": "tsup --config ./tsup.cdn.js", "typegen": "tsc --customConditions default --emitDeclarationOnly --outDir dist/types && rollup -c rollup.dts.config.js && find dist/types -name '*.d.ts' ! -name 'index.d.ts' -delete", - "demo": "pnpm build && npx -y serve -l 3456 --cors .", "lint": "eslint ./src --ext .ts,.jsx,.tsx --max-warnings=0", "pack:root": "pnpm pack --pack-destination $(dirname $(pnpm root -w))", "prepack": "node scripts/prepack.mjs", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 31dab14f00..182ee19376 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1031,6 +1031,12 @@ importers: specifier: ^5.6.2 version: 5.6.2 + packages/audience/sdk-sample-app: + devDependencies: + '@imtbl/audience': + specifier: workspace:* + version: link:../sdk + packages/auth: dependencies: '@imtbl/generated-clients': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 09f5e8fee2..7ae4680e8e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -24,6 +24,7 @@ packages: - "packages/audience/core" - "packages/audience/pixel" - "packages/audience/sdk" + - "packages/audience/sdk-sample-app" - "packages/game-bridge" - "packages/webhook/sdk" - "packages/minting-backend/sdk" From 498d864dd3c4e29abae0693b61ec45c632a4b98f Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Sun, 12 Apr 2026 11:23:13 +1000 Subject: [PATCH 11/20] fix(audience): wire onError callback from config to queue and consent manager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README and demo page document onError as a config option, and the core infrastructure (MessageQueue.options.onError, createConsentManager's onError parameter, invokeOnError helper) already exists — but the web SDK's Audience class never passed the callback through. Any studio following the quickstart code would get a silently ignored handler. Adds onError to AudienceConfig and threads it into both MessageQueue options and createConsentManager so flush and consent-sync failures actually reach studio error handlers. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/audience/sdk/src/sdk.ts | 2 ++ packages/audience/sdk/src/types.ts | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/audience/sdk/src/sdk.ts b/packages/audience/sdk/src/sdk.ts index fe77bc1bf7..acaf7b908b 100644 --- a/packages/audience/sdk/src/sdk.ts +++ b/packages/audience/sdk/src/sdk.ts @@ -98,6 +98,7 @@ export class Audience { flushSize, { onFlush: (ok, count) => this.debug.logFlush(ok, count), + onError: config.onError, staleFilter: (m) => isTimestampValid(m.eventTimestamp), storagePrefix: '__imtbl_web_', }, @@ -111,6 +112,7 @@ export class Audience { environment, consentSource, consentLevel, + config.onError, ); this.attribution = collectAttribution(); diff --git a/packages/audience/sdk/src/types.ts b/packages/audience/sdk/src/types.ts index 2e6ad62f46..14a574aefc 100644 --- a/packages/audience/sdk/src/types.ts +++ b/packages/audience/sdk/src/types.ts @@ -1,4 +1,4 @@ -import type { Environment, ConsentLevel } from '@imtbl/audience-core'; +import type { Environment, ConsentLevel, AudienceError } from '@imtbl/audience-core'; /** Configuration for the Immutable Web SDK. */ export interface AudienceConfig { @@ -16,4 +16,6 @@ export interface AudienceConfig { flushInterval?: number; /** Number of queued messages that triggers an automatic flush. Defaults to 20. */ flushSize?: number; + /** Called when a flush or consent sync fails. Exceptions thrown from this callback are swallowed. */ + onError?: (err: AudienceError) => void; } From 98022166e8e327a7c7246ec95e2434893c192a99 Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Sun, 12 Apr 2026 11:24:50 +1000 Subject: [PATCH 12/20] fix(audience): use real types in CDN smoke test per review feedback Addresses nattb8's two inline review comments on cdn.test.ts: 1. Replace the loose inline type shape ({ Audience: { init: Function }, AudienceError: typeof Error, IdentityType: Record }) with a GlobalShape using typeof Audience, typeof AudienceError, and typeof IdentityType from their real modules. If the CDN global shape drifts from the actual SDK classes, the test now fails at compile time instead of silently passing at runtime. 2. Remove the `as any` cast on `new g!.AudienceError(...)`. With the real constructor type, TypeScript verifies the init object shape matches what AudienceError actually accepts. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/audience/sdk/src/cdn.test.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/audience/sdk/src/cdn.test.ts b/packages/audience/sdk/src/cdn.test.ts index 710b8b1dec..4eebd014b5 100644 --- a/packages/audience/sdk/src/cdn.test.ts +++ b/packages/audience/sdk/src/cdn.test.ts @@ -6,6 +6,15 @@ * itself is exercised by running `pnpm build` and loading the built file in * the demo page. */ +import type { AudienceError, IdentityType } from '@imtbl/audience-core'; +import type { Audience } from './sdk'; + +type GlobalShape = { + Audience: typeof Audience; + AudienceError: typeof AudienceError; + IdentityType: typeof IdentityType; + version: string; +}; describe('cdn entry point', () => { beforeEach(() => { @@ -20,12 +29,7 @@ describe('cdn entry point', () => { it('attaches the SDK surface to window.ImmutableAudience', async () => { await import('./cdn'); const g = (globalThis as unknown as { - ImmutableAudience?: { - Audience: { init: Function }; - AudienceError: typeof Error; - IdentityType: Record; - version: string; - }; + ImmutableAudience?: GlobalShape; }).ImmutableAudience; expect(g).toBeDefined(); @@ -37,7 +41,7 @@ describe('cdn entry point', () => { expect(g!.IdentityType.Custom).toBe('custom'); // AudienceError should be constructable and extend Error - const err = new (g!.AudienceError as any)({ + const err = new g!.AudienceError({ code: 'NETWORK_ERROR', message: 'test', status: 0, From a9ed0165ba77c75e3edf274bd0d67a3be15dceb2 Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Sun, 12 Apr 2026 11:25:52 +1000 Subject: [PATCH 13/20] feat(audience): export AudienceError and AudienceErrorCode from SDK package The CDN bundle already exposes AudienceError on the global, and the README documents it under "Error handling", but the npm package's index.ts didn't re-export it. TypeScript users couldn't type their onError callbacks without reaching into @imtbl/audience-core directly. Adds AudienceError (class) and AudienceErrorCode (type union) to the public exports so `import { AudienceError } from '@imtbl/audience'` works. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/audience/sdk/src/index.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/audience/sdk/src/index.ts b/packages/audience/sdk/src/index.ts index a76ca58a07..b2478776cb 100644 --- a/packages/audience/sdk/src/index.ts +++ b/packages/audience/sdk/src/index.ts @@ -1,4 +1,6 @@ export { Audience } from './sdk'; -export { IdentityType } from '@imtbl/audience-core'; +export { AudienceError, IdentityType } from '@imtbl/audience-core'; export type { AudienceConfig } from './types'; -export type { Environment, ConsentLevel, UserTraits } from '@imtbl/audience-core'; +export type { + AudienceErrorCode, Environment, ConsentLevel, UserTraits, +} from '@imtbl/audience-core'; From 96f53ed90f167ffd1d064644fc818028a19288f7 Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Sun, 12 Apr 2026 11:37:42 +1000 Subject: [PATCH 14/20] test(audience): add integration tests for onError callback forwarding Adds four tests that verify the onError callback wired in the previous commit actually reaches studio code end-to-end through httpSend: - flush failure (500) delivers FLUSH_FAILED - consent sync failure (503) delivers CONSENT_SYNC_FAILED - successful operations do not fire onError - exceptions thrown by the callback are swallowed (SDK keeps running) The core layer already tests the downstream machinery (toAudienceError, invokeOnError, queue retry semantics). These tests close the remaining gap: proving that Audience.init threads the callback into both the queue and the consent manager so it actually fires in a real SDK session. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/audience/sdk/src/sdk.test.ts | 87 +++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/packages/audience/sdk/src/sdk.test.ts b/packages/audience/sdk/src/sdk.test.ts index 1b8b370b58..ccedfce878 100644 --- a/packages/audience/sdk/src/sdk.test.ts +++ b/packages/audience/sdk/src/sdk.test.ts @@ -789,4 +789,91 @@ describe('Audience', () => { sdk.shutdown(); }); }); + + describe('onError', () => { + function failResponse(status: number) { + return { + ok: false, + status, + headers: new Headers({ 'content-type': 'application/json' }), + json: async () => ({ error: 'test failure' }), + text: async () => '{"error":"test failure"}', + }; + } + + it('fires onError with FLUSH_FAILED when flush returns 500', async () => { + const onError = jest.fn(); + const sdk = createSDK({ onError }); + + // Queue a message, then make the next fetch fail. + sdk.track('test_event'); + mockFetch.mockImplementationOnce(async (url: string, init?: RequestInit) => { + fetchCalls.push({ url, init: init ?? {} }); + return failResponse(500); + }); + await sdk.flush(); + + expect(onError).toHaveBeenCalledTimes(1); + expect(onError.mock.calls[0][0].code).toBe('FLUSH_FAILED'); + expect(onError.mock.calls[0][0].status).toBe(500); + + sdk.shutdown(); + }); + + it('fires onError with CONSENT_SYNC_FAILED when consent PUT fails', async () => { + const onError = jest.fn(); + // Start at none so setConsent triggers a consent sync. + mockFetch.mockImplementationOnce(async (url: string, init?: RequestInit) => { + fetchCalls.push({ url, init: init ?? {} }); + return failResponse(503); + }); + const sdk = createSDK({ consent: 'none', onError }); + + sdk.setConsent('anonymous'); + // notifyBackend is fire-and-forget; httpSend has two internal awaits + // (fetch + parseBody) plus the .then() that fires invokeOnError, + // so we need enough microtask yields for the full chain to settle. + await Promise.resolve(); // fetch resolves + await Promise.resolve(); // parseBody resolves + await Promise.resolve(); // httpSend returns + await Promise.resolve(); // .then() fires invokeOnError + + expect(onError).toHaveBeenCalledTimes(1); + expect(onError.mock.calls[0][0].code).toBe('CONSENT_SYNC_FAILED'); + expect(onError.mock.calls[0][0].status).toBe(503); + + sdk.shutdown(); + }); + + it('does not fire onError on successful operations', async () => { + const onError = jest.fn(); + const sdk = createSDK({ onError }); + + sdk.track('purchase', { value: 9.99 }); + await sdk.flush(); + + expect(onError).not.toHaveBeenCalled(); + + sdk.shutdown(); + }); + + it('swallows exceptions thrown by the onError callback', async () => { + const onError = jest.fn().mockImplementation(() => { + throw new Error('callback crashed'); + }); + const sdk = createSDK({ onError }); + + sdk.track('test_event'); + mockFetch.mockImplementationOnce(async (url: string, init?: RequestInit) => { + fetchCalls.push({ url, init: init ?? {} }); + return failResponse(500); + }); + + // Should not throw even though onError throws internally. + await expect(sdk.flush()).resolves.toBeUndefined(); + expect(onError).toHaveBeenCalledTimes(1); + + sdk.shutdown(); + }); + }); }); From 7af0c30f90095c9bf213caec45667a1bff9ef3da Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Sun, 12 Apr 2026 11:44:07 +1000 Subject: [PATCH 15/20] feat(audience): centralize event names and property types in events.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds AudienceEvents — a single const object that defines every standard event name — mirroring the pattern Play uses in its audience-sdk/events module. Studios get autocomplete, typo protection, and a consistent schema instead of scattering raw strings across their codebase. The object includes SDK-managed session events (imported from core so the strings stay in sync) and recommended studio events carried over from the Play integration: email_acquired, game_page_viewed, link_clicked, sign_in, wishlist_add, wishlist_remove. Each event has a typed property interface (EmailAcquiredProperties, LinkClickedProperties, etc.) so studios can optionally type-check the properties they pass to track(). sdk.ts now imports session constants from ./events instead of directly from @imtbl/audience-core, making events.ts the single source of truth. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/audience/sdk/src/events.ts | 70 +++++++++++++++++++++++++++++ packages/audience/sdk/src/index.ts | 9 ++++ packages/audience/sdk/src/sdk.ts | 7 ++- 3 files changed, 82 insertions(+), 4 deletions(-) create mode 100644 packages/audience/sdk/src/events.ts diff --git a/packages/audience/sdk/src/events.ts b/packages/audience/sdk/src/events.ts new file mode 100644 index 0000000000..72d73b9b57 --- /dev/null +++ b/packages/audience/sdk/src/events.ts @@ -0,0 +1,70 @@ +import { SESSION_START, SESSION_END } from '@imtbl/audience-core'; + +/** + * Standard event names for the Immutable Audience SDK. + * + * Session events (`SESSION_START`, `SESSION_END`) are fired automatically + * by the SDK — do not fire them manually. The remaining events are the + * recommended vocabulary for studio-fired tracking. Using these constants + * instead of raw strings gives you autocomplete, typo protection, and a + * consistent schema across all Immutable-powered apps. + */ +export const AudienceEvents = { + // --- SDK-managed (fired automatically) --- + + /** Fired automatically on init and reset. Do not call `track()` with this. */ + SESSION_START, + /** Fired automatically on shutdown. Do not call `track()` with this. */ + SESSION_END, + + // --- Recommended studio events --- + + /** A player connects an external account (Steam, Epic, Discord, Telegram) or opts into marketing emails. */ + EMAIL_ACQUIRED: 'email_acquired', + /** A player opens a game or content page (dedupe per page per session). */ + GAME_PAGE_VIEWED: 'game_page_viewed', + /** A player clicks an outbound link (Play Now, store, social). */ + LINK_CLICKED: 'link_clicked', + /** A player logs in via Passport or another provider. */ + SIGN_IN: 'sign_in', + /** A player follows / wishlists a game. */ + WISHLIST_ADD: 'wishlist_add', + /** A player unfollows / un-wishlists a game. */ + WISHLIST_REMOVE: 'wishlist_remove', +} as const; + +// --- Property interfaces for each event --- + +export interface EmailAcquiredProperties { + isLoggedIn: boolean; + source: string; +} + +export interface GamePageViewedProperties { + gameId: string; + gameName: string; + slug: string; + isLoggedIn: boolean; +} + +export interface LinkClickedProperties { + url: string; + label: string; + source: string; + isLoggedIn: boolean; + gameId?: string; +} + +export interface SignInProperties { + method?: string; +} + +export interface WishlistAddProperties { + gameId: string; + source?: string; + platform?: string; +} + +export interface WishlistRemoveProperties { + gameId: string; +} diff --git a/packages/audience/sdk/src/index.ts b/packages/audience/sdk/src/index.ts index b2478776cb..7fc2549275 100644 --- a/packages/audience/sdk/src/index.ts +++ b/packages/audience/sdk/src/index.ts @@ -1,6 +1,15 @@ export { Audience } from './sdk'; +export { AudienceEvents } from './events'; export { AudienceError, IdentityType } from '@imtbl/audience-core'; export type { AudienceConfig } from './types'; +export type { + EmailAcquiredProperties, + GamePageViewedProperties, + LinkClickedProperties, + SignInProperties, + WishlistAddProperties, + WishlistRemoveProperties, +} from './events'; export type { AudienceErrorCode, Environment, ConsentLevel, UserTraits, } from '@imtbl/audience-core'; diff --git a/packages/audience/sdk/src/sdk.ts b/packages/audience/sdk/src/sdk.ts index acaf7b908b..a0e5368160 100644 --- a/packages/audience/sdk/src/sdk.ts +++ b/packages/audience/sdk/src/sdk.ts @@ -27,9 +27,8 @@ import { collectAttribution, getOrCreateSession, createConsentManager, - SESSION_START, - SESSION_END, } from '@imtbl/audience-core'; +import { AudienceEvents } from './events'; import { DebugLogger } from './debug'; import type { AudienceConfig } from './types'; import { @@ -187,7 +186,7 @@ export class Audience { this.enqueue('track(session_start)', { ...this.baseMessage(), type: 'track', - eventName: SESSION_START, + eventName: AudienceEvents.SESSION_START, properties: { sessionId: this.sessionId, ...this.attribution, @@ -200,7 +199,7 @@ export class Audience { this.enqueue('track(session_end)', { ...this.baseMessage(), type: 'track', - eventName: SESSION_END, + eventName: AudienceEvents.SESSION_END, properties: { sessionId: this.sessionId, ...(this.sessionStartTime && { From 27afe6e60b996abc199072556b71b84999071674 Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Sun, 12 Apr 2026 12:06:48 +1000 Subject: [PATCH 16/20] fix(audience): serve .md files as text/plain in demo server The demo footer links to ./README.md but the MIME map didn't include .md, so the browser downloaded it as application/octet-stream instead of rendering it. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/audience/sdk-sample-app/serve.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/audience/sdk-sample-app/serve.mjs b/packages/audience/sdk-sample-app/serve.mjs index a82f7aee0a..a2926376cd 100644 --- a/packages/audience/sdk-sample-app/serve.mjs +++ b/packages/audience/sdk-sample-app/serve.mjs @@ -33,6 +33,7 @@ const MIME = { '.png': 'image/png', '.jpg': 'image/jpeg', '.json': 'application/json; charset=utf-8', + '.md': 'text/plain; charset=utf-8', }; /** From 8371b497befcb23eb88f7b31330f039bf0158ebc Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Sun, 12 Apr 2026 12:06:53 +1000 Subject: [PATCH 17/20] fix(audience): remove broken SDK README link from demo footer The ../README.md link resolved outside the serve root, so serve.mjs returned 403. The demo README link still works. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/audience/sdk-sample-app/index.html | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/audience/sdk-sample-app/index.html b/packages/audience/sdk-sample-app/index.html index 65a33d3e02..16510e0feb 100644 --- a/packages/audience/sdk-sample-app/index.html +++ b/packages/audience/sdk-sample-app/index.html @@ -101,6 +101,22 @@

Track

+
+

Studio Events

+
+ + +
+
+ + + + + + +
+
+

Identify

@@ -164,8 +180,6 @@

Event Log

@imtbl/audience 0.0.0 · Demo README - · - SDK README From 581a71dd7118266ec1097048587e71bb941d7374 Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Sun, 12 Apr 2026 12:07:05 +1000 Subject: [PATCH 18/20] docs(audience): show AudienceEvents constants in quickstart Adds an example of using the predefined event name constants alongside custom event strings, matching how Play uses the SDK. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/audience/sdk/README.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/audience/sdk/README.md b/packages/audience/sdk/README.md index 802a3d33fd..73c13e17ef 100644 --- a/packages/audience/sdk/README.md +++ b/packages/audience/sdk/README.md @@ -33,7 +33,7 @@ Until the first npm release, you can build the CDN bundle locally from this repo ## Quickstart ```ts -import { Audience, IdentityType } from '@imtbl/audience'; +import { Audience, AudienceEvents, IdentityType } from '@imtbl/audience'; const audience = Audience.init({ publishableKey: 'pk_imapik-...', @@ -51,6 +51,14 @@ audience.page({ section: 'marketplace' }); // Track a custom event audience.track('purchase_completed', { sku: 'pack-1', usd: 9.99 }); +// Use the predefined event vocabulary for standard actions +audience.track(AudienceEvents.LINK_CLICKED, { + url: 'https://store.example.com', + label: 'play_now', + source: 'game_page', + isLoggedIn: true, +}); + // Upgrade consent and identify the user audience.setConsent('full'); audience.identify('player-7721', IdentityType.Passport, { plan: 'premium' }); From 07f6b23de768d7dfdb691784f966936ea49a02d0 Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Sun, 12 Apr 2026 12:07:12 +1000 Subject: [PATCH 19/20] feat(audience): add studio events panel to demo One-click buttons for each recommended event (game_page_viewed, link_clicked, email_acquired, sign_in, wishlist_add, wishlist_remove) with realistic demo properties matching the Play integration. Shared game ID input lets you vary the gameId across events. Walkthrough updated to cover the new panel. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/audience/sdk-sample-app/README.md | 13 ++- packages/audience/sdk-sample-app/demo.js | 105 +++++++++++++++++++++ 2 files changed, 113 insertions(+), 5 deletions(-) diff --git a/packages/audience/sdk-sample-app/README.md b/packages/audience/sdk-sample-app/README.md index 97b9938db1..5a4b563774 100644 --- a/packages/audience/sdk-sample-app/README.md +++ b/packages/audience/sdk-sample-app/README.md @@ -29,11 +29,14 @@ These are test-only keys registered for audience tracking. Safe to commit and sh 3. Click **Call page()** with no properties → `PAGE` entry + 200 response. 4. Enter `{"section":"marketplace"}` in the page properties textarea → `PAGE {section: marketplace}`. 5. Track a custom event with properties → `TRACK`. -6. Set consent to `full` → `PUT /v1/audience/tracking-consent` returns 204. -7. Identify a user (any made-up ID, type `passport`, optional traits) → status bar's User ID updates. -8. Try Alias with a Steam ID → Passport ID → `ALIAS` entry. -9. Click **Reset** → anonymous ID rotates, session end + start fire. -10. Click **Shutdown** → session end fires, buttons flip off. +6. In **Studio Events**, click **game_page_viewed** → fires with demo gameId, gameName, slug, isLoggedIn. +7. Click **link_clicked** → fires with a demo URL, label, source, and gameId. +8. Click **email_acquired**, **sign_in**, **wishlist_add**, **wishlist_remove** → each fires with realistic demo properties matching the Play integration. +9. Set consent to `full` → `PUT /v1/audience/tracking-consent` returns 204. +10. Identify a user (any made-up ID, type `passport`, optional traits) → status bar's User ID updates. +11. Try Alias with a Steam ID → Passport ID → `ALIAS` entry. +12. Click **Reset** → anonymous ID rotates, session end + start fire. +13. Click **Shutdown** → session end fires, buttons flip off. ## Environments diff --git a/packages/audience/sdk-sample-app/demo.js b/packages/audience/sdk-sample-app/demo.js index 735387b7e4..b514e11f07 100644 --- a/packages/audience/sdk-sample-app/demo.js +++ b/packages/audience/sdk-sample-app/demo.js @@ -336,6 +336,12 @@ $('btn-identify').disabled = !on; $('btn-identify-traits').disabled = !on; $('btn-alias').disabled = !on; + $('btn-ev-game-page-viewed').disabled = !on; + $('btn-ev-link-clicked').disabled = !on; + $('btn-ev-email-acquired').disabled = !on; + $('btn-ev-sign-in').disabled = !on; + $('btn-ev-wishlist-add').disabled = !on; + $('btn-ev-wishlist-remove').disabled = !on; $('pk').disabled = on; $('flush-interval').disabled = on; $('flush-size').disabled = on; @@ -498,6 +504,98 @@ } } + // --- Studio event quick-fire buttons --- + // Each fires audience.track() with the recommended event name and realistic + // demo properties matching the shapes defined in events.ts. + + function studioGameId() { + return ($('studio-game-id').value || '').trim() || 'game-demo-001'; + } + + function onGamePageViewed() { + if (!audience) return; + var gameId = studioGameId(); + var props = { + gameId: gameId, + gameName: 'Demo Game', + slug: 'demo-game', + isLoggedIn: !!currentUserId, + }; + try { + audience.track('game_page_viewed', props); + log('TRACK', { eventName: 'game_page_viewed', properties: props }, 'ok'); + } catch (err) { + log('TRACK', String(err && err.message || err), 'err'); + } + } + + function onLinkClicked() { + if (!audience) return; + var gameId = studioGameId(); + var props = { + url: 'https://store.example.com/demo-game', + label: 'play_now', + source: 'game_page', + isLoggedIn: !!currentUserId, + gameId: gameId, + }; + try { + audience.track('link_clicked', props); + log('TRACK', { eventName: 'link_clicked', properties: props }, 'ok'); + } catch (err) { + log('TRACK', String(err && err.message || err), 'err'); + } + } + + function onEmailAcquired() { + if (!audience) return; + var props = { + isLoggedIn: !!currentUserId, + source: 'linked_account_steam', + }; + try { + audience.track('email_acquired', props); + log('TRACK', { eventName: 'email_acquired', properties: props }, 'ok'); + } catch (err) { + log('TRACK', String(err && err.message || err), 'err'); + } + } + + function onSignIn() { + if (!audience) return; + var props = { method: 'passport' }; + try { + audience.track('sign_in', props); + log('TRACK', { eventName: 'sign_in', properties: props }, 'ok'); + } catch (err) { + log('TRACK', String(err && err.message || err), 'err'); + } + } + + function onWishlistAdd() { + if (!audience) return; + var gameId = studioGameId(); + var props = { gameId: gameId }; + try { + audience.track('wishlist_add', props); + log('TRACK', { eventName: 'wishlist_add', properties: props }, 'ok'); + } catch (err) { + log('TRACK', String(err && err.message || err), 'err'); + } + } + + function onWishlistRemove() { + if (!audience) return; + var gameId = studioGameId(); + var props = { gameId: gameId }; + try { + audience.track('wishlist_remove', props); + log('TRACK', { eventName: 'wishlist_remove', properties: props }, 'ok'); + } catch (err) { + log('TRACK', String(err && err.message || err), 'err'); + } + } + function onIdentify() { if (!audience) return; var id = $('identify-id').value.trim(); @@ -600,6 +698,13 @@ $('btn-page').addEventListener('click', onPage); $('btn-track').addEventListener('click', onTrack); + $('btn-ev-game-page-viewed').addEventListener('click', onGamePageViewed); + $('btn-ev-link-clicked').addEventListener('click', onLinkClicked); + $('btn-ev-email-acquired').addEventListener('click', onEmailAcquired); + $('btn-ev-sign-in').addEventListener('click', onSignIn); + $('btn-ev-wishlist-add').addEventListener('click', onWishlistAdd); + $('btn-ev-wishlist-remove').addEventListener('click', onWishlistRemove); + var versionEl = $('sdk-version'); if (versionEl) versionEl.textContent = SDK_VERSION; From 84d3e6b36b2357c6eec0a02a7ba45450b879e404 Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Sun, 12 Apr 2026 12:30:15 +1000 Subject: [PATCH 20/20] refactor(audience): share CDN global type between cdn and its test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renames the local GlobalShape to ImmutableAudienceGlobal, exports it from cdn.ts as a type-only export, and imports it in cdn.test.ts. Eliminates the duplicated definition that previously had to be kept in sync by hand. The runtime behaviour is unchanged — types are erased at compile time so cdn.ts remains a side-effect-only module for value imports. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/audience/sdk/src/cdn.test.ts | 12 ++---------- packages/audience/sdk/src/cdn.ts | 12 ++++++++++-- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/audience/sdk/src/cdn.test.ts b/packages/audience/sdk/src/cdn.test.ts index 4eebd014b5..0061bce7e2 100644 --- a/packages/audience/sdk/src/cdn.test.ts +++ b/packages/audience/sdk/src/cdn.test.ts @@ -6,15 +6,7 @@ * itself is exercised by running `pnpm build` and loading the built file in * the demo page. */ -import type { AudienceError, IdentityType } from '@imtbl/audience-core'; -import type { Audience } from './sdk'; - -type GlobalShape = { - Audience: typeof Audience; - AudienceError: typeof AudienceError; - IdentityType: typeof IdentityType; - version: string; -}; +import type { ImmutableAudienceGlobal } from './cdn'; describe('cdn entry point', () => { beforeEach(() => { @@ -29,7 +21,7 @@ describe('cdn entry point', () => { it('attaches the SDK surface to window.ImmutableAudience', async () => { await import('./cdn'); const g = (globalThis as unknown as { - ImmutableAudience?: GlobalShape; + ImmutableAudience?: ImmutableAudienceGlobal; }).ImmutableAudience; expect(g).toBeDefined(); diff --git a/packages/audience/sdk/src/cdn.ts b/packages/audience/sdk/src/cdn.ts index 1f590b2ea2..9ec3dca9af 100644 --- a/packages/audience/sdk/src/cdn.ts +++ b/packages/audience/sdk/src/cdn.ts @@ -15,7 +15,15 @@ import { AudienceError, IdentityType } from '@imtbl/audience-core'; import { Audience } from './sdk'; import { LIBRARY_VERSION } from './config'; -type GlobalShape = { +/** + * The shape of `window.ImmutableAudience` attached by the CDN bundle. + * Type-only export — importing this module otherwise has runtime side effects, + * which is why the file's `DO NOT import this` warning above only applies to + * value imports. The CDN smoke test imports this type to keep its assertions + * in lockstep with the real surface, eliminating the need for a duplicate + * definition. + */ +export type ImmutableAudienceGlobal = { Audience: typeof Audience; AudienceError: typeof AudienceError; IdentityType: typeof IdentityType; @@ -26,7 +34,7 @@ type GlobalShape = { // to `window` for browsers that predate globalThis (Safari < 12.1). const globalObj = ( typeof globalThis !== 'undefined' ? globalThis : window -) as unknown as { ImmutableAudience?: GlobalShape }; +) as unknown as { ImmutableAudience?: ImmutableAudienceGlobal }; if (globalObj.ImmutableAudience) { // eslint-disable-next-line no-console