Skip to content

Add interactive playground for documented UI components - #168

Merged
roncodes merged 8 commits into
dev-v0.4.0from
feature/component-playground
Aug 28, 2026
Merged

roncodes merged 8 commits into
dev-v0.4.0from
feature/component-playground

Conversation

@roncodes

@roncodes roncodes commented Aug 25, 2026 •

Copy link
Copy Markdown
Member

Adds an interactive playground for the component surface documented at
fleetbase.io/docs/ui, built from this addon's existing Ember dummy
application.

Why tests/dummy

The dummy app is already a real Ember application that consumes this addon through normal
resolution, with the real addon styles, the real services and the real build. Adding a second
workspace (or Storybook) would mean a second dependency graph, a second build to keep in step, and
previews that drift from what consumers actually render. Everything here is playground-only code
under tests/dummy; nothing was added to addon/, because none of it is useful to consumers.

addon/ and app/ are byte-identical to the base of this PR. No production source changed.

Why scope follows the documentation, not the export list

The addon exports 275 public components. /docs/ui documents 63. Only those 63 get a page.

tests/dummy/app/playground/allowlist.js is the scope authority — resolution path, display name,
category and documentation URL per entry. An existing-but-undocumented public component
(chat-container, metadata-editor, aside-item-scroller, …) resolves to the playground's
deliberate not-found page rather than being exposed automatically. Two tests enforce this in both
directions, and neither compares the registry against app/components; that comparison is exactly
what the playground exists to avoid.

63 of 63 documented components are represented. The live documentation navigation was audited
against the reviewed list and matches it — including the grouped pages (the six Layout::*
scaffolding components share /layout/overview, the four resource layouts share
/layout/resource-tabular, the eight modal layouts share /modals/modal-layouts).

ScheduleCalendar: the documentation is stale

/docs/ui/scheduling/event-calendar is titled "EventCalendar / ScheduleCalendar", but
ScheduleCalendar and ScheduleItemCard were deleted from this addon as confirmed dead code in
e6a3903 during PR #143.

  • EventCalendar is included.
  • ScheduleCalendar and ScheduleItemCard are not restored, and no replacement was invented.
  • No broken route was created to match stale prose.

The mismatch is recorded in the allowlist as REMOVED_FROM_ADDON_STILL_IN_DOCS, asserted by a test
so it cannot be quietly forgotten, and written up in PLAYGROUND.md. Recommendation: correct the
documentation separately
— that page should describe EventCalendar only. Its source is not in
this repository, so it was not touched here.

Other documentation/runtime discrepancies

Registry controls were read off the current templates and classes, not the prose. Where they
disagree the implementation wins, so a control that would silently do nothing never gets written:

  • InputGroup renders its label from @name, not @labelText. @labelText is not read at
    all (input-group.hbs:3), and @placeholder falls back to @name. The existing integration
    tests use @name throughout. The control is bound to @name and labelled accordingly.
  • InputGroup exposes no change callback, so the adapter observes the native input event
    through splattributes.
  • Layout::Resource::Panel has no save button unless @saveTask is passed — deliberate, per
    DEFECTS.md. The example passes a local no-op task so the button is demonstrable.

Routes and iframe behaviour

Route Purpose
/ redirects to the catalog
/components searchable, categorized catalog (q, category in the URL)
/components/:slug full interactive page
/embed/:slug minimal iframe view
anything else intentional not-found

Production uses hash routing, because Pages cannot rewrite deep links:

https://fleetbase.github.io/ember-ui/#/components/button
https://fleetbase.github.io/ember-ui/#/embed/button?state=…

Both routes render the same host component with the same state and the same adapters, so "the
embed shows the same thing" is true by construction rather than by duplication. The embed reports
its height to the parent through a one-way postMessage
(fleetbase:ember-ui-playground:resize); it installs no incoming message handler, and height is
the only thing ever sent. PLAYGROUND.md documents the iframe markup, accessible title, sandbox
guidance, the resize listener, and the frame-src https://fleetbase.github.io CSP the docs site
will need.

Controls, fixtures, state and events

  • Control types: boolean, text, number, select, colour, date, datetime, validated JSON. Invalid
    input shows an accessible message and falls back to the documented default — it never takes the
    preview down.
  • Scenarios do double duty: presets write values into the controls (Button's Primary, Danger,
    Loading, …), fixture scenarios pick data (Table's Five orders / Empty state).
  • One encoded state query parameter. Only non-default values are encoded. Decoding validates
    against the control schema; unknown keys, wrong types and malformed encoding all degrade to
    defaults with a non-fatal warning. Functions, services, records, Files, Dates, Errors and
    non-plain objects are never serialized.
  • The event log summarizes arguments before storing them: DOM events reduced to a few safe fields,
    records to modelName:id, Files to name/type/size (never contents), cycles and depth bounded.
    It never throws.
  • Fixtures are deterministic — nothing reads the clock or the network, and no example calls a live
    API or persists anything. Modal layouts are demonstrated through the real modals-manager
    service, which is how the documentation tells consumers to use them.

A bug this found

Navigating straight from one component page to another keeps the same route and template position,
so Ember reuses the host component and its constructor does not run again — the new component
rendered with the previous one's control values, silently dropping @selectable, @sortable and
@page on Table after visiting Button.

The suite could not catch it: every application test boots a fresh application, so only a direct
page-to-page navigation inside one test reaches it. It was found by driving the built Pages
artifact in a real browser. Three tests now cover that path; two fail without the fix, and the
third was rewritten to navigate directly after it turned out that going via the catalog tears the
host down and cannot reproduce it.

What the host application had to supply

Two gaps surfaced while trying the playground for real. Neither was a component defect; both were
things a consuming application normally provides and the dummy app did not.

Element normalisation. addon/styles/addon.css deliberately ships no @tailwind base — an
addon emitting preflight would clobber every application consuming it — and its @apply-generated
rules assume the elements underneath are already normalised. Without that layer a <button> kept
the user agent's buttontext colour (pure black) instead of inheriting, and form controls kept UA
fonts. The components looked unstyled when only the layer beneath them was missing.

Emitting @tailwind base from the dummy app fixes the previews and breaks the addon: preflight
lands after the addon stylesheet and wins on equal specificity, so button { cursor: pointer }
silently overrides the console's deliberate * { cursor: default } — which
layout/sidebar/navigator-test.js asserts, and which duly went red when I tried it. A real console
loads preflight before the addon; that ordering cannot be reproduced from inside the dummy app.
The existing test was not touched. Instead only the normalisation the previews need is applied,
scoped to .pg-host, with the ancestor inside :where() so the rules weigh exactly what
preflight's own element selectors weigh (0,0,1) and .btn-sm keeps winning.

The console's viewport lock. The shipped CSS contains
body, html { height: 100vh; overflow: hidden } — right for the console, which fills the viewport
and scrolls its panes independently, wrong for an ordinary document. The catalog measured 3080px
inside a 720px non-scrolling viewport, so everything below the fold was unreachable. tests/dummy
loads after the addon stylesheet, so restoring height: auto / overflow-y: auto at equal
specificity is enough — no !important, no selector hacks.

Both are covered by tests/acceptance/playground/styling-test.js, which asserts the computed
styles of a previewed Button (border, background, radius, and that it no longer falls back to the
UA button colour) and that nothing between the catalog and the document clips it.

Also excluded playground-dist/ from the lint ignores alongside dist/ and coverage/: building
the Pages artifact and then linting pushed 4565 vendor-CSS errors through stylelint. CI never saw
it because lint and build run in separate jobs.

Testing

Uses the existing QUnit + Ember Test Helpers + Testem + headless Chrome setup.
No Playwright, Puppeteer, Selenium, Cypress or Storybook was added — setupApplicationTest
already drives real routes in real Chrome, and a second runner would mean a second CI lane, a
second set of flakes, and another thing to keep in step with the coverage lifecycle.

323 playground tests, layered so they assert wiring rather than re-testing component contracts.
tests/integration/components/ remains the source of truth for behaviour and is untouched — the
Button suite does not re-test that a disabled button refuses clicks; it tests that the disabled
control reaches Button.

  • allowlist + registry: scope completeness both ways, slug uniqueness, resolvable components and
    adapters, valid control metadata, the ScheduleCalendar mismatch
  • state codec, controls, event sanitization (including cyclic and throwing values)
  • catalog, component page, embed, resize messaging
  • representative interactions: an input, a tooltip, a modal layout, a table, a resource layout, and
    two store-backed components
  • allowlist-wide smoke coverage: all 63 component routes and all 63 embed routes must settle,
    render their marker and title, resolve their adapter, produce content and raise nothing

Commands and results

All on Node 22.22.2. A non-default --test-port was used throughout because another suite was
running on this machine.

Command Result
pnpm install --frozen-lockfile ✅ lockfile verified
pnpm run lint ✅ exit 0 (js, hbs, css)
pnpm exec ember test --filter=playground ✅ 280/280
pnpm exec ember test (full) ✅ 5524/5524, 0 failures
pnpm run coverage:selftest ✅ 17 cases
node scripts/stamp-coverage-run.js && COVERAGE=true ember test ✅ 5513/5513, LCOV written (300K)
pnpm run coverage:check ❌ inherited — see below
pnpm run build ✅ dist/, 9.0M
pnpm run build:playground ✅ playground-dist/, 9.0M
notification-tray module ×5 ✅ 19/19 every run
--filter="the view-all link reports the press" ✅ passes

Pages artifact inspected: index.html and .nojekyll at the root, 404.html copied from
index.html, every asset URL under /ember-ui/, rootURL: /ember-ui/, locationType: hash, and
no tests/, tests.html, testem.js, coverage/, lcov.info or log files.

Inherited failures — this PR introduces none

coverage:check fails with 267 sites across 96 files in addon/. This is pre-existing:

  • addon/ and app/ are byte-identical to this PR's base — git diff <base> --name-only -- addon/ app/
    is empty. This PR adds only tests and dummy-app code, which can only increase coverage, so
    the base has at least as many failing sites.
  • The playground contributes 0 entries to the coverage report (344 entries, all under addon/),
    so none of it is gated.
  • The failing files are the categories DEFECTS.md already catalogues under "Why the remaining
    coverage gaps are where they are"
    — framework-invoked defaults, resolver-provided services,
    @tracked initializers a constructor pre-empts, statements after a throw, guards behind
    already-disabled buttons, comparator early-return halves.

The 100% gate is not green, and this PR does not claim it is. The campaign is actively closing
these gaps — it advanced through four commits while this work was in progress, and this branch was
rebased onto each.

The notification-tray timeout from PR #143 reproduces in CI on this PR — and equally on the
campaign branch without any of this code.
The Test with coverage job fails with:

Error: Browser timeout exceeded: 120s
Error while executing test: Integration | Component | notification-tray > interacting: the view-all link reports the press

The run aborts at that test, so coverage enforcement is never reached — the same shape of failure
the campaign has been seeing.

Evidence that it is inherited, not introduced here:

  • The campaign branch's own most recent run before this PR existed
    (32866406523, test/coverage-campaign)
    fails with the identical message on the identical test: # tests 2177, pass 2176, fail 1.
    The last six runs on that branch are all failures.
  • This PR changes no addon/ source and does not touch notification-tray or its test.
  • It does not reproduce locally: 5 runs of the module (19/19 each), the specific named test
    three times, and two full-suite runs (5513 and 5520 tests, 0 failures) all passed.

fix/testem-disconnect-timeout (#166) already landed on the campaign branch and raised
browser_disconnect_timeout from 10s to 120s — the message above shows the higher ceiling now
being hit, so that change moved the ceiling without removing the underlying stall. It is a CI-only,
environment-sensitive hang.

It was deliberately not papered over: no global timeout increase, no skip, no weakened
assertion, and no unrelated production change. Diagnosing it needs a CI-reproducible investigation
of what stays pending in that test, which is campaign work rather than playground work.

DEFECTS.md #18 (±1 branch-count nondeterminism) was not investigated and is not claimed fixed —
no fresh differing artifacts were captured to name the responsible file.

GitHub Pages

.github/workflows/playground-pages.yml uses actions/configure-pages,
actions/upload-pages-artifact and actions/deploy-pages with contents: read, pages: write,
id-token: write, the github-pages environment and a pages concurrency group.

Pull request heads are never deployed — a PR from a fork could otherwise publish arbitrary
content to the project's site. It triggers only on pushes to test/coverage-campaign and on
workflow_dispatch.

CI branch filters were widened to include test/coverage-campaign; without that, PRs targeting the
campaign branch ran no checks at all. The build job also builds the Pages artifact and asserts
its contents.

Required repository settings (not done by this PR)

  1. Set Pages source to "GitHub Actions" (Settings → Pages). Until then the workflow builds and
    uploads but has nothing to publish. External repository settings were deliberately not mutated.
  2. Move the trigger to main when the campaign merges, and drop test/coverage-campaign.
    Leaving both would let two branches overwrite the same site unpredictably.

Screenshots

Catalog — 63 of 63 documented components, categorized and searchable:

Catalog

Button component page — metadata, real component, controls, presets, event log:

Button page

Button embed view — preview first, controls below, no catalog chrome:

Button embed

Table — a complex fixture-backed example, deterministic five-row fixture:

Table page

All four were captured from the built playground-dist/ artifact served under /ember-ui/, not
from a dev server.

The scope authority is the component surface documented at fleetbase.io/docs/ui, not the addon's
full public export list: 275 components are exported, 63 are documented, and only those 63 get a
playground page. allowlist.js records that surface — resolution path, display name, category and
documentation URL — and registry.js is built from it.

Argument surfaces were read off the current addon templates and classes rather than the
documentation prose, so a control that would silently do nothing does not get written. Where the
two disagree the implementation wins; the discrepancies are recorded in PLAYGROUND.md.

Alongside it:

- controls.js       control types, coercion and validation. An invalid value falls back to the
                    documented default rather than taking the preview down.
- state-codec.js    one encoded query parameter carrying control state. Decoding validates against
                    the control schema; unknown keys, wrong types and malformed encoding all
                    degrade to defaults with a non-fatal warning. Functions, services, records,
                    Files and non-plain objects are never serialized.
- event-sanitizer.js turns callback arguments into something safe to display: DOM events reduced to
                    a few fields, records to modelName:id, Files to name/type/size, cycles and
                    depth bounded. It never throws.
- fixtures/         deterministic fixtures. Nothing reads the clock or the network.
- host-stubs.js     host-application dependencies the console layout components need, registered
                    from the adapters so the existing integration tests are unaffected.
Uses the existing dummy app rather than a second workspace, so the playground consumes the real
components through normal Ember resolution with the real addon styles. No copies, no Storybook.

Routes: / redirects to the catalog, /components is a searchable categorized catalog,
/components/:slug is the full interactive page and /embed/:slug is the minimal iframe view.
Catalog and detail are siblings rather than parent/child so each owns its own model and query
parameters. Anything else — including undocumented public components — lands on the playground's
deliberate not-found page instead of being exposed automatically.

Both routes render the same host component, so the embed showing the same state as the full page
is true by construction rather than by duplication. Example adapters bind every argument to the
real component explicitly: if an adapter stops forwarding a value, the test for that control fails.

The event log appends after render rather than during it — layout/mobile-navbar invokes @onsetup
from its constructor, and assigning tracked state mid-render is a backtracking re-render error.

The embed reports its height to the parent through a one-way postMessage. It installs no incoming
message handler; height is the only thing ever sent.
Uses the existing QUnit, Ember Test Helpers, Testem and headless Chrome setup. No second
browser-testing framework: setupApplicationTest already drives real routes in real Chrome, and
another runner would mean a second CI lane and a second set of flakes.

These assert the playground's wiring, not the components' contracts — tests/integration/components/
remains the source of truth for component behaviour and is untouched. The Button suite does not
re-test that a disabled button refuses clicks; it tests that the disabled control reaches Button.

- allowlist/registry: scope completeness in both directions, slug uniqueness, resolvable
  components and adapters, valid control metadata, and the ScheduleCalendar mismatch.
- state codec: defaults, round trips, unknown keys, wrong types, malformed encoding, and the
  values that must never be serialized.
- controls and event sanitization, including cyclic and throwing values.
- catalog, component page, embed behaviour and resize messaging.
- route smoke coverage walking the registry: all 63 component routes and all 63 embed routes must
  settle, render their marker and title, resolve their adapter and raise nothing.
build:playground wraps ember build rather than replacing it, so the package build is untouched.
Pages needs hash routing (it cannot rewrite deep links onto index.html) and assets under a
sub-path; both are opt-in through the environment, so ember build, ember serve and the test suite
are unaffected. The base path is a flag, not a constant, so a custom domain later is a CI argument
rather than a code change.

CI: the branch filters now include test/coverage-campaign. Without that, pull requests targeting
the campaign branch ran no checks at all. The build job also builds the Pages artifact and asserts
index.html, .nojekyll and assets/ exist, that assets resolve under the sub-path, and that no test
output or coverage artefact reaches it.

The Pages workflow never deploys a pull request head — a PR from a fork would otherwise be able to
publish arbitrary content to the project's site. It triggers on pushes to test/coverage-campaign
and on workflow_dispatch. Moving the trigger to main after the campaign merges, and setting the
Pages source to GitHub Actions, are recorded in PLAYGROUND.md as post-merge steps.
PLAYGROUND.md covers the documentation-driven scope and how to update it, the architecture, the
registry schema, adding an example, controls and presets, fixtures and services, the event log,
state URLs, iframe integration and resize messaging, the Pages build and its post-merge settings,
and the testing strategy.

It also records the discrepancies found while reconciling the documentation against the
implementation: /docs/ui/scheduling/event-calendar still names ScheduleCalendar, which was deleted
from this addon as dead code in e6a3903 and is deliberately not restored, and InputGroup renders
its label from @name — @labelText is not read by the component at all.
Navigating straight from one component page to another keeps the same route and the same position
in the template, so Ember reuses the host component and its constructor does not run again. The new
component was rendering with the previous one's control values: every key it does not share arrived
as undefined, so Table's @selectable, @Sortable and @page were silently dropped after visiting
Button.

Found by driving the built GitHub Pages artifact, not by the suite — every application test boots a
fresh application, so only a direct page-to-page navigation inside a single test reaches it. Three
tests now cover that path; two of them fail without this fix, and the third was rewritten to
navigate directly after it turned out that going via the catalog tears the host down and cannot
reproduce the bug.

Also: put scripts/build-playground.js under the repo's node ESLint config, drop its unused shebang,
and add the four screenshots referenced from the pull request.
Reported while trying the playground: the components looked unstyled, and the page would not
scroll past the fold. Both were host-application gaps rather than anything wrong with a component.

Element normalisation. addon/styles/addon.css deliberately ships no `@tailwind base` — an addon
emitting preflight would clobber every application consuming it — and its `@apply`-generated rules
assume the elements underneath are already normalised. Without that layer a <button> keeps the user
agent's `buttontext` colour, pure black, instead of inheriting, and form controls keep UA fonts.

Emitting `@tailwind base` here fixes the previews and breaks the addon: preflight lands after the
addon stylesheet and wins on equal specificity, so `button { cursor: pointer }` silently overrides
the console's deliberate `* { cursor: default }` — which navigator-test.js asserts, and which duly
went red. A real console loads preflight before the addon, and that ordering cannot be reproduced
from inside the dummy app. So only the normalisation the previews need is applied, scoped to
.pg-host, with the ancestor inside `:where()` so the rules weigh exactly what preflight's own
element selectors weigh and .btn-sm keeps winning. Nothing outside the playground is affected.

Viewport lock. The shipped CSS contains `body, html { height: 100vh; overflow: hidden }`, which is
right for the console — it fills the viewport and scrolls its panes independently — but the
playground is an ordinary document. Inheriting it left the catalog measuring 3080px inside a 720px
non-scrolling viewport, with everything below unreachable. The dummy app loads after the addon
stylesheet, so restoring `height: auto` and `overflow-y: auto` at equal specificity is enough.

Also excludes playground-dist/ from the lint ignores, alongside dist/ and coverage/. Building the
Pages artifact and then linting put 4565 vendor-CSS errors through stylelint; CI never saw it
because lint and build run in separate jobs.

Covered by tests/acceptance/playground/styling-test.js, which asserts the computed styles of a
previewed Button and that nothing between the catalog and the document clips it. Screenshots
regenerated.
Base automatically changed from test/coverage-campaign to dev-v0.4.0 August 28, 2026 04:25
Implements 'Playground Redesign.dc.html' against the Fleetbase brand ramps in tailwind.config.js —
sky for accent, night/nightsky for dark. No invented colours, no addon or app source touched.

Catalog: the page title and lede are gone, as the design has none; the filter bar is a tinted band
with its own rule; the category rail is divided from the content by a full-height border; labels
are uppercase and muted; cards carry the component name, its slug in mono, and the description.
The design's cmd-K affordance is wired rather than faked — the listener lives and dies with the
catalog, and the addon's own cmd-K binding is never rendered on that route.

Component page: title row with the component chip and Open embed, a segmented source/tests/docs
meta strip, a preview hero on a dotted grid, and an inspector whose boolean rows put the label and
control on one line.

Three defects surfaced while building it, each now covered by a test that fails without the fix:

- Dark mode never actually themed the previews. 1094 of the addon's rules are scoped to
  `body[data-theme='dark']` specifically, and the attribute was only being set on the playground's
  own container — so dark mode gave near-black component text on a dark surface. A modifier now
  mirrors the theme onto <body>, which is what a real console does.
- The offered embed URL was built by concatenating location.pathname with '#/embed/…', which is
  only valid in the hash-routed Pages build. In development it produced
  '/components/table#/embed/table'. It is built through the router now, so it is right under both
  location types.
- Playground typography was set on <body>, so it reached #ember-testing and every component
  integration test with it. Dropping the base to 13px flipped a drag-reorder decision in
  query-builder/group-by-test.js, which derives clientY from element geometry. Type now lives on
  the playground's own containers and the addon keeps its 16px baseline.

Table previews additionally opt into @useTfootPagination: the default pagination bar is fixed to
the viewport and landed outside the preview panel, which is also what the design depicts.
@roncodes
roncodes force-pushed the feature/component-playground branch from 5207c7e to acdd5ad Compare August 28, 2026 05:46
@roncodes
roncodes merged commit 27ed417 into dev-v0.4.0 Aug 28, 2026
@roncodes
roncodes deleted the feature/component-playground branch August 28, 2026 05:54
@roncodes roncodes mentioned this pull request Aug 28, 2026
This was referenced Sep 9, 2026
@roncodes roncodes mentioned this pull request Sep 25, 2026
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.

1 participant