Skip to content

Add a widget playground for manual testing and demos - #677

Open
ashyablok-cs wants to merge 21 commits into
developfrom
widget-playground
Open

ashyablok-cs wants to merge 21 commits into
developfrom
widget-playground

Conversation

@ashyablok-cs

Copy link
Copy Markdown
Contributor

Stacked on #676 — based on LYT-1120-widget-dialog-accessible-names, so this diff
shows only the playground. Retarget to develop once #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 one
from 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 runs
verbatim — which is the case I most wanted when reproducing #675.

Run it with yarn run local and open
localhost: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.html instead. No z-index
contest, 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.PathforaCSS is set before the SDK loads. The constructor reads it and
    runs on evaluation (src/rollup/pathfora.js:141, :150), so a later assignment is
    ignored. Without it the page loads c.lytics.io/static/pathfora.min.css and that
    production 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.
  • Stored state is cleared before every render. pathfora.clearAll() resets
    in-memory trackers only — it never touches storage. So a submitted gate stays unlocked
    and impression caps stay spent, across renders and across reloads. Both
    localStorage and sessionStorage are swept (impressions and recommendations write to
    each), by the nine key prefixes in src/rollup/globals/config.js:14-22. Keep stored
    state
    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/gate is labelled broken in the UI. Its Confirm button does nothing:
    construct-widget-actions.js:272-284 never assigns a widgetAction for
    type: 'sitegate', so the click handler returns early — it never tracks, never writes
    PathforaUnlocked_, never closes. Form with layout: 'gate' is the working
    equivalent. Flagged in the sidebar so nobody debugs it twice.
  • Gate layouts get no position. validateWidgetPosition has no case 'gate', so
    choices stays undefined and choices.length throws. It is unreachable today only
    because the call is guarded by if (config.position) and every type defaults
    position to ''.

Also

  • Removes test.html. It was an unreferenced, stale version of this: titled "Action
    widget 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 harnesses
    where the worse one is the one people find first.
  • Extends the gulp lint glob to playground/**/*.js, which neither that glob nor
    yarn lint previously reached.
  • README section under Development.

playground.js is not prettier-formatted. The repo's eslint config sets
wrap-iife: ['error', 'outside'], which wants (function () {}()), and prettier rewrites
that to (function () {})(). eslint is what CI enforces and prettier is not even a
dependency, so eslint wins. The HTML and CSS are prettier-formatted.

Verification

  • All 16 combinations render and are visible in the stage; driven programmatically in
    Chrome, not by clicking through.
  • No c.lytics.io stylesheet in the stage frame — the local build is what is loaded.
  • Submitted a Form/gate, confirmed PathforaUnlocked_ is written, re-rendered and it
    appears again. With Keep stored state ticked it is correctly suppressed, which
    proves the toggle works rather than the sweep being a no-op.
  • Editor: edited config takes effect; a syntax error and an invalid type/layout
    combination both surface in the page rather than only the console.
  • yarn lint, gulp build, suite at 300 passing — unchanged, and dist/ is
    byte-identical to the parent branch. Nothing under src/rollup/** is touched.

Note yarn lint reports two pre-existing errors on develop
(replace-entity-field.js, expiring-local-storage.js) in files neither branch touches.
CI does not see them because src/**/*.js expands as src/*/*.js under its shell.

🤖 Generated with Claude Code

@snyk-io

snyk-io Bot commented Sep 15, 2026

Copy link
Copy Markdown

Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
Open Source Security 0 0 0 0 0 issues
Licenses 0 0 0 0 0 issues
Code Security 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

ashyablok-cs and others added 21 commits September 17, 2026 15:49
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
ashyablok-cs changed the base branch from LYT-1120-widget-dialog-accessible-names to develop September 17, 2026 22:50
@ashyablok-cs
ashyablok-cs requested a review from a team September 17, 2026 23:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants