Add a widget playground for manual testing and demos - #677
Open
ashyablok-cs wants to merge 21 commits into
Open
ashyablok-cs wants to merge 21 commits into
ashyablok-cs wants to merge 21 commits into
Conversation
✅ Snyk checks have passed. No issues have been found so far.
💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse. |
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
Two bugs that only showed up by opening the page rather than scripting it. Every earlier check clicked a catalogue entry first, which set the state and masked both. buildConfig ran before any entry was selected, where state.config is still null - JSON.parse(JSON.stringify(null)) is null, so reading config.content threw and init never finished. The page loaded with a stale "Loading…" status, an empty form and an uncaught TypeError. The readiness check for the stage frame then used contentDocument.readyState, which is not a trustworthy signal: a freshly created iframe reports 'complete' for its own initial blank document, long before stage.html and the SDK inside it have loaded. Rendering against that gave "pathfora is not defined". The SDK actually being present is the real signal, so that is what is checked now, from both the load event and a short poll to cover either ordering. The playground now opens on the first catalogue entry with a widget already rendered, rather than an empty form. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Branding is no longer a thing in practice, so offering a control for it just invites people to turn on something they do not want. The library still supports it - reset-default-props.js defaults it to false, construct-widget-layout.js renders the Lytics mark and set-widget-classname.js adds pf-widget-has-branding - so it can still be set by hand in the Config pane if anyone needs to reproduce an older configuration. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Takes the Trigger toolbar button with it - releasing manualTrigger widgets was the only thing it did, so leaving it would have been a control with nothing to act on. The library still supports manualTrigger, so a config written by hand in the Config pane can still set it. Such a widget will now sit waiting forever though, since nothing in the playground calls pathfora.triggerWidgets() any more. Worth putting the button back if that turns out to matter. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Puts the two lead-capture types next to each other and moves the least used of the three to the bottom. Message stays first, so the entry the playground opens on is unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Covers both states: headline, msg, delay, and the ok/cancel buttons each state can carry. Available for form and subscription on modal, slideout, gate and inline. Bar is excluded because constructWidgetLayout never builds the state elements for it, so a bar with formStates just blanks for a few seconds. Also adds a "simulate submit outcome" control, because the error state is otherwise unreachable from the form: it only fires for a confirmAction with waitForAsyncResponse, which needs a callback, and a function cannot survive JSON.stringify. The config carries a sentinel string that snippetFor swaps for real source on the way out, so the generated snippet stays copy-pasteable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Hide panels drops both the catalogue and the controls column, so the stage frame takes the whole window - a modal or gate then sits at something close to the proportions it would have on a real page, rather than centred in the 960px left over beside the controls. Collapses both panels rather than just the catalogue, since hiding only the 190px catalogue would not have got near full width. The rendered widget survives the toggle: it is CSS only, and the stage frame is never reloaded. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It sat at the far right of the toolbar, a long way from the thing it controls. Now it is the first item in the bar, directly above the panels it collapses, as a chevron that flips to point the other way when they are hidden. The icon is an inline svg rather than a glyph or an emoji, so it inherits currentColor and renders identically everywhere. It is marked aria-hidden, and the button is named by aria-label and title, both updated on toggle - an icon-only button with no accessible name would be announced as just "button". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It was in the toolbar, which did not read as belonging to the panels it controls. It now sits on the seam between the panels and the stage, vertically centred, and slides to the window edge when they collapse - so its position says what it is attached to. The offset comes from --pg-sidebar-w and --pg-controls-w, which the panels themselves are now sized from, so the handle cannot drift out of line with the seam if either width changes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Lytics tag toggle reloads the stage with the real tag installed, against the same demo account the published docs examples use. Off by default, so the playground stays network-free for anyone just checking a layout. The tag is configured with publish and preview disabled. That stops the demo account's own campaigns rendering on top of the widget under test, and as a side effect stops the tag installing its own SDK - so the local dist/ build stays in charge, which is the whole point of the playground. Verified by the rendered aria-labelledby: the local build namespaces it per widget, production 1.2.21 emits a dangling "pf-widget-headline". The new Audience section targets a segment, matched against the visitor's own memberships, with their current segments offered as suggestions and free text allowed so you can target one they are not in and watch the widget correctly not render. Exclusions are modelled as subtracting from the target rather than as an alternative to it, because that is all initTargetedWidgets does with them - an exclude on its own never matches anything and silently does nothing. Two things this turned up: - Targeted renders are async, via addCallback, so the status line was read before the widget existed and always said "Nothing rendered". It now takes a second look once the tag has had a chance to answer. - With the real tag, a targeted widget initialises twice and throws "Cannot add two widgets with the same id". add-callback.js registers the callback with jstag.entityReady and then falls through to push it onto pathfora.callbacks as well - the legacy branch above it returns, the jstag branch does not - and the tag flushes both. That is a library bug, not a playground one, so it is reported in the UI as a known issue rather than as a failed render. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
My earlier commit claimed add-callback.js has a bug: that it registers a
callback with jstag.entityReady and then wrongly falls through to push it
onto pathfora.callbacks as well. That was wrong, and the reasoning was
backwards.
What is actually happening, on this demo account: the entity the tag
returns is { data: { segments: [...] } } with no `user` key, and the
entityReady branch only calls back `if (e.data && e.data.user)`. So that
branch never fires here. The pathfora.callbacks push is not a stray
fallback - it is the only path that runs, drained by the tag as it starts
up, and targeting depends on it entirely.
Which also explains the double init seen earlier: it needs both paths to
fire, so it takes an account whose entity does carry user data plus a
render landing in the window before the tag drains the queue. Not
something a normal integration hits, which matches nobody having seen it
in production.
The real consequence for the playground is different and worse: the tag
drains that queue once. A second targeted render queues a callback that
nothing will ever drain, so it silently renders nothing. Targeted renders
now reload the stage, which gives the tag another pass. They also get a
longer debounce, since each one is a frame reload.
Also guards commit() against running before an entry is selected, which
threw while the stage was still coming up.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The targeting suggestions were the visitor's own memberships, which on a fresh profile is just "all" - not much of a picker. They are now the 56 Lytics managed audiences on the demo account the stage's tag points at, with their friendly names shown alongside the slug. Hardcoded, because reading them live would mean keeping an API key somewhere. Free text is still accepted, so a custom audience or another account's slug still works. Also drops the live getSegments lookup that fed the old suggestions. It was reaching into the stage frame on every field render and nothing reads it any more. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
makeControl already told the commit handler whether a given event was structural - false while typing, true once the value settled - but the closure receiving it was declared as function (raw) and dropped the second argument, falling back to the field-level flag. So every keystroke in a field marked structural, which is how the audience picker reveals the exclude field, rebuilt the whole form. Scroll went to the top and the focus went with it, once per character. The handler now honours the per-event flag, so typing only updates the config and the rebuild waits until the value settles on change. Rebuilds also no longer lose your place: buildForm records the scroll position, which control had focus and where the caret was, and puts them back afterwards. That covers the rebuilds that still have to happen - picking a theme, setting a position - not just the one that was firing too often. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It was hidden until a "show to" segment was set. Showing it always, and the generated config now emits an exclude-only targeting object rather than falling back to a plain array. Worth knowing what the SDK currently does with that, which I checked rather than assumed: plain array renders exclude only, visitor in segment nothing exclude only, visitor not in nothing common + exclude, visitor in renders, exclusion ignored So on this version an exclusion only ever subtracts from a "show to" match - alone it matches nothing and the widget never appears, and it cannot currently express "show to everyone except X". The field says so rather than the playground hiding it. Dropping the gate also means targetSegment no longer needs to be structural, so typing in either audience field stops rebuilding the form at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The stage used bare element selectors - p, h1, code - plus typography on
body. A widget message is a p, so `p { max-width: 60ch }` was capping
modal copy and wrapping it early, and body's line-height and colour were
inheriting in too.
Every rule is now an explicit stage- class, and body carries only margin,
padding and background. Scoping to a wrapper would not have been enough:
inline layouts mount into #pg-inline-host, which lives inside this page's
own markup, so a descendant selector still reaches them. Verified both -
computed max-width on the message is none for a modal and for an inline,
while the stage's own copy stays capped.
The playground chrome was namespaced pg- from the start for exactly this
reason. The stage needed the same care and did not get it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reverts the UI half of dbbf143. An exclusion only subtracts from widgets a target already matched, so on its own it matches nothing and the widget never renders - measured against the SDK rather than assumed: exclude only, visitor in segment nothing exclude only, visitor not in nothing common + exclude, visitor in renders, exclusion ignored Keeping the field hidden until there is something to subtract from means the form cannot produce that config at all, which beats rendering nothing and explaining why in a note. The initCall change from dbbf143 stays: it emits target and exclude independently, so a hand-written exclusion in the config pane is still passed through rather than silently dropped. targetSegment goes back to being structural, which is what reveals the exclude field. That no longer costs anything - since 5bc4aea the rebuild waits for the value to settle, so typing an audience name holds its scroll position, focus and caret. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The collection field was free text with nothing to go on. It now suggests the four Lytics managed collections on the demo account, with their names shown alongside the slug, the same way the audience picker works. Still free text underneath, so another account's collection can be typed in. Hardcoded for the same reason as the audiences: reading them live would mean keeping an API key somewhere. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Content recommendation and Audience do nothing without the tag - no account to call, no profile to match against - and there was nothing in the form saying so. They now carry a note when the tag is off, which clears once it is on. Also stops a tag toggle throwing away what you were configuring. It reset state and bounced back to the first catalogue entry, which was a poor reward for acting on the note the toggle now prompts. The selection and its settings survive the stage reload, and render straight into the fresh frame rather than asking for another one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Targets a field on the Lytics user profile rather than an audience, using
the rule form of a target entry. Generates calls against the public
pathfora.rules helpers - eq, notEq, includes, excludes, gt, gte, lt, lte
- so the snippet stays readable and copy-pasteable.
Three things the SDK forced:
- segment and rule cannot share a target entry; validateWidgetsObject
throws if they do. Setting both emits two entries in one target list,
which pathfora ORs.
- gt/gte/lt/lte parseInt the attribute, so their operand is emitted as a
bare number rather than a quoted string.
- a rule is handed e.data.user, not the top of the entity - which on this
tag is just { user, errors }. The attribute suggestions read
data.user's keys, falling back to data for the legacy lio shape.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The suggestions were read once, when the stage became ready - which is when the tag script has loaded, not when the visitor's profile has. So getEntity() was still empty and the list rendered blank. entityReady is a real function by that point, since the tag itself has loaded, so the form now rebuilds when the profile lands. buildForm already preserves scroll, focus and caret, so the rebuild is not disruptive. With that in place the outstanding checks from 413f5e8 pass: the suggestions show the visitor's actual profile fields, and a rule filters for real - segments includes "all" renders, includes a segment they are not in renders nothing, and going back to a matching rule brings it back. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ashyablok-cs
force-pushed
the
widget-playground
branch
from
September 17, 2026 22:50
1cb3138 to
bae44e0
Compare
ashyablok-cs
changed the base branch from
LYT-1120-widget-dialog-accessible-names
to
develop
September 17, 2026 22:50
vedant-contentstack
approved these changes
Sep 18, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #676 — based on
LYT-1120-widget-dialog-accessible-names, so this diffshows only the playground. Retarget to
developonce #676 merges.Why
There is 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. This makes that harness a real thing.
playground/renders any of the 16 valid type × layout combinations. Picking onefrom the catalogue loads its config into an editor; change it and render again. The
editor runs as JavaScript in the same shape as the 52 examples in
docs/docs/examples/src, so a snippet from a customer bug report pastes in and runsverbatim — which is the case I most wanted when reproducing #675.
Run it with
yarn run localand openlocalhost:8080/playground/. It works on a fresh
clone with no build step, since
dist/is committed.Widgets render in an iframe
The first working version rendered widgets in the playground page itself. That put a
bottom-left slideout behind the sidebar and a top-fixed bar behind the toolbar, and
there is no corner of the chrome that is reliably out of the way — a toggle only
half-fixes it. So widgets render into
playground/stage.htmlinstead. No z-indexcontest, no CSS bleed from the playground into the thing being inspected, and a bar
lands exactly where it would on a customer's page. It is the same reason Storybook
frames its canvas.
Two traps it handles
Both cost me real time on #675, and both are invisible when you get them wrong:
window.PathforaCSSis set before the SDK loads. The constructor reads it andruns on evaluation (
src/rollup/pathfora.js:141,:150), so a later assignment isignored. Without it the page loads
c.lytics.io/static/pathfora.min.cssand thatproduction CSS wins the cascade over your local build — local CSS edits appear to do
nothing, with no error. This made me report an animation fix as broken when it was
fine. Worth knowing independently of this PR: you cannot verify local CSS on any page
that lets the library load its own stylesheet.
pathfora.clearAll()resetsin-memory trackers only — it never touches storage. So a submitted gate stays unlocked
and impression caps stay spent, across renders and across reloads. Both
localStorageandsessionStorageare swept (impressions and recommendations write toeach), by the nine key prefixes in
src/rollup/globals/config.js:14-22. Keep storedstate opts out for anyone deliberately testing impression caps or
hideAfterAction.Two library bugs it surfaces
Found while building the catalogue; neither is fixed here.
SiteGate/gateis labelled broken in the UI. Its Confirm button does nothing:construct-widget-actions.js:272-284never assigns awidgetActionfortype: 'sitegate', so the click handler returns early — it never tracks, never writesPathforaUnlocked_, never closes.Formwithlayout: 'gate'is the workingequivalent. Flagged in the sidebar so nobody debugs it twice.
position.validateWidgetPositionhas nocase 'gate', sochoicesstaysundefinedandchoices.lengththrows. It is unreachable today onlybecause the call is guarded by
if (config.position)and every type defaultspositionto''.Also
test.html. It was an unreferenced, stale version of this: titled "Actionwidget example" while rendering a form gate, and it loads the jstag, which installs the
production SDK alongside the local
dist/build. Leaving it means two harnesseswhere the worse one is the one people find first.
playground/**/*.js, which neither that glob noryarn lintpreviously reached.playground.jsis not prettier-formatted. The repo's eslint config setswrap-iife: ['error', 'outside'], which wants(function () {}()), and prettier rewritesthat to
(function () {})(). eslint is what CI enforces and prettier is not even adependency, so eslint wins. The HTML and CSS are prettier-formatted.
Verification
Chrome, not by clicking through.
c.lytics.iostylesheet in the stage frame — the local build is what is loaded.Form/gate, confirmedPathforaUnlocked_is written, re-rendered and itappears again. With Keep stored state ticked it is correctly suppressed, which
proves the toggle works rather than the sweep being a no-op.
combination both surface in the page rather than only the console.
yarn lint,gulp build, suite at 300 passing — unchanged, anddist/isbyte-identical to the parent branch. Nothing under
src/rollup/**is touched.Note
yarn lintreports two pre-existing errors ondevelop(
replace-entity-field.js,expiring-local-storage.js) in files neither branch touches.CI does not see them because
src/**/*.jsexpands assrc/*/*.jsunder its shell.🤖 Generated with Claude Code