From da08686e493d83525dbf17c40ac7195604fb62a1 Mon Sep 17 00:00:00 2001 From: Ash Yablok Date: Tue, 15 Sep 2026 13:56:58 -0700 Subject: [PATCH 01/22] Add a widget playground for manual testing and demos There was no way to look at a pathfora widget without hand-writing an HTML page. Verifying the #675 accessibility work meant building a throwaway harness twice, and the same traps cost time both times. playground/ renders any of the 16 valid type and layout combinations. Picking one from the catalogue loads its config into an editor, where it can be changed and rendered again. The editor runs as JavaScript in the same shape as the examples in docs/docs/examples/src, so a snippet from a bug report pastes in and runs verbatim. Widgets render inside a stage iframe rather than in the playground page. Without that the sidebar covers a bottom-left slideout and the toolbar covers a top-fixed bar, and no corner of the chrome is reliably out of the way - a widget preview tool whose preview sits behind its own interface is not much use. Two things it handles that are easy to get wrong by hand: - It sets window.PathforaCSS before loading the SDK. The SDK otherwise injects the CDN stylesheet, and that production CSS wins the cascade over a local build, so local CSS changes appear to do nothing with no error. This produced a wrong conclusion during the #675 work. - It clears stored state before each render. pathfora.clearAll() only resets in-memory trackers, so without this a submitted gate stays unlocked and impression caps stay spent, across renders and across reloads. Both localStorage and sessionStorage are swept, since impressions and recommendations are written to each. "Keep stored state" opts out for anyone deliberately testing impression caps. The SiteGate/gate entry is labelled as broken in the UI: its Confirm button does nothing, because construct-widget-actions.js never assigns a widgetAction for type "sitegate". Form with layout "gate" is the working equivalent. Gate layouts get no position, since validateWidgetPosition has no case for them and dereferences an undefined `choices`. Also removes test.html, which was an unreferenced, stale version of this - titled "Action widget example" while rendering a form gate, and loading the jstag, which installs the production SDK alongside the local build. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 26 ++++ gulpfile.js | 2 +- playground/index.html | 71 +++++++++ playground/playground.css | 257 ++++++++++++++++++++++++++++++ playground/playground.js | 319 ++++++++++++++++++++++++++++++++++++++ playground/stage.html | 114 ++++++++++++++ test.html | 94 ----------- 7 files changed, 788 insertions(+), 95 deletions(-) create mode 100644 playground/index.html create mode 100644 playground/playground.css create mode 100644 playground/playground.js create mode 100644 playground/stage.html delete mode 100644 test.html diff --git a/README.md b/README.md index 1e77fbf..510b3f2 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,32 @@ The docs task will walk through every `.js` file in the examples source director This allows us to keep our source code in one place. Changing a js file in the examples source folder will change the code snippet in the docs and update the example .html file. +### Widget playground + +`playground/` is a local page for rendering any widget type and layout, for manual +QA and for demoing. Start the dev server and open it: + +```sh +$ yarn run local +``` + +Then visit [http://localhost:8080/playground/](http://localhost:8080/playground/). + +Pick any combination from the sidebar to render it; its config appears in the editor, +where you can change it and render again. The editor runs as JavaScript in the same +shape as the examples in `docs/docs/examples/src`, so a snippet from a bug report can +be pasted in and run as-is. + +Two things it handles that are easy to get wrong by hand: + +- It sets `window.PathforaCSS` to `/dist/pathfora.min.css` before loading the SDK. The + SDK otherwise injects the CDN stylesheet, and that production CSS wins the cascade + over your local build - so local CSS changes appear to do nothing, with no error. +- It clears pathfora's stored state before each render. `pathfora.clearAll()` only + resets in-memory trackers, so without this a submitted gate stays unlocked and + impression caps stay spent, across renders *and* across reloads. Tick **Keep stored + state** when you are deliberately testing impressions or `hideAfterAction`. + ### Testing Pathfora uses [Jasmine](https://github.com/jasmine/jasmine) as a test framework, and [Karma](https://github.com/karma-runner/karma/) to run tests. Before running tests, or commiting changes be sure to run `gulp build` instead of `gulp local`, or tests may fail due to mismatching URLs. diff --git a/gulpfile.js b/gulpfile.js index 29f5c1a..4c11b18 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -293,7 +293,7 @@ gulp.task( gulp.series( 'build:js', shell.task([ - 'eslint --fix src/rollup/**/*.js gulpfile.js test/**/*.js docs/docs/examples/**/*.js', + 'eslint --fix src/rollup/**/*.js gulpfile.js test/**/*.js docs/docs/examples/**/*.js playground/**/*.js', ]) ) ); diff --git a/playground/index.html b/playground/index.html new file mode 100644 index 0000000..60a2e9b --- /dev/null +++ b/playground/index.html @@ -0,0 +1,71 @@ + + + + + + Pathfora widget playground + + + + +
+ Pathfora playground + Loading… + + + + + +
+ +
+ + +
+
+
+ + Runs as JavaScript, same shape as the examples in + docs/docs/examples/src +
+ +
+ + +
+
+ + + +
+
+ + + + diff --git a/playground/playground.css b/playground/playground.css new file mode 100644 index 0000000..01e1c29 --- /dev/null +++ b/playground/playground.css @@ -0,0 +1,257 @@ +/* + * Playground chrome only. Every selector is namespaced pg- so nothing here can + * reach into the pf-* markup we are trying to look at. + * + * Widgets render inside the stage iframe, so there is no z-index contest with + * them at all - a bottom-left slideout and a top-fixed bar both land where they + * would on a real page instead of underneath this interface. + */ + +:root { + --pg-bg: #12151a; + --pg-panel: #1a1f27; + --pg-line: #2b323d; + --pg-text: #e6e9ef; + --pg-muted: #939cab; + --pg-accent: #4f9cf9; + --pg-error: #ff6b6b; + --pg-warn: #f5a623; +} + +body { + margin: 0; + font-family: + -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; + font-size: 14px; + color: #23282f; + background: #fff; +} + +/* Top bar */ + +.pg-bar { + position: fixed; + top: 0; + left: 0; + right: 0; + display: flex; + align-items: center; + gap: 16px; + height: 48px; + padding: 0 16px; + background: var(--pg-bg); + color: var(--pg-text); + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3); +} + +.pg-bar-title { + font-weight: 600; + white-space: nowrap; +} + +.pg-status { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--pg-muted); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12px; +} + +.pg-bar-actions { + display: flex; + align-items: center; + gap: 10px; + white-space: nowrap; +} + +.pg-check { + display: flex; + align-items: center; + gap: 5px; + color: var(--pg-muted); + font-size: 12px; + cursor: pointer; +} + +.pg-bar button { + padding: 5px 11px; + border: 1px solid var(--pg-line); + border-radius: 4px; + background: var(--pg-panel); + color: var(--pg-text); + font-size: 12px; + cursor: pointer; +} + +.pg-bar button:hover { + border-color: var(--pg-accent); +} + +/* Shell */ + +.pg-shell { + display: flex; + height: 100vh; + padding-top: 48px; + box-sizing: border-box; +} + +.pg-sidebar { + flex: 0 0 220px; + width: 220px; + overflow-y: auto; + padding: 16px; + background: var(--pg-bg); + color: var(--pg-text); + box-sizing: border-box; +} + +.pg-hint { + margin: 0 0 16px; + color: var(--pg-muted); + font-size: 12px; + line-height: 1.5; +} + +.pg-group { + margin-bottom: 18px; +} + +.pg-group-title { + margin: 0 0 6px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--pg-muted); +} + +.pg-group button { + display: block; + width: 100%; + margin-bottom: 3px; + padding: 6px 9px; + border: 1px solid transparent; + border-radius: 4px; + background: var(--pg-panel); + color: var(--pg-text); + font-size: 13px; + text-align: left; + cursor: pointer; +} + +.pg-group button:hover { + border-color: var(--pg-line); +} + +.pg-group button.is-active { + background: var(--pg-accent); + color: #fff; +} + +.pg-group button.has-caveat::after { + content: '!'; + float: right; + color: var(--pg-warn); + font-weight: 700; +} + +.pg-group button.is-active.has-caveat::after { + color: #fff; +} + +/* Main column */ + +.pg-main { + display: flex; + flex: 1; + flex-direction: column; + min-width: 0; +} + +.pg-editor-pane { + flex: 0 0 auto; + padding: 12px 20px 14px; + border-bottom: 1px solid #e2e7ec; + background: #f4f6f9; +} + +/* The frame widgets render into. Given the remaining height so a bar or a + bottom slideout has somewhere realistic to sit. */ +.pg-stage { + flex: 1; + width: 100%; + min-height: 0; + border: 0; + background: #fff; +} + +.pg-editor-head { + display: flex; + align-items: baseline; + gap: 10px; + margin-bottom: 6px; +} + +.pg-editor-head label { + font-weight: 600; +} + +.pg-editor-note { + color: #6c7480; + font-size: 12px; +} + +#pg-editor { + display: block; + width: 100%; + height: 130px; + padding: 12px; + border: 1px solid #d5dae1; + border-radius: 6px; + background: #fbfcfd; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12.5px; + line-height: 1.55; + box-sizing: border-box; + resize: vertical; +} + +#pg-editor:focus { + outline: 2px solid var(--pg-accent); + outline-offset: 1px; +} + +.pg-editor-actions { + display: flex; + align-items: center; + gap: 12px; + margin-top: 10px; +} + +.pg-primary { + padding: 7px 16px; + border: 0; + border-radius: 4px; + background: var(--pg-accent); + color: #fff; + font-size: 13px; + font-weight: 600; + cursor: pointer; +} + +.pg-error { + flex: 1; + min-width: 0; + color: var(--pg-error); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12px; + line-height: 1.5; +} + +.pg-error[hidden] { + display: none; +} diff --git a/playground/playground.js b/playground/playground.js new file mode 100644 index 0000000..045d634 --- /dev/null +++ b/playground/playground.js @@ -0,0 +1,319 @@ +/** + * Pathfora widget playground. + * + * Renders any valid type/layout combination against the local dist/ build, and + * lets you edit the config and render again. See the README for how to run it. + * + * Widgets live in the stage iframe (playground/stage.html), not in this page, so + * the sidebar and toolbar can never sit on top of one. + */ +(function () { + 'use strict'; + + // Every key prefix pathfora persists under, from src/rollup/globals/config.js. + // Impressions and recommendations also go to sessionStorage, so both stores + // have to be swept - see clearStoredState below. + var STORAGE_PREFIXES = [ + 'PathforaRecommend_', + 'PathforaUnlocked_', + 'PathforaImpressions_', + 'PathforaTotalImpressionsSince_', + 'PathforaConfirm_', + 'PathforaCancel_', + 'PathforaClosed_', + 'PathforaTest_', + 'PathforaPageView', + ]; + + // The valid type x layout matrix, per the switch statements in + // src/rollup/widgets/construct-widget-layout.js. Anything else throws. + var CATALOGUE = [ + { + ctor: 'Message', + layouts: ['modal', 'slideout', 'bar', 'gate', 'button', 'inline'], + }, + { + ctor: 'Subscription', + layouts: ['modal', 'slideout', 'bar', 'gate', 'inline'], + }, + { ctor: 'Form', layouts: ['modal', 'slideout', 'gate', 'inline'] }, + { ctor: 'SiteGate', layouts: ['gate'] }, + ]; + + // Defaults only where the layout actually accepts a position. Gate is + // deliberately absent: validateWidgetPosition has no case for it, so setting + // one dereferences an undefined `choices` and throws. + var DEFAULT_POSITION = { + slideout: 'bottom-left', + bar: 'top-absolute', + button: 'top-left', + }; + + // Known-broken combinations, surfaced in the UI so nobody debugs them twice. + var CAVEATS = { + 'SiteGate/gate': + 'Known issue: the Confirm button does nothing. ' + + 'construct-widget-actions.js never assigns a widgetAction for type ' + + '"sitegate", so the click handler returns early. Use Form with layout ' + + '"gate" for a working gate.', + }; + + var INLINE_HOST = '#pg-inline-host'; + + var el = {}; + var activeKey = null; + + function byId(id) { + return document.getElementById(id); + } + + function keyFor(ctor, layout) { + return ctor + '/' + layout; + } + + function setStatus(text) { + el.status.textContent = text; + } + + function showError(message) { + el.error.textContent = message; + el.error.hidden = false; + } + + function hideError() { + el.error.textContent = ''; + el.error.hidden = true; + } + + function stageWindow() { + return el.stage.contentWindow; + } + + function stageDocument() { + return el.stage.contentDocument; + } + + function isPathforaKey(key) { + return STORAGE_PREFIXES.some(function (prefix) { + return key.indexOf(prefix) === 0; + }); + } + + function sweep(store) { + var doomed = []; + var i; + + for (i = 0; i < store.length; i++) { + if (isPathforaKey(store.key(i))) { + doomed.push(store.key(i)); + } + } + + doomed.forEach(function (key) { + store.removeItem(key); + }); + + return doomed.length; + } + + /** + * pathfora.clearAll() resets in-memory trackers only - it never touches + * storage. Without this a submitted gate stays unlocked and impression caps + * stay spent, across renders and across reloads, which makes repeat testing + * baffling. + */ + function clearStoredState() { + var win = stageWindow(); + var cleared = 0; + + try { + cleared += sweep(win.localStorage); + cleared += sweep(win.sessionStorage); + } catch (storageError) { + showError('Could not clear storage: ' + storageError.message); + } + + stageDocument() + .cookie.split(';') + .forEach(function (entry) { + var name = entry.split('=')[0].trim(); + + if (name && isPathforaKey(name)) { + win.pathfora.utils.deleteCookie(name); + cleared++; + } + }); + + return cleared; + } + + function describeRendered() { + var nodes = stageDocument().querySelectorAll('.pf-widget'); + + if (!nodes.length) { + return 'Nothing rendered'; + } + + return ( + 'Rendered: ' + + Array.prototype.map + .call(nodes, function (node) { + return node.id; + }) + .join(', ') + ); + } + + function snippetFor(ctor, layout) { + var config = { + id: 'playground-' + ctor.toLowerCase() + '-' + layout, + layout: layout, + headline: ctor + ' / ' + layout, + msg: 'This is a ' + layout + ' rendered from the playground.', + }; + + if (DEFAULT_POSITION[layout]) { + config.position = DEFAULT_POSITION[layout]; + } + + if (layout === 'inline') { + config.positionSelector = INLINE_HOST; + } + + return ( + 'var widget = new pathfora.' + + ctor + + '(' + + JSON.stringify(config, null, 2) + + ');\n\npathfora.initializeWidgets([widget]);\n' + ); + } + + function clearWidgets() { + try { + stageWindow().pathfora.clearAll(); + } catch (clearError) { + // clearAll on an empty tracker is harmless; never block a render on it + window.console.debug('clearAll: ' + clearError.message); + } + } + + function render(snippet) { + var doc = stageDocument(); + var script; + + hideError(); + clearWidgets(); + + if (!el.preserve.checked) { + clearStoredState(); + } + + // Injected as a script element rather than eval'd so it runs in the stage's + // own scope. Errors thrown here reach the stage window's error handler, + // wired up in watchStage, rather than this call stack. + script = doc.createElement('script'); + script.textContent = snippet; + doc.body.appendChild(script); + doc.body.removeChild(script); + + if (el.error.hidden) { + setStatus(describeRendered()); + } + } + + function selectEntry(ctor, layout) { + activeKey = keyFor(ctor, layout); + + Array.prototype.forEach.call( + el.catalogue.querySelectorAll('button'), + function (button) { + button.classList.toggle('is-active', button.dataset.key === activeKey); + } + ); + + el.editor.value = snippetFor(ctor, layout); + render(el.editor.value); + + // A caveat is context rather than a failure, so it goes up after the render + if (CAVEATS[activeKey] && el.error.hidden) { + showError(CAVEATS[activeKey]); + } + } + + function buildCatalogue() { + CATALOGUE.forEach(function (group) { + var section = document.createElement('div'); + var heading = document.createElement('h2'); + + section.className = 'pg-group'; + heading.className = 'pg-group-title'; + heading.textContent = group.ctor; + section.appendChild(heading); + + group.layouts.forEach(function (layout) { + var button = document.createElement('button'); + + button.type = 'button'; + button.textContent = layout; + button.dataset.key = keyFor(group.ctor, layout); + + if (CAVEATS[keyFor(group.ctor, layout)]) { + button.classList.add('has-caveat'); + button.title = 'Known issue - see the note when selected'; + } + + button.addEventListener('click', function () { + selectEntry(group.ctor, layout); + }); + + section.appendChild(button); + }); + + el.catalogue.appendChild(section); + }); + } + + /** + * Surface anything the stage throws. Widgets can fail well after the click + * that created them - a showDelay widget throws from inside a timeout - and a + * config with a syntax error never reaches a try/catch here at all. + */ + function watchStage() { + stageWindow().addEventListener('error', function (event) { + showError(event.message); + setStatus('Render failed'); + }); + } + + function init() { + el.catalogue = byId('pg-catalogue'); + el.editor = byId('pg-editor'); + el.error = byId('pg-error'); + el.status = byId('pg-status'); + el.preserve = byId('pg-preserve'); + el.stage = byId('pg-stage'); + + hideError(); + buildCatalogue(); + + byId('pg-render').addEventListener('click', function () { + render(el.editor.value); + }); + + byId('pg-clear').addEventListener('click', function () { + clearWidgets(); + setStatus('Cleared'); + }); + + byId('pg-reset').addEventListener('click', function () { + clearWidgets(); + setStatus('Cleared ' + clearStoredState() + ' stored key(s)'); + }); + + el.stage.addEventListener('load', watchStage); + setStatus('Pick a widget to render'); + } + + init(); +}()); diff --git a/playground/stage.html b/playground/stage.html new file mode 100644 index 0000000..b9f20b1 --- /dev/null +++ b/playground/stage.html @@ -0,0 +1,114 @@ + + + + + + Playground stage + + + + + + + + +

Example page

+

+ This stands in for a customer's page. Widgets render over it exactly as + they would in the wild — nothing from the playground's own interface + overlaps this frame. +

+

+ The dashed box is the mount point for inline layouts. The + filler below gives the page enough height to exercise scroll-based display + conditions. +

+ +
#pg-inline-host
+ +
Scroll area
+ + + + + diff --git a/test.html b/test.html deleted file mode 100644 index d1b0ca7..0000000 --- a/test.html +++ /dev/null @@ -1,94 +0,0 @@ - - - - Action widget - - -

Action widget example

- - - - - - From b42b1d46b1d57002808c67b0b946dec5309a0696 Mon Sep 17 00:00:00 2001 From: Ash Yablok Date: Tue, 15 Sep 2026 14:49:26 -0700 Subject: [PATCH 02/22] Add form mode to the playground, drop SiteGate The playground could only be configured by writing JavaScript. This adds a form covering the settings worth changing while demoing or reproducing a report, with the config pane still there for anything the form does not reach. Form mode covers content, buttons, placement, theme and colours, all 14 display conditions including the nested ones (date, impressions, hideAfterAction, and repeating rows for urlContains and metaContains), content recommendations, and custom form fields. Audience targeting and A/B testing are deliberately out of scope. Controls are described as data in playground/fields.js rather than as markup, so the rules about where an option applies live in one place. Those rules are not cosmetic - four of them keep the form from generating a config that throws: footerText bar, button and inline templates have no footer element and construct-widget-layout.js assigns to it unguarded position validate-widget-position.js has no case for gate, so it dereferences an undefined `choices` pushDown init-widget.js throws unless the bar is top-positioned recommend validate-recommendation-widget.js throws for any type but message, or any layout but modal/slideout/inline The rest hide options the library accepts and silently ignores, on the grounds that a control that does nothing is worse than no control. Content recommendations render their default document rather than a live one, since the playground stubs the Lytics account - the docs note their own examples behave the same way. The section says so, and also that setupWidgetContentUnit needs both recommend and content, so a default document on its own renders nothing. SiteGate is removed from the catalogue. It is deprecated, and its confirm button is dead code regardless: construct-widget-actions.js never assigns a widgetAction for type "sitegate", so the handler returns early. Form with layout "gate" is the working equivalent. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 23 +- playground/fields.js | 472 +++++++++++++++++++++++++++++ playground/index.html | 58 ++-- playground/playground.css | 235 ++++++++++++--- playground/playground.js | 603 +++++++++++++++++++++++++++++++++----- 5 files changed, 1254 insertions(+), 137 deletions(-) create mode 100644 playground/fields.js diff --git a/README.md b/README.md index 510b3f2..1bc193c 100644 --- a/README.md +++ b/README.md @@ -143,10 +143,16 @@ $ yarn run local Then visit [http://localhost:8080/playground/](http://localhost:8080/playground/). -Pick any combination from the sidebar to render it; its config appears in the editor, -where you can change it and render again. The editor runs as JavaScript in the same -shape as the examples in `docs/docs/examples/src`, so a snippet from a bug report can -be pasted in and run as-is. +Pick any combination from the sidebar to render it, then use either mode to configure it: + +- **Form** builds the config from controls covering content, buttons, placement, theme + and colours, all 14 display conditions, content recommendations and custom form + fields. Controls only appear where the option actually applies, which keeps you away + from the combinations that throw - `footerText` on a bar, a `position` on a gate, a + `pushDown` on a bar that is not top-positioned. +- **Config** is the generated JavaScript, editable by hand. It is the same shape as the + examples in `docs/docs/examples/src`, so a snippet from a bug report can be pasted in + and run as-is. Switching back to Form regenerates the config from the controls. Two things it handles that are easy to get wrong by hand: @@ -158,6 +164,15 @@ Two things it handles that are easy to get wrong by hand: impression caps stay spent, across renders *and* across reloads. Tick **Keep stored state** when you are deliberately testing impressions or `hideAfterAction`. +Content recommendations render their default document rather than a real recommendation, +because the playground stubs the Lytics account - the docs note their own examples behave +the same way. Note that `setupWidgetContentUnit` needs both `recommend` and `content`, so +a default document on its own renders nothing. + +`SiteGate` is deliberately absent: it is deprecated, and its confirm button is dead code +because `construct-widget-actions.js` never assigns it a `widgetAction`. Use `Form` with +layout `gate` instead. + ### Testing Pathfora uses [Jasmine](https://github.com/jasmine/jasmine) as a test framework, and [Karma](https://github.com/karma-runner/karma/) to run tests. Before running tests, or commiting changes be sure to run `gulp build` instead of `gulp local`, or tests may fail due to mismatching URLs. diff --git a/playground/fields.js b/playground/fields.js new file mode 100644 index 0000000..03d309a --- /dev/null +++ b/playground/fields.js @@ -0,0 +1,472 @@ +/** + * Field schema for the playground's form mode. + * + * Every control is described as data rather than markup so the rules about + * where an option applies live in one place. Those rules are not cosmetic - a + * few of them stop the form producing a config that throws: + * + * footerText - construct-widget-layout.js sets widgetFooter.innerHTML with no + * null guard, and bar/button/inline templates have no footer + * pushDown - init-widget.js throws unless the layout is a top-positioned bar + * position - validate-widget-position.js has no case for gate, so it + * dereferences an undefined `choices` + * recommend - validate-recommendation-widget.js throws for any type but + * message, or any layout but modal/slideout/inline + * + * The rest describe options the library accepts but silently ignores, which is + * worth hiding for a different reason: a control that does nothing is worse + * than no control. + */ +(function () { + 'use strict'; + + function layoutIn(list) { + return function (ctx) { + return list.indexOf(ctx.layout) !== -1; + }; + } + + function layoutNotIn(list) { + return function (ctx) { + return list.indexOf(ctx.layout) === -1; + }; + } + + function every(tests) { + return function (ctx) { + return tests.every(function (test) { + return test(ctx); + }); + }; + } + + function typeIs(type) { + return function (ctx) { + return ctx.type === type; + }; + } + + // Valid positions per layout, from validate-widget-position.js. Gate and + // inline are absent on purpose - gate throws, inline uses positionSelector. + var POSITIONS = { + modal: ['', 'middle-center'], + slideout: [ + 'bottom-left', + 'bottom-right', + 'left', + 'right', + 'top-left', + 'top-right', + ], + bar: [ + 'top-absolute', + 'top-fixed', + 'bottom-fixed', + 'top-center', + 'bottom-center', + ], + button: [ + 'left', + 'right', + 'top-left', + 'top-right', + 'bottom-left', + 'bottom-right', + ], + }; + + // The colour keys set-custom-colors.js understands, only used with theme custom + var COLOR_KEYS = [ + 'background', + 'text', + 'headline', + 'close', + 'actionText', + 'actionBackground', + 'cancelText', + 'cancelBackground', + 'fieldBackground', + 'required', + 'requiredText', + ]; + + function colorFields() { + return COLOR_KEYS.map(function (key) { + return { + key: 'colors.' + key, + label: key, + type: 'color', + applies: function (ctx) { + return ctx.config.theme === 'custom'; + }, + }; + }); + } + + function impressionFields(scope) { + return [ + { + key: 'displayConditions.impressions.' + scope + '.session', + label: scope + ' session', + type: 'number', + }, + { + key: 'displayConditions.impressions.' + scope + '.total', + label: scope + ' total', + type: 'number', + }, + { + key: 'displayConditions.impressions.' + scope + '.buffer', + label: scope + ' buffer (s)', + type: 'number', + }, + { + key: 'displayConditions.impressions.' + scope + '.duration', + label: scope + ' duration (s)', + type: 'number', + }, + ]; + } + + function hideAfterActionFields(action) { + return [ + { + key: 'displayConditions.hideAfterAction.' + action + '.hideCount', + label: action + ' hideCount', + type: 'number', + }, + { + key: 'displayConditions.hideAfterAction.' + action + '.duration', + label: action + ' duration (s)', + type: 'number', + }, + ]; + } + + var SECTIONS = [ + { + title: 'Content', + fields: [ + { + key: 'headline', + label: 'headline', + type: 'text', + applies: layoutNotIn(['bar', 'button']), + note: 'bar and button templates have no headline element', + }, + { key: 'msg', label: 'msg', type: 'textarea' }, + { + key: 'image', + label: 'image (url)', + type: 'text', + applies: layoutNotIn(['button']), + }, + { + key: 'footerText', + label: 'footerText', + type: 'text', + applies: layoutIn(['modal', 'slideout', 'gate']), + note: 'throws on bar, button and inline - no footer element', + }, + { + key: 'branding', + label: 'branding', + type: 'bool', + applies: layoutIn(['modal', 'slideout', 'gate', 'inline']), + }, + { key: 'className', label: 'className', type: 'text' }, + { key: 'responsive', label: 'responsive', type: 'bool' }, + ], + }, + + { + title: 'Buttons', + fields: [ + { key: 'okShow', label: 'okShow', type: 'bool' }, + { key: 'okMessage', label: 'okMessage', type: 'text' }, + { + key: 'cancelShow', + label: 'cancelShow', + type: 'bool', + applies: function (ctx) { + if (ctx.type === 'subscription') { + return false; + } + return ['inline', 'button'].indexOf(ctx.layout) === -1; + }, + note: 'no cancel button in subscription, inline or button templates', + }, + { + key: 'cancelMessage', + label: 'cancelMessage', + type: 'text', + applies: function (ctx) { + if (ctx.type === 'subscription') { + return false; + } + return ['inline', 'button'].indexOf(ctx.layout) === -1; + }, + }, + ], + }, + + { + title: 'Placement', + fields: [ + { + key: 'position', + label: 'position', + type: 'select', + optionsFor: function (ctx) { + return POSITIONS[ctx.layout] || []; + }, + applies: function (ctx) { + return Boolean(POSITIONS[ctx.layout]); + }, + note: 'gate has no positions - setting one throws', + }, + { + key: 'origin', + label: 'origin', + type: 'select', + options: ['', 'bottom'], + applies: layoutIn(['slideout']), + note: 'only pf-origin-bottom has styles', + }, + { + key: 'positionSelector', + label: 'positionSelector', + type: 'text', + note: 'required for inline layouts', + }, + { + key: 'pushDown', + label: 'pushDown', + type: 'text', + applies: every([ + layoutIn(['bar']), + function (ctx) { + var pos = ctx.config.position; + return pos === 'top-fixed' || pos === 'top-absolute'; + }, + ]), + note: 'top-positioned bars only - throws otherwise', + }, + ], + }, + + { + title: 'Theme', + fields: [ + { + key: 'theme', + label: 'theme', + type: 'select', + options: ['', 'light', 'dark', 'custom', 'none'], + }, + ].concat(colorFields()), + }, + + { + title: 'Display conditions', + fields: [ + { + key: 'displayConditions.showOnInit', + label: 'showOnInit', + type: 'bool', + }, + { + key: 'displayConditions.showDelay', + label: 'showDelay (s)', + type: 'number', + }, + { + key: 'displayConditions.hideAfter', + label: 'hideAfter (s)', + type: 'number', + }, + { + key: 'displayConditions.showOnExitIntent', + label: 'showOnExitIntent', + type: 'bool', + }, + { + key: 'displayConditions.displayWhenElementVisible', + label: 'displayWhenElementVisible', + type: 'text', + note: 'a selector in the stage page', + }, + { + key: 'displayConditions.scrollPercentageToDisplay', + label: 'scrollPercentageToDisplay', + type: 'number', + }, + { + key: 'displayConditions.pageVisits', + label: 'pageVisits', + type: 'number', + note: 'counts up in PathforaPageView, which Reset clears', + }, + { + key: 'displayConditions.manualTrigger', + label: 'manualTrigger', + type: 'bool', + note: 'use the Trigger button to release it', + }, + { + key: 'displayConditions.showOnMissingFields', + label: 'showOnMissingFields', + type: 'bool', + }, + { + key: 'displayConditions.date.start_at', + label: 'date start_at', + type: 'datetime', + }, + { + key: 'displayConditions.date.end_at', + label: 'date end_at', + type: 'datetime', + }, + ] + .concat(impressionFields('widget')) + .concat(impressionFields('global')) + .concat(hideAfterActionFields('confirm')) + .concat(hideAfterActionFields('cancel')) + .concat(hideAfterActionFields('closed')) + .concat([ + { + key: 'displayConditions.urlContains', + label: 'urlContains', + type: 'list', + row: [ + { + key: 'match', + label: 'match', + type: 'select', + options: ['simple', 'exact', 'string', 'regex'], + }, + { key: 'value', label: 'value', type: 'text' }, + { key: 'exclude', label: 'exclude', type: 'bool' }, + ], + }, + { + key: 'displayConditions.metaContains', + label: 'metaContains', + type: 'list', + row: [ + { key: 'property', label: 'property', type: 'text' }, + { key: 'name', label: 'name', type: 'text' }, + { key: 'content', label: 'content', type: 'text' }, + ], + }, + ]), + }, + + { + title: 'Content recommendation', + applies: every([ + typeIs('message'), + layoutIn(['modal', 'slideout', 'inline']), + ]), + intro: + 'Set at least one recommend option below - setupWidgetContentUnit only ' + + 'runs when recommend and content are both present, so a default ' + + 'document on its own renders nothing. The playground stubs the Lytics ' + + 'account, so the API returns no recommendation and the default ' + + 'document is what you will see. The docs note their own examples ' + + 'behave the same way.', + fields: [ + { key: 'recommend.collection', label: 'collection', type: 'text' }, + { + key: 'recommend.rollups', + label: 'rollups', + type: 'csv', + note: 'comma separated', + }, + { key: 'recommend.visited', label: 'visited', type: 'bool' }, + { key: 'recommend.shuffle', label: 'shuffle', type: 'bool' }, + { + key: 'recommend.rank', + label: 'rank', + type: 'select', + options: ['', 'popular', 'recent', 'affinity'], + }, + { key: 'recommend.display.title', label: 'show title', type: 'bool' }, + { key: 'recommend.display.image', label: 'show image', type: 'bool' }, + { + key: 'recommend.display.description', + label: 'show description', + type: 'bool', + }, + { key: 'recommend.display.author', label: 'show author', type: 'bool' }, + { key: 'recommend.display.date', label: 'show date', type: 'bool' }, + { + key: 'recommend.display.descriptionLimit', + label: 'descriptionLimit', + type: 'number', + }, + { key: 'content.0.title', label: 'default title', type: 'text' }, + { key: 'content.0.url', label: 'default url', type: 'text' }, + { + key: 'content.0.description', + label: 'default description', + type: 'textarea', + }, + { key: 'content.0.image', label: 'default image', type: 'text' }, + { key: 'content.0.author', label: 'default author', type: 'text' }, + ], + }, + + { + title: 'Custom form fields', + applies: typeIs('form'), + intro: + 'Adding any field here replaces the default form entirely - ' + + 'formElements takes over from the legacy fields, required and ' + + 'placeholders options.', + fields: [ + { + key: 'formElements', + label: 'formElements', + type: 'list', + row: [ + { + key: 'type', + label: 'type', + type: 'select', + options: [ + 'input', + 'text', + 'email', + 'date', + 'us-postal-code', + 'textarea', + 'select', + 'radio-group', + 'checkbox-group', + ], + }, + { key: 'name', label: 'name', type: 'text' }, + { key: 'label', label: 'label', type: 'text' }, + { key: 'placeholder', label: 'placeholder', type: 'text' }, + { key: 'required', label: 'required', type: 'bool' }, + { + key: 'values', + label: 'values', + type: 'options', + note: 'comma separated, for select and group types', + }, + ], + }, + ], + }, + ]; + + window.PlaygroundFields = { + sections: SECTIONS, + positionsFor: function (layout) { + return POSITIONS[layout] || []; + }, + }; +}()); diff --git a/playground/index.html b/playground/index.html index 60a2e9b..d5dcf99 100644 --- a/playground/index.html +++ b/playground/index.html @@ -16,6 +16,13 @@ Keep stored state + @@ -24,20 +31,26 @@
-
-
+
+
+ + +
+ +
+ +
- Runs as JavaScript, same shape as the examples in - docs/docs/examples/src +