Skip to content

feat(website): serve the UI gallery at webjs.dev/ui#1126

Open
vivek7405 wants to merge 27 commits into
mainfrom
feat/ui-gallery-on-webjs-dev
Open

feat(website): serve the UI gallery at webjs.dev/ui#1126
vivek7405 wants to merge 27 commits into
mainfrom
feat/ui-gallery-on-webjs-dev

Conversation

@vivek7405

@vivek7405 vivek7405 commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Closes #1099

The component gallery moves from ui.webjs.dev onto the marketing app at webjs.dev/ui, rendering in the same shell as /docs: the header, footer, theme toggle, fonts, and design tokens are literally the same code every other page uses, and the sidebar comes from a shell module the two sections now share. There is no landing page. /ui opens straight onto the gallery with the introduction in the content column, the way /docs opens on Getting Started, and the sidebar groups components under plain Tier 1 / Tier 2 headers.

Two reasons, the same as the docs move. In search, a subdomain accrues authority to itself rather than to webjs.dev. In design, the gallery carried a parallel layout, nav, footer, announce strip, and a duplicated @theme block, so a reader crossing over left one design system and entered another.

ui.webjs.dev becomes a redirect-only host, and it has to keep resolving forever for a harder reason than the docs host did: /registry/* is a live API that already-published packages fetch from.

The registry gate, settled before anything moved

The issue called this the whole risk, so it was verified against real published tarballs rather than the source in this repo. @webjsdev/ui@0.3.1 and 0.3.8 were installed from npm and pointed at a host that 301s cross-origin; both followed the redirect and parsed the result. Then the full chain was run end to end: the published 0.3.8 binary, pointed at the OLD URL shape, followed the 301 into the new endpoint and wrote a genuine components/ui/card.ts.

webjsui add card --registry <old-host>/registry
  -> 301 <new-host>/ui/registry/card.json
  -> 200, wrote components/ui/card.ts

Since 0.3.9 the kit resolves local-first (#983), so add / list / view never reach the network on the default registry at all; the exposed population is 0.3.8 and below, plus webjsui diff at any version. Full write-up in a comment below, including what it rules out (the fallback plan of maintaining a second composer on the old host indefinitely).

What changed

  • website/app/ui/**: the introduction at /ui, one page per component at /ui/<name>, and the registry API at /ui/registry/**. The composer moved to website/modules/ui/queries/registry.server.ts and still reads its sources from packages/ui/packages/registry/, which is unchanged.
  • The docs shell is now shared. website/lib/docs-shell.ts carries the sidebar, the mobile drawer, and the .prose-docs typography; /docs and /ui both render through it, so they cannot drift. No DOM change for /docs.
  • Preview panes render in the kit's neutral palette, scoped to .ui-preview, so a demo looks like the reader's own app while the page around it stays webjs.dev. The old site could declare those values on :root because nothing else lived on that domain; here they must not leak.
  • ui.webjs.dev redirects, path-aware rather than path-preserving: /registry/** keeps its shape under /ui, /docs/components/<name> flattens to /ui/<name>, and / and /docs both land on /ui. A blind prefix redirect would have sent every existing component link to /ui/docs/components/<name>, which 404s.
  • The gallery is in the sitemap, one URL per component enumerated from the live registry index, so a new component is crawlable the moment it exists. ui.webjs.dev served no sitemap at all, which is most of why this migration was cheap.
  • Hardcoded URLs updated for future releases: HOSTED_REGISTRY_URL, the package homepages, and the README catalogue links.

Bugs and drift found on the way

@theme vs @theme inline silently broke every preview. A plain @theme emits --color-primary: var(--primary) on :root and compiles the utility to var(--color-primary). Custom properties substitute at the element where they are DECLARED, so that inner var(--primary) resolves against :root, where the kit palette does not exist. Every filled component rendered transparent while outlined ones looked fine, because only the latter got their color from a border utility. Caught by looking at the rendered page, not by any test that existed. The scoping is now asserted.

/docs/ui was a second, badly drifted description of the same kit. It advertised roughly 55 components against an actual 32, listed two dozen that had been removed in the v1 scope cut, and showed a <ui-button> / <ui-card> API the kit does not have (those are Tier-1 class helpers, not custom elements). It is deleted and permanently redirects to /ui, so one page owns the keyword rather than two cannibalising it.

The cross-framework claim was overstated. The page said the CLI detects Next, Astro, Vite, Lit, or plain projects and adapts. Detection covers four types, neither Lit nor SvelteKit is among them (both silently fall through to plain), and all it decides is the stylesheet path and the alias set written into components.json. The copy is now scoped to WebJs apps; removing the behaviour from the package is filed as #1129.

The dev server's orphan-component scan reads comments. A JSDoc line describing a Tier-2 component as class X extends WebComponent was reported on every boot as an unregistered component in a file that declares none. Reworded as prose here; the analyser weakness is pre-existing and still fires for the docs pages that legitimately contain code samples.

Deliberately not done

The $schema URLs are left byte-identical. The issue lists ui.webjs.dev/schema.json as something that must keep resolving, but it does not resolve today and never has: both it and /schema/registry-item.json return 404 on the live host. There is no contract to preserve, and repointing a dead link at a webjs.dev/ui/... path that would also 404 would rewrite the $schema line in every user's components.json without making anything resolve. Making them actually serve a schema is real work (zod@3.25 here has no toJSONSchema) and belongs in its own issue.

Test plan

Every number below was measured from a COLD tree (rm -rf website/components/ui website/lib/ui first). An earlier version of this plan was measured on a working tree where a dev server had already generated that gitignored mirror, which made CI red while local runs looked green. The pretest hooks exist so that gap cannot reopen.

  • Unit + integration: npm test 3338 pass, 0 fail.
  • Website suite: 122 pass, 0 fail. New ui-gallery: chrome compared byte for byte against a marketing page through the real request pipeline, one-header/footer/toggle counts, shared-shell markers, bare tier headers, every registry component reachable, non-components 404, the palette scoped and mapped through @theme inline, the dark variant tracking data-theme, the registry endpoints (manifest, flat index, inlined item source, reserved slugs, CORS, static-over-dynamic precedence), sitemap coverage, and the /docs/ui redirect.
  • Redirect host: test/ui/ui-host-redirect.test.mjs 11 pass, asserting destinations rather than the presence of a redirect, including the asset passthrough and CORS on the registry 301.
  • Browser: 35 pass, including new suites for the preview toggle's tab pattern and the sidebar drawer's interaction with the header menu.
  • E2E: WEBJS_E2E=1 78 pass, 0 fail.
  • UI touch e2e: 5 pass against /ui, including the soft-nav round trip that exercises the client router across gallery links.
  • Bun: node scripts/run-bun-tests.js 253 pass, 0 genuine failures. bun test test/ui/ui-host-redirect.test.mjs green. bun test/bun/app-boot.mjs green on /, /docs/*, /ui, /ui/button, 42 preloads, none broken.
  • Counterfactuals: reverting @theme inline to a plain @theme reds the scoping test; dropping the component type guard reds the non-component 404 test; neutering the redirect's component rule reds 3 of the mapping tests.
  • Real-client proof: published @webjsdev/ui@0.3.8 init + add against both the new endpoint and the old redirecting one, writing genuine component source both times.
  • Dogfood: website boots 200 on /, /docs/*, /ui, /ui/button in prod mode with no broken modulepreload, on Node and Bun; blog e2e green; both redirect hosts covered by their mapping tests rather than a vacuous 301 status check.
  • webjs check clean on website/ and on the redirect host; tsc --noEmit clean on both.
  • Manual: verified in a real browser that previews render in the kit palette in light and dark, that dark mode tracks the site's data-theme rather than only the OS preference, and that the gallery reads as the same site as /docs.

Doc surfaces

Updated: AGENTS.md, README.md, framework-dev.md, packages/ui/AGENTS.md (module map plus the mirror footgun, whose scope narrowed), packages/ui/README.md catalogue links, packages/cli/README.md, packages/ui/packages/registry/README.md, the redirect host's own AGENTS.md and README.md (rewritten), examples/blog/AGENTS.md and CONVENTIONS.md, Dockerfile, compose.yaml, .github/workflows/ci.yml, and the start-work skill's dogfood gate.

N/A: the MCP server (no introspection projection changed; its ui tool reads the kit inventory from the package, not the website). The editor plugins (no intellisense, grammar, snippet, or command surface changed). The scaffold generators (webjs create writes a components.json whose $schema is deliberately unchanged, and nothing it emits references the gallery).

Follow-up, not in this PR

ui.webjs.dev needs its Railway service redeployed from this branch after merge. It defaults to redirecting at https://webjs.dev with no env var set, so no Railway variable change is required; the UI_URL variable on the website service becomes dead and can be removed at leisure. Scoping the ui package to WebJs projects is #1129.

The sidebar, mobile drawer, and .prose-docs typography move out of the
docs sub-layout into a shared shell so the component library at /ui
(#1099) can render the exact same chrome instead of a drifting copy.
No DOM change for /docs; the source-reading chrome guards now point at
the shell file.
@vivek7405 vivek7405 self-assigned this Jul 27, 2026
scripts/copy-registry.mjs copies packages/ui/packages/registry components
into the gitignored components/ui/ and lib/ui/, rewriting the kit-local
lib imports, so the upcoming /ui gallery pages (#1099) can import the
components for SSR + live previews. Wired into webjs.dev.before /
webjs.start.before, with the registry sources added to the Tailwind
regenerate inputs.
The gallery moves off ui.webjs.dev into the marketing app, rendering in
the same shell as /docs: shared header, footer, theme toggle, fonts, and
tokens, with the component list in the sidebar under plain Tier 1 and
Tier 2 headers. There is no landing page; /ui opens on the introduction
the way /docs opens on Getting Started.

Preview panes carry the kit's neutral shadcn palette, scoped to
.ui-preview so a demo looks like the reader's own app while the page
around it stays webjs.dev. Those tokens sit in a @theme inline block
because a plain @theme resolves its var() at :root, where the kit
palette does not exist, which renders every filled component
transparent.
The page claimed the CLI detects Next, Astro, Vite, Lit, or plain
projects and adapts. Detection covers four types and neither Lit nor
SvelteKit is among them, and all it picks is the stylesheet path and the
alias set written into components.json. Dropping the cross-framework
pitch rather than describing it more precisely: the kit is for WebJs
apps.
Component pages titled '<Name> | WebJs UI' rather than '<name>: WebJs
UI', so a tab or a search result reads the same coming from /docs or
/ui. The registry identifier stays lowercase where it is the thing you
type.
The sidebar lists every component on every page, so the 'All components'
control above each title was a second way to do what the nav already
does. The heading now reads as a name (Alert Dialog) while the registry
identifier stays lowercase wherever it is a value you type.
The three endpoints shipped @webjsdev/ui versions fetch from
(/registry, /registry/index.json, /registry/<name>.json) now answer under
/ui as well, ahead of turning ui.webjs.dev into a redirect host. Shared
headers live in one module so the three cannot drift on caching or CORS.

Verified by running the real published webjsui 0.3.8 binary against
http://localhost:5001/ui/registry: init wrote components.json and the
theme, add fetched and wrote a genuine components/ui/button.ts.
The gallery and the registry API now live at webjs.dev/ui, so this app
drops its pages, layout, chrome, assets, and registry composer and keeps
only a middleware that permanently redirects.

The mapping is path-aware rather than a blind prefix: /registry/** keeps
its shape under /ui, /docs/components/<name> flattens to /ui/<name>, and
/ and /docs both land on /ui. A prefix redirect would have sent every
existing component link to /ui/docs/components/<name>, which 404s.

/registry is a live API for already-published packages, so the redirect
also carries CORS, and test/ui/ui-host-redirect.test.mjs asserts
destinations rather than the presence of a redirect.
@vivek7405

Copy link
Copy Markdown
Collaborator Author

Registry compatibility: how I settled the gating question, and what it rules out

The issue called the registry coupling the whole risk and said to settle it before anything else, so this is what I did before writing a line of the migration.

The question is whether a 301 from ui.webjs.dev/registry is safe for CLIs that are ALREADY on people's machines. Reading fetcher.js in this repo does not answer that, because what matters is the code in the published tarballs, so I tested those directly. I installed @webjsdev/ui@0.3.1 (oldest published) and @webjsdev/ui@0.3.8 (newest version that still takes the network path) from npm, stood up a host that 301s every /registry request cross-origin to a second host serving the JSON, and called their fetchRegistryItem / fetchRegistryIndex. Both followed the redirect and parsed the result. fetch follows redirects by default, and neither version opts out.

Then I ran the real thing end to end rather than trusting the unit-level result. With the gallery serving on one port and the redirect host on another, I pointed the published 0.3.8 binary at the OLD URL shape:

webjsui add card --registry http://localhost:5003/registry
  -> 301 http://localhost:5001/ui/registry/card.json
  -> 200, wrote components/ui/card.ts

The file it wrote is the genuine card source, not an empty shell. Same for init (components.json, lib/utils.ts, and the theme all written) and for add button straight against the new endpoint.

Worth noting how small the blast radius actually is: since 0.3.9 the kit resolves local-first (#983), so add / list / view never reach the network on the default registry at all. The exposed population is 0.3.8 and below, plus webjsui diff at any version (it deliberately compares against the live upstream).

What this rules out. The issue's fallback plan was that if redirects were not followed, ui.webjs.dev/registry would have to keep SERVING the registry directly forever, which would mean maintaining a second copy of the composer on a second host indefinitely. That is not needed, so the old host is now purely a redirect and there is one composer.

One premise in the issue turned out to be wrong. It lists ui.webjs.dev/schema.json and the schema paths as things that must keep resolving. They do not resolve today and never have: I probed the live host and both /schema.json and /schema/registry-item.json return 404. So there is no live contract to preserve there. I deliberately left the $schema values byte-identical rather than repointing them at a webjs.dev/ui/... path that would also 404, because that would rewrite the $schema line in every user's components.json without making anything resolve. Making those URLs actually serve a schema is real work and belongs in its own issue; zod@3.25 here has no toJSONSchema, so it is not a two-line addition.

The mapping is the part worth reviewing. A blind path-preserving redirect (what the docs host does) would have been wrong here, because the old URL shapes are not the new ones: /docs/components/button has to become /ui/button, not /ui/docs/components/button. test/ui/ui-host-redirect.test.mjs therefore asserts destinations, not the presence of a redirect, since a redirect that resolves to the wrong path looks healthy from outside. Counterfactual: neutering the component rule reds 3 of those 10 tests.

…page

The sitemap enumerates one URL per component from the live registry
index, so a component added to the registry is crawlable the moment it
exists. Themes and lib items are excluded: they have no page.

/docs/ui was a second hand-written description of the same kit and had
drifted badly, claiming roughly 55 components against an actual 32 and
showing a <ui-button> API the kit does not have. It is deleted and its
URL permanently redirects to /ui, so one page owns the keyword.
The composer, the Dockerfile bake guard, the preload probe, and the SEO
tag checks all pointed at files the migration moved or deleted. The
guards themselves still matter, so they follow the code rather than
being dropped: the preload probe now covers /ui and /ui/button, and the
SEO checks cover only apps that still serve HTML.

Adds a bake-ordering assertion (copy-registry before Tailwind) that
nothing covered: the mirrored sources are a scanned @source, so a
stylesheet built first is missing every kit utility class and the
previews render unstyled in production only. Also pins
HOSTED_REGISTRY_URL, which selects local-first resolution.
The touch e2e booted the ui-website and read /docs/components/<name>,
which the migration emptied, so it would have failed against a host that
now only redirects. It boots the marketing app and reads /ui/<name>
instead; all 5 checks pass, including the soft-nav round trip that
exercises the client router across gallery links.

The Bun and Node boot checks drop the two redirect-only hosts, whose
routes are empty 301s that pass a status check vacuously, and cover /ui
and /ui/button on the app that actually serves them.
Root README, framework-dev, AGENTS.md, packages/ui/AGENTS.md, the blog
example's docs, and the start-work skill all described a ui-website that
serves pages. It serves redirects now, so they point at the marketing
site instead, and the mirror footgun section is rewritten: the scope
narrowed to the ui/ subdirectories, so a hand-written site component no
longer needs the app/_components workaround.

Leaves packages/ui/README.md's project-detection claim alone on purpose.
It is wrong (it advertises Lit detection that does not exist), but the
fix belongs with removing the behaviour, tracked as #1129.
The JSDoc described a Tier-2 component with a literal class-declaration
snippet, and the dev server's orphan-component scan reads source text
without skipping comments, so every boot warned that a file declaring no
components had an unregistered one. Rewritten as prose.
Review found seven real problems.

A `git add -A` swept in a self-referential node_modules symlink and
three screenshots, all pre-existing working-tree junk that would
materialize as an ELOOP path in any fresh clone.

The lockfile still listed the two dependencies the redirect host no
longer needs, so `npm ci` would have failed in CI.

The redirect sent asset URLs under /ui, where they 404. The old root
layout published /public/og.png as its og:image and the favicons beside
it, so every social card already scraped from that host pointed there;
assets now keep their path, which resolves to the equivalent file on the
new host, with a test per URL.

dev-all never passed SITE_URL to the ui host, so `npm run dev:all` made
localhost:5003 redirect to production instead of the local site.

The Tailwind regenerate step scanned a stale mirror: the generated
sources are a scanned @source, so it now refreshes the mirror first or
the rebuild silently omits every kit utility class.

website/AGENTS.md documented none of app/ui, lib/docs-shell, modules/ui,
or the generated mirror, and still told deployers to set UI_URL, which
no longer exists.
The node_modules rule had a trailing slash, so it matched directories
only and a symlink of that name sailed past it into a commit. A
self-referential one resolves to an ELOOP path that npm and Node's
resolver both stat on every fresh clone.

Root-level images are ignored the same way, since screenshots taken while
working are exactly what a broad `git add -A` picks up. Scoped to the
root so the real assets under */public/ stay tracked.

@vivek7405 vivek7405 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Went through this fresh. The migration itself holds up: the registry URL shapes survive, the palette scoping is genuinely leak-free, and dropping /docs/ui does not break the doc-page enumeration. What it did not hold up on is hygiene and the edges of the redirect.

Two things would have broken CI or a clone outright. A git add -A swept in a self-referential node_modules symlink plus three loose screenshots, and the lockfile was never resynced after the redirect host shed two dependencies, so npm ci would have failed on the desync.

The one I care most about is the asset redirect. The old root layout published /public/og.png as its og:image, so every social card already scraped from that host points there, and the fall-through rule was sending it to /ui/public/og.png, which does not exist. Same for the favicons. Assets have to keep their path, not gain the /ui prefix the pages do.

Then two that only bite locally but bite quietly: dev:all never handed SITE_URL to the redirect host, so hitting localhost:5003 sent you to production, and the Tailwind regenerate step listed the registry sources as an input while its command could not refresh the mirror those sources feed.

Docs: website/AGENTS.md is the file the rewritten packages/ui/AGENTS.md now points a gallery editor at, and it documented none of the new layout while still telling deployers to set a UI_URL that no longer exists.

Comment thread packages/ui/packages/website/middleware.ts
Comment thread scripts/dev-all.js
Comment thread website/package.json
Comment thread .gitignore
@vivek7405

Copy link
Copy Markdown
Collaborator Author

Also fixed from that round, without an inline anchor

Two findings sat on lines the diff does not carry, so they get recorded here.

The lockfile was resynced with npm install --package-lock-only: the redirect host's entry dropped the two dependencies it no longer declares, which is a four-line diff. Without it npm ci fails on the desync and every CI job dies before running a test.

website/AGENTS.md now documents app/docs/, app/ui/, lib/docs-shell.ts, modules/ui/**, scripts/copy-registry.mjs, and the two gitignored mirror directories, with the never-hand-write warning on the mirrors. The UI_URL deployment instruction is gone (the variable no longer exists), and the announcement-banner note points at UI_PATH. website/.env.example dropped its UI_URL line for the same reason.

CI was red and my earlier local runs were not evidence: the mirror in
website/components/ui is gitignored and only webjs.dev.before,
webjs.start.before, and the Dockerfile bake create it, so /ui/button
500'd in every job that boots the app. It passed locally only because a
dev server had already produced it. A pretest hook in both the root and
the website package now generates it, and both suites pass from a tree
with the mirror deleted.

The registry endpoints had no test at their new location: the redirect
test asserted Location strings, so a correct redirect into a broken
endpoint would have shipped green. Now the manifest, the flat index, an
item's inlined source, the reserved index/registry slugs, CORS, and the
static-over-dynamic precedence are all asserted.

Also: the mirror writes only changed files, so a stylesheet regeneration
no longer restarts the dev server through its own watch path; the
registry lib dir joins the regenerate inputs (it owns cn and the layout
helpers every example imports); and the dark variant is pinned, since it
is inert outside previews and nothing else would catch its removal.

@vivek7405 vivek7405 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Zoomed in on runtime correctness this round rather than hygiene, and the important finding is that the previous round's green test numbers were not real.

CI is red on the pushed head, and the reason is structural: website/components/ui/ is gitignored and only webjs.dev.before, webjs.start.before, and the Dockerfile bake ever create it. webjs test runs none of those, so /ui/button 500s in every job that boots the app. It passed locally only because a dev server had already produced the mirror, which means the test plan was measured against a warm tree and reported as if it were a clean one. That is the failure mode worth naming: the numbers were not wrong so much as not evidence.

Second, the registry API has no test where it now lives. The redirect test asserts Location strings, and the composer test covers the function, so a correct redirect into a broken route would have shipped green on the one surface this PR calls a released contract.

Beyond those, two dev-loop papercuts and a doc line, all small. The mirror rewrote all 32 files unconditionally while the dev supervisor watches that directory, so every stylesheet regeneration also restarted the server. The regenerate inputs list the registry's components but not its lib, which owns cn and the layout helpers every example imports. And README.md still documented UI_URL as an overridable cross-link.

I checked the things most likely to hide a real bug and they are clean: startCase in generateMetadata is a hoisted function declaration, not a TDZ trap; unsafeHTML only ever receives module-authored strings, with every registry-derived value going through an escaped hole; the .json strip is safe against a doubled suffix and against traversal, because lookup only matches manifest names; and the extracted shell is byte-equivalent for /docs.

Comment thread website/package.json
Comment thread website/test/ssr/ui-gallery.test.ts
Comment thread website/scripts/copy-registry.mjs
@vivek7405

Copy link
Copy Markdown
Collaborator Author

Also from that round: two smaller fixes and one finding I am not taking

The regenerate inputs now include the registry's lib directory alongside components. It owns cn plus the layout helpers (fieldClass, hintClass, stackClass) that every example imports, so editing it previously left both the mirror and the stylesheet stale in dev with no signal.

README.md no longer documents UI_URL as an overridable cross-link. The paragraph now says what is actually true: the landing site cross-links to the demo, the two redirect hosts read SITE_URL, and the docs and the gallery are plain paths on the landing site itself.

The dark variant got a test. The review confirmed it compiles correctly and resolves right in all four OS-preference and data-theme combinations, so this was a coverage gap rather than a defect, but it is worth pinning precisely because it is inert outside previews: delete it and every marketing page looks identical while component previews silently stop tracking the theme.

Not taking: the one-function-per-file convention for the registry composer.

It exports three reads (item, index, manifest) from modules/ui/queries/registry.server.ts, and the convention for a feature's queries directory says one function per file. I am leaving it, with the reasoning written into the file header rather than left implicit.

They are three views of ONE parse and they share the module-level caches. Splitting them would either duplicate the cache per view, which loses the same-reference guarantee packages/ui/test/registry-composer.test.js pins, or require a fourth internal module holding the state the three then import. Both are worse than one cohesive composer, and the convention exists to keep queries granular and individually importable, which is already true here. webjs check agrees: its one-function rule targets a configured action file, which this is not. AGENTS.md is explicit that conventions are followed by judgment and webjs check is the enforced half, so this is a deliberate, documented deviation rather than an oversight.

Two findings from review, both measured rather than reasoned about.

The shared shell's inline onclick attributes made lib/docs-shell.ts
client-effecting, which shipped BOTH layout modules to the browser
against the execution model's own self-check. The handlers move into the
root layout's existing delegated listener, which already survives
navigation, so both layouts now elide. That also fixes a stale
aria-expanded: dismissing the drawer via the backdrop needs no
navigation and used to leave the toggle advertising an expanded drawer,
and Escape now closes it and restores focus.

SSR action seeding was serializing every registry read into the page for
a consumer that cannot exist: no component here does an async render or
calls an action, and a page function never re-runs in the browser. It was
a quarter of some responses. /ui/dropdown-menu drops 141KB to 107KB and
the pre-existing /changelog 1.10MB to 800KB.

Also unpinned the snippet map and API metadata from the browser bundle
(a module-scope IIFE and one comment mentioning a DOM global were enough
to mark both client-effecting) and trimmed the page to only the Tier-2
side-effect imports, since a Tier-1 helper registers no element.
The kit's own docs list scope on header cells and a caption as the
reader's Tier-1 obligation, and the gallery shipped neither: 2 bare th
on /ui and 6 on every component page. Worse, the paste-ready table
snippet in the Code tab taught the omission, contradicting the
component's JSDoc that spells out scope=col.

preview-tabs declared role=tablist with two role=tab buttons and no
aria-controls, no tabpanel, and no keyboard handling, so it announced as
tabs with no associated panel on 33 indexed pages. It now implements
what the roles promise: cross-linked panels, a roving tabindex so the
group is one tab stop, and Arrow / Home / End. Covered by browser tests,
since all of it is focus behaviour that only exists after upgrade;
neutering aria-controls and the roving tabindex reds two of them.
… in llms.txt

All 33 gallery URLs shared one meta description inherited from the
layout, which is the duplicate-content shape this migration exists to
remove; introducing 33 of them in a search-consolidation change would
have been self-defeating. Each page now derives its own from the tier
and the naming convention, so it says something true and distinct and
stays correct if a component changes tier. The registry carries no
per-item description, so a hand-written map would just be 33 more
strings to drift.

llms.txt enumerated the 45 doc pages but not a single component, while
the sitemap advertised all 33. An agent entering through llms.txt could
not discover the kit it is meant to use.

Also repaired three stale pointers the move left behind: the favicon
generator wrote into a deleted directory and would have thrown, and the
registry README plus two source comments still credited the emptied app
as the composer.

@vivek7405 vivek7405 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Zoomed out to the surfaces nobody had looked at, and this round was the most productive of the three.

The one I would not have found by reading the diff: both layout modules and the component page were shipping WHOLE to the browser, against the execution model's own self-check. webjs doctor says so plainly and nobody had run it. The causes were almost comically small, which is what makes them worth writing down: a module-scope IIFE building the sonner demos, and one comment containing the word document, were each enough to mark a pure data module client-effecting and drag its importer along. The shell's inline onclick attributes did the same to both layouts.

That led to the second finding, measured rather than reasoned about: every page was carrying an SSR action-seed payload no component on this site can consume. It was a quarter of some responses. This is mostly PRE-EXISTING and much bigger than the gallery: /changelog was shipping 304KB of dead seed in a 1.1MB page, and /blog 19KB. Turning seeding off for the app fixes all of it.

On accessibility, the gallery was violating the rules it documents. preview-tabs declared role="tablist" with two role="tab" buttons and no aria-controls, no tabpanel, and no keyboard handling, so it announced as tabs with no associated panel on 33 indexed pages. And every table shipped bare <th> while the page beside it told the reader that scope is their responsibility, with the paste-ready snippet in the Code tab teaching the omission.

On search, the change was quietly working against its own purpose: 33 new URLs all sharing one meta description, and an llms.txt that enumerated 45 doc pages but not one component, while the sitemap advertised all 33.

Also: the favicon generator still wrote into a directory this PR deletes, so it would have thrown after writing two of its three outputs.

Comment thread website/modules/ui/utils/examples.ts
Comment thread website/lib/docs-shell.ts
Comment thread website/components/preview-tabs.ts
Comment thread website/app/ui/layout.ts
@vivek7405

Copy link
Copy Markdown
Collaborator Author

The seed payload: a pre-existing problem much bigger than this PR

This came out of the round-3 review and is worth its own note, because most of it predates the gallery and the numbers are large.

Every 'use server' result invoked during SSR is serialized into the page so a shipping async component can skip its refetch on hydration (#472). Nothing on this site can consume that. No component here does an async render or calls an action, and a page function never re-runs in the browser, so the payload was pure page weight, duplicating content already present in the visible HTML.

Measured before and after turning it off:

page before after saved
/changelog 1.10 MB 800 KB 304 KB
/ui/dropdown-menu 141 KB 107 KB 34 KB
/blog 116 KB 97 KB 19 KB
/ui 61 KB 55 KB 6 KB
/docs/routing 77 KB 77 KB none, it had no seed

/changelog and /blog have been shipping that since long before this branch. I fixed it here rather than filing it because the fix is the same one line that fixes my own 33 new pages, and leaving the gallery bloated while filing an issue about the rest would have been the wrong trade. webjs.seed is off for the app, documented in website/AGENTS.md, and pinned by a test so it cannot come back without a consumer.

On the elision findings, one thing worth internalising

The causes were tiny and invisible: a module-scope IIFE, and a single comment containing the word for a DOM global. Each was enough to mark a pure data module client-effecting and drag its importing page into the browser bundle. That is the second time in this PR a comment has tripped a source-text scan (the first was the component scanner reporting an unregistered component in a file that declares none).

The analyser weakness is pre-existing and still fires for the docs pages that legitimately contain code samples. I have not filed it: the false positives are warnings rather than errors, and a scanner that parsed instead of grepped is a real piece of work in packages/server. Worth doing if it bites a third time.

Browser CI

The Browser job failed on this branch twice, on a Firefox navigation interrupt in plain-link-target, a core client-router test this PR does not touch. It has failed the same way on main. I re-ran the job on the same commit and it passed, so it is flake, not a regression here.

…derline

The derived helper name in each Tier-1 description was guessed from the
component name, and the convention does not hold for all of them:
breadcrumb exports breadcrumbListClass, popover exports
popoverContentClass, and switch exports switchInputClass, none of which
have a bare xClass. Three search snippets named a function that does not
exist. It now reads the exported name out of the component's own source,
so all 32 are true and stay true when the kit renames one.

The accordion trigger no longer underlines on hover. An underline reads
as a link affordance and this is a disclosure control on a summary
element; the pointer cursor and the rotating chevron already carry it.
Presentation only, so the shadcn API parity that matters is untouched.
Clicking the drawer toggle returned early before the header mobile
menu's outside-click close, so the header dropdown stayed open. The
header is a stacking context, so its menu was then trapped beneath the
drawer's backdrop: visible, dimmed, and unclickable. The menu
housekeeping now runs first, unconditionally.

Escape with both open closed both and left focus on the header summary
rather than the control that opened the drawer, contradicting the
comment right above it. The drawer branch returns now.

Verified in a real browser across all ten states, then pinned: six
browser tests for the interactions, plus a source assertion that the
transcribed listener still matches the real inline script, including the
ordering that actually broke.
… menu

The comment claimed the header menu was trapped under the drawer's
backdrop because the header is a stacking context. Measured on the live
page, that is backwards: the backdrop sits inside the root layout's z-1
context, so it composites below the z-20 header, and a hit test at a
dropdown link with the drawer open returns the link. The menu was never
unclickable, and docs-shell.ts already documents the real z-order, so
the repo carried two comments asserting opposite stacking.

The behaviour is right and unchanged; only the justification was wrong,
which is worse than no comment because the next reader reasons from it.
@vivek7405

Copy link
Copy Markdown
Collaborator Author

Review loop: seven rounds, last one clean

Recording where this landed, since the loop ran long and found more than I expected.

Rounds 1 and 2 were hygiene and process: a self-referential node_modules symlink and three screenshots swept in by a broad git add -A, a lockfile left desynced after the redirect host shed two dependencies, and the asset redirect sending every already-scraped social card to a 404. Round 2 also caught that CI was red while I was reporting green numbers, because the gitignored component mirror only exists after a dev or start phase and webjs test runs neither. That one is worth naming plainly: the numbers were not wrong, they were measured on a warm tree and reported as if they were evidence. Every figure in the test plan is now taken from a cold tree, and pretest hooks mean the gap cannot reopen.

Round 3 was the substantive one and found four things I would not have caught by reading the diff. Both layouts and the component page were shipping whole to the browser against the execution model's own self-check, and webjs doctor had been saying so the whole time. The causes were a module-scope IIFE and a single comment containing the word for a DOM global, each enough to mark a pure data module client-effecting. Fixing the shell's inline handlers also elided the /docs layout, which was shipping before this PR. It also surfaced the dead action-seed payload, which turned out to be mostly pre-existing and much larger than the gallery (304KB on /changelog), and the fact that the gallery was violating the accessibility rules it documents, and that 33 new URLs shared one meta description in a change whose whole purpose is search consolidation.

Rounds 4 through 6 checked the fixes rather than the original diff, which is where the last real bugs were. Two were in the delegated listener I had just written: clicking the drawer toggle returned early before the header menu's outside-click close, and Escape with both open closed both and left focus on the wrong control. Round 6 then caught that my comment explaining the first fix asserted a false z-index mechanism, contradicting the correct explanation already sitting in docs-shell.ts. The behaviour was right and the reasoning was wrong, which is worse than no comment, because the next reader builds a mental model from it.

Round 7 came back clean.

Every finding is accounted for. All fixed on the branch except one, which is filed: scoping @webjsdev/ui to WebJs projects and deleting the project-type detection is #1129, kept out of this PR because it changes a published package's behaviour and belongs in its own release. Nothing was rejected as a false positive.

Two things I deliberately did not do, both argued rather than skipped. The composer keeps its three exports instead of splitting one-per-file, because they are three views of one parse sharing a cache and splitting them would lose the same-reference guarantee its tests pin; the reasoning is in the file header. And the $schema URLs stay byte-identical, because they 404 today and repointing a dead link at a new domain would rewrite the $schema line in every user's components.json without making anything resolve.

@vivek7405
vivek7405 marked this pull request as ready for review July 27, 2026 13:41
Replaces the affordance removed with the underline. A slight drop in
text weight reads as interactive without borrowing a link's vocabulary,
and it pairs with the chevron that rotates on open.

The label now carries text-foreground explicitly rather than inheriting
it, because the hover target needs a known starting colour: dimming an
author's inherited colour to 80% of the THEME foreground would shift the
hue rather than just the weight. Slash opacity is already idiomatic in
the kit (text-foreground/60, ring-ring/50), and Tailwind emits a solid
fallback ahead of the color-mix form.

Verified hovered in a real browser in both themes.
#1101 widened the documentation from the 1200 container and 800 content
column it used on its own domain to 1240/820, and this branch inherited
that and then applied it to the gallery as well. Back to 1200/800 for
both, from the one shared shell.

A reference page is read line by line and 800px is the comfortable
ceiling for that measure; the extra 20px bought nothing. Note the shell
is now 40px narrower than the header's own container, so the sidebar
sits 20px inside the wordmark rather than flush with it. That is the
deliberate trade, recorded in the shell, and the header can follow if
the offset ever reads as an accident.
… 800

Splits the two decisions #1101 had moved together. The outer container
goes back to the header's 1240 so the sidebar sits flush under the
wordmark, while the content column stays capped at 800, the comfortable
ceiling for a reference page read line by line.

The slack lands as space between the sidebar and the text rather than as
a longer line. Verified at 1600px: sidebar and wordmark both start at
204, content column 800, no horizontal scroll.
Reverts the two width commits from this session, putting the container
back to 1240 and the content column to 820, byte-identical to what main
serves today.

Those changes were chasing a widening that happened in #1101, not here,
and the second of them (820 to 800) was a 20px difference nobody could
see. The shell is back to inheriting whatever the documentation uses, so
/docs is untouched by this PR and /ui matches it, which was the original
request.
The height cap and overflow sat on a wrapper div while the border,
background, and rounded corners come from the pre inside it, so the
scrollbar rendered outside the visible rectangle, detached from the card
it was scrolling. A scrollbar renders inside its own element's border
box, so both now live on the pre.
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.

seo: serve the UI gallery at webjs.dev/ui with the marketing site chrome

1 participant