From 3298c6d86df5971d762ccb7031608bd166748508 Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Thu, 9 Apr 2026 09:04:32 +1000 Subject: [PATCH 01/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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"