diff --git a/.claude/skills/webjs-start-work/SKILL.md b/.claude/skills/webjs-start-work/SKILL.md index 47946c994..0fb19120d 100644 --- a/.claude/skills/webjs-start-work/SKILL.md +++ b/.claude/skills/webjs-start-work/SKILL.md @@ -158,20 +158,20 @@ Doc drift is the #1 way a framework rots. Documentation MUST stay in sync with c - **Publishing on a release.** The VS Code extension publishes to the VS Marketplace + Open VSX (`packages/editors/vscode/PUBLISHING.md`); webjs.nvim is a git subtree split mirrored to `webjsdev/webjs.nvim` (re-run the split + force-push after a change; `packages/editors/nvim/PUBLISHING.md`). Bump `packages/editors/vscode/package.json` `version` when its bundle changes. - **Heuristic:** if your change would make an editor highlight wrong, resolve the wrong definition, offer a stale snippet/command, or ship a drifted bundle, the editor plugins are part of your change. Update them on the same PR (with the matching test), re-vendor the nvim copy, or write "N/A because " in the PR body. 7. **Marketing copy** at `website/app/page.ts`. Update if the change touches positioning or any landing-page claim ("no-build", "AI-first", "web components first", etc.). -8. **Dogfood apps must still build and boot. MANDATORY GATE, run it automatically, never wait to be asked.** The framework ships four in-repo apps that consume it: `examples/blog` (the demo), `website`, `docs`, and `packages/ui/packages/website`. A framework change that compiles is NOT done until all four still serve. This is a recurring miss: running only the blog e2e and stopping is the exact failure this gate exists to prevent. For ANY change to `packages/core`, `packages/server`, `packages/cli`, the dist build, the importmap, or anything that alters what the browser fetches, you MUST run the full four-app check below before marking the draft PR ready for review and report its result in the PR body. The user should never have to ask "did you check the apps?". +8. **Dogfood apps must still build and boot. MANDATORY GATE, run it automatically, never wait to be asked.** The framework ships four in-repo apps that consume it: `examples/blog` (the demo), `website` (the marketing pages plus /docs and /ui), and the redirect-only `docs` and `packages/ui/packages/website` hosts. A framework change that compiles is NOT done until they all still serve. This is a recurring miss: running only the blog e2e and stopping is the exact failure this gate exists to prevent. For ANY change to `packages/core`, `packages/server`, `packages/cli`, the dist build, the importmap, or anything that alters what the browser fetches, you MUST run the full four-app check below before marking the draft PR ready for review and report its result in the PR body. The user should never have to ask "did you check the apps?". **The check (copy-paste, runs in seconds):** - `examples/blog`: covered by the e2e suite. Run `WEBJS_E2E=1 node --test test/e2e/e2e.test.mjs` (it exercises the blog in a real browser; if `dist/` is built it runs in dist mode, so it covers the production wire too). - - `website` / `docs` / `packages/ui/packages/website`: boot each through `createRequestHandler` in PROD mode and GET a real route, asserting status < 400. Write this harness to a file INSIDE the repo (bare `@webjsdev/*` specifiers only resolve from the repo's `node_modules`, NOT from `/tmp`), run it, delete it: + - `website`: boot it through `createRequestHandler` in PROD mode and GET a real route, asserting status < 400. It is the app that serves every HTML surface the project has, the marketing pages plus the documentation at `/docs` (#1098) and the component gallery at `/ui` (#1099), so its routes are where a break shows up. `docs/` and `packages/ui/packages/website/` are redirect-only hosts whose every route is an empty 301: they pass a status check vacuously, so probe their MAPPINGS via `test/docs/docs-host-redirect.test.mjs` and `test/ui/ui-host-redirect.test.mjs` instead of booting them here. Write this harness to a file INSIDE the repo (bare `@webjsdev/*` specifiers only resolve from the repo's `node_modules`, NOT from `/tmp`), run it, delete it: ```js // ./.boot-check.mjs (write at repo root, run `node ./.boot-check.mjs`, then rm) import { createRequestHandler } from '@webjsdev/server'; import { resolve } from 'node:path'; const apps = [ - { name: 'website', dir: 'website', routes: ['/'] }, - { name: 'docs', dir: 'docs', routes: ['/', '/docs/'] }, - { name: 'ui-website', dir: 'packages/ui/packages/website', routes: ['/'] }, + // /ui/button is the heaviest page: it pulls the mirrored kit component + // sources, the exact import class a broken preload shows up in. + { name: 'website', dir: 'website', routes: ['/', '/docs/', '/ui', '/ui/button'] }, ]; let fail = false; for (const app of apps) { @@ -200,7 +200,7 @@ Doc drift is the #1 way a framework rots. Documentation MUST stay in sync with c Add `GET` routes for any page you edited (a 307/308 redirect is a pass; it has no body to inspect). If a change is browser-wire-affecting (dist build, importmap, core exports), also assert the served ` - - - - -
- - -
- - - Webjs UI - - - - - - -
-
- - - - - -
- -
-
- -
${children}
- - -
- - `; -} - -// Touch to force a Railway redeploy of this app for the workspace router fixes in #151 and #157 (the watch path skips framework-only changes in packages/core). diff --git a/packages/ui/packages/website/app/not-found.ts b/packages/ui/packages/website/app/not-found.ts deleted file mode 100644 index 9998c1333..000000000 --- a/packages/ui/packages/website/app/not-found.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { html } from '@webjsdev/core'; - -/** - * Root 404 boundary. Renders when a page or server action throws - * notFound() AND when no route matches the requested URL. - * - * Keep this file's import graph minimal: same reason as error.ts: - * if this file fails to load, the framework falls through to its - * generic 404 page. Only @webjsdev/core, nothing else. - * - * (Do not put U+0060 GRAVE ACCENT characters in comments inside the - * html template body below. See [[feedback-html-template-no-backticks]].) - */ -export default function NotFound() { - return html` -
-
404 · not found
-

Page not found.

-

The page you were looking for does not exist.

- ← Back home -
- `; -} diff --git a/packages/ui/packages/website/app/page.ts b/packages/ui/packages/website/app/page.ts index db837a217..fa704143b 100644 --- a/packages/ui/packages/website/app/page.ts +++ b/packages/ui/packages/website/app/page.ts @@ -1,250 +1,11 @@ -import { html } from '@webjsdev/core'; -import { loadRegistryIndex } from './_lib/registry.server.ts'; -import { splitByTier } from './_lib/tier.ts'; - -export default async function Home() { - const items = await loadRegistryIndex(); - const ui = items.filter((i) => i.type === 'registry:ui'); - const { tier1, tier2 } = splitByTier(ui); - - return html` - -
-
- - AI-first component library -
-

- A component library
written for AI agents. -

-

- ${ui.length} primitives designed for the agent era: copy‑paste source code, - full native HTML semantics, shadcn API parity, zero third‑party dependencies. - Built for WebJs, styled with Tailwind v4. -

- -
- - -
-
-
-
01 · Composition
-

Class helpers, not wrappers

-

- Tier‑1 components are pure functions returning Tailwind class strings. AI - agents compose with raw native HTML they already know, with no DSL, no JSX - translation, and no projection complexity. -

-
-
-
02 · Native semantics
-

Real elements, real forms

-

- A button is a real <button>. - A checkbox is a real <input>. - Form submission, autofill, browser validation, screen readers: all work - natively, never proxied. -

-
-
-
03 · Zero dependencies
-

Auditable in an afternoon

-

- No Radix, no clsx, no tailwind‑merge, no Floating UI, no Sonner. - Hand‑rolled cn(), - positioning, focus trap, toast queue. Every line is yours to read and edit. -

-
-
-
- - -
-

Install

-
-
In any webjs app
-
# shipped with @webjsdev/cli, already in every webjs app
-webjs ui init
-webjs ui add button card dialog
-
-
- - -
-

How agents write it

-

- Idiomatic HTML + a named class helper. No state to thread, no wrappers to decode. -

-
-
-
A form field
-
<form class=\${stackClass({ gap: 'sm' })}>
-  <div class=\${fieldClass()}>
-    <label class=\${labelClass()} for="email">Email</label>
-    <input class=\${inputClass()} id="email" type="email" required />
-    <p class=\${hintClass()}>We'll send a link.</p>
-  </div>
-  <button class=\${buttonClass({ size: 'sm' })} type="submit">Subscribe</button>
-</form>
-
-
-
A dialog
-
<ui-dialog>
-  <ui-dialog-trigger>
-    <button class=\${buttonClass({ variant: 'outline' })}>Edit</button>
-  </ui-dialog-trigger>
-  <ui-dialog-content>
-    <h2 class=\${dialogTitleClass()}>Delete project?</h2>
-    <div class=\${dialogFooterClass()}>
-      <ui-dialog-close><button class=\${buttonClass({ variant: 'ghost' })}>Cancel</button></ui-dialog-close>
-      <button class=\${buttonClass({ variant: 'destructive' })}>Delete</button>
-    </div>
-  </ui-dialog-content>
-</ui-dialog>
-
-
-
- - -
-

Two tiers, one mental model

-

- Visual primitives are composable. Stateful primitives are custom elements. -

-
-
-
Tier 1
-

Class‑helper functions

-

- buttonClass, - cardClass, - inputClass… - Apply to any native element. Same variants and sizes as shadcn. -

-
- 23 components · button, card, badge, alert, input, textarea, label, - checkbox, switch, radio, native‑select, avatar, separator, skeleton, - aspect‑ratio, kbd, table, toggle, breadcrumb, pagination, popover, - accordion, collapsible -
-
-
-
Tier 2
-

Stateful custom elements

-

- <ui-dialog>, - <ui-tabs>, - <ui-dropdown-menu>… - Manage what the platform doesn't: keyboard nav for menus + tabs, - hover‑with‑delay for tooltips, toast queue. -

-
- 9 components · dialog, alert‑dialog, tooltip, hover‑card, - tabs, dropdown‑menu, sonner, progress, toggle‑group -
-
-
-
- - -
-
-

All components

- ${ui.length} primitives -
-

- Grouped by composition tier. Pick Tier 1 by default. Reach for Tier 2 - only when the browser doesn't ship the behavior natively. -

- - -
-
- Tier 1 -

Class‑helper functions

-
- ${tier1.length} components -
-

- Apply *Class() to a real <button> / <input> / <div>. Native semantics, native a11y, native form submission. -

- - - -
-
- Tier 2 -

Stateful custom elements

-
- ${tier2.length} components -
-

- <ui-X> tags that manage open/close, keyboard nav, focus trap, escape, click‑outside. Import once in your layout. -

- -
- `; +import { redirect } from '@webjsdev/core'; + +/** + * Unreachable in practice: middleware.ts catches every request to this host + * and 301s it to webjs.dev/ui. This page exists so the app still has a route + * (and so a bare visit is handled if the middleware is ever bypassed), not + * because anything is expected to render it. + */ +export default function UiHostRoot() { + redirect('https://webjs.dev/ui', 301); } diff --git a/packages/ui/packages/website/app/registry/[name]/route.ts b/packages/ui/packages/website/app/registry/[name]/route.ts deleted file mode 100644 index fbac3ced7..000000000 --- a/packages/ui/packages/website/app/registry/[name]/route.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { - loadRegistryItem, - loadRegistryIndex, - loadRegistryManifest, -} from '#app/_lib/registry.server.ts'; - -const HEADERS = { - 'Content-Type': 'application/json', - 'Cache-Control': 'public, max-age=60', - 'Access-Control-Allow-Origin': '*', -}; - -/** - * GET /registry/.json: returns the registry item for ``. - * - * One reserved slug: - * - `index` → flat list (same as `GET /registry/index.json` via the sibling route) - * - * Everything else looks up the item in `registry.json` and composes its - * shadcn-compatible JSON on demand. See _lib/registry.server.ts. - */ -export async function GET(_req: Request, { params }: { params: { name: string } }) { - const slug = params.name.replace(/\.json$/, ''); - - if (slug === 'index') { - return new Response(JSON.stringify(await loadRegistryIndex(), null, 2), { headers: HEADERS }); - } - if (slug === 'registry') { - return new Response(await loadRegistryManifest(), { headers: HEADERS }); - } - - const item = await loadRegistryItem(slug); - if (!item) { - return Response.json({ error: `Registry item "${slug}" not found` }, { status: 404 }); - } - return new Response(JSON.stringify(item, null, 2), { headers: HEADERS }); -} diff --git a/packages/ui/packages/website/app/registry/index.json/route.ts b/packages/ui/packages/website/app/registry/index.json/route.ts deleted file mode 100644 index adcd57672..000000000 --- a/packages/ui/packages/website/app/registry/index.json/route.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { loadRegistryIndex } from '#app/_lib/registry.server.ts'; - -/** GET /registry/index.json: flat list of registry items (metadata only, used by `webjsui list`). */ -export async function GET() { - const items = await loadRegistryIndex(); - return new Response(JSON.stringify(items, null, 2), { - headers: { - 'Content-Type': 'application/json', - 'Cache-Control': 'public, max-age=60', - 'Access-Control-Allow-Origin': '*', - }, - }); -} diff --git a/packages/ui/packages/website/app/registry/route.ts b/packages/ui/packages/website/app/registry/route.ts deleted file mode 100644 index e4c2543eb..000000000 --- a/packages/ui/packages/website/app/registry/route.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { loadRegistryManifest } from '#app/_lib/registry.server.ts'; - -/** GET /registry: full registry manifest with content inlined per item. */ -export async function GET() { - const body = await loadRegistryManifest(); - return new Response(body, { - headers: { - 'Content-Type': 'application/json', - 'Cache-Control': 'public, max-age=60', - 'Access-Control-Allow-Origin': '*', - }, - }); -} diff --git a/packages/ui/packages/website/middleware.ts b/packages/ui/packages/website/middleware.ts new file mode 100644 index 000000000..686721347 --- /dev/null +++ b/packages/ui/packages/website/middleware.ts @@ -0,0 +1,95 @@ +/** + * ui.webjs.dev is now a redirect-only host. + * + * The component library moved onto the main domain at webjs.dev/ui. A + * subdomain accrues its own authority in search rather than contributing to + * webjs.dev, and it carried a second layout that had drifted from the + * marketing site's. Both problems go away by serving the gallery as a path. + * + * This host MUST keep resolving indefinitely, and it carries a harder + * constraint than the docs host does: `/registry/*` is a LIVE API. Every + * already-published @webjsdev/ui and @webjsdev/cli fetches components from + * `https://ui.webjs.dev/registry/.json` when a user runs + * `webjs ui add`, and a published version can never be corrected after the + * fact. If this host stops answering, `webjs ui add` breaks for everyone on + * an older install, permanently. + * + * A 301 is safe there, which was verified before the move rather than assumed: + * the real published 0.3.1 and 0.3.8 tarballs were pointed at a host that + * 301s cross-origin, and both followed it and parsed the result (fetch follows + * redirects by default). Since 0.3.9 the kit resolves LOCAL-first (#983), so + * `add` / `list` / `view` do not even reach the network on the default + * registry; only `webjsui diff` and an explicit custom --registry do. + * + * The mapping is path-aware, not a blind prefix, because the old URL shapes + * are not the new ones (see PATHS below). + */ +const TARGET = (process.env.SITE_URL || 'https://webjs.dev').replace(/\/$/, ''); + +/** + * Old path to new path. Order matters: the first match wins, so the specific + * component-page rule is tested before the generic /docs one. + * + * The old site had two human-facing shapes, a landing page at `/` and docs at + * `/docs`, and the new site has neither: /ui IS the gallery, opening straight + * on the introduction. So both collapse onto /ui. + */ +function mapPath(pathname: string): string { + // The registry API. Preserved shape-for-shape, including the reserved + // `index` / `registry` slugs the CLI relies on. + if (pathname === '/registry' || pathname.startsWith('/registry/')) { + return '/ui' + pathname; + } + + // Assets keep their path INSTEAD of moving under /ui, because the marketing + // site serves the same filenames at the same place. The deleted root layout + // published /public/og.png as its og:image and /public/favicon-192.png, + // /public/favicon.svg, and /public/apple-touch-icon.png as its icons, so + // every social card already scraped from this host, and every embed of that + // image, points at those URLs. Sending them to /ui/public/... would resolve + // them to nothing; sending them to /public/... resolves them to the + // equivalent asset on the new host. + if (pathname.startsWith('/public/') || pathname === '/favicon.ico') return pathname; + // A component page: /docs/components/button becomes /ui/button. + const component = pathname.match(/^\/docs\/components\/([^/]+)\/?$/); + if (component) return '/ui/' + component[1]; + + // The old docs root and the old landing page both mean "the gallery". + if (pathname === '/' || pathname === '/docs' || pathname === '/docs/') return '/ui'; + + // Anything else under /docs keeps its tail under /ui. + if (pathname.startsWith('/docs/')) return '/ui/' + pathname.slice('/docs/'.length); + + // Everything else (including the schema paths, which have always 404'd) + // moves under /ui unchanged, so a stray link lands somewhere coherent + // rather than on the marketing home page. + return '/ui' + pathname; +} + +export default async function redirectToSite( + req: Request, + next: () => Promise, +): Promise { + const { pathname, search } = new URL(req.url); + + // The framework's own endpoints stay local, handled by the framework rather + // than answered here. The health and readiness probes are served before app + // middleware runs, so they never reach this line, but the rest of the + // namespace does, and answering it with a redirect would break it rather + // than leave it alone. + if (pathname.startsWith('/__webjs/')) return next(); + + return new Response(null, { + status: 301, + headers: { + location: TARGET + mapPath(pathname) + search, + // Long-lived and public: this host now has exactly one behaviour, so an + // intermediary caching the redirect is correct and saves the hop. + 'cache-control': 'public, max-age=86400', + // The registry is fetched cross-origin by tooling. A redirect that a + // browser-context consumer cannot follow is as good as a failure, so the + // CORS header rides the 301 the same way it rode the 200. + 'access-control-allow-origin': '*', + }, + }); +} diff --git a/packages/ui/packages/website/package.json b/packages/ui/packages/website/package.json index c2b554468..74ccef690 100644 --- a/packages/ui/packages/website/package.json +++ b/packages/ui/packages/website/package.json @@ -6,28 +6,20 @@ "imports": { "#*": "./*" }, - "description": "Registry host + docs site for @webjsdev/ui. Serves /r/.json and renders per-component docs.", + "description": "Redirect-only host for ui.webjs.dev. The component gallery and the registry API are served by the marketing site at webjs.dev/ui.", "scripts": { "dev": "webjs dev --port ${PORT:-5003}", - "start": "webjs start", - "css:build": "tailwindcss -i ./public/input.css -o ./public/tailwind.css --minify", - "preview:build": "node scripts/copy-registry.js" - }, - "webjs": { - "dev": { - "before": ["node scripts/copy-registry.js", "npm run css:build"], - "regenerate": [{ "output": "public/tailwind.css", "command": "tailwindcss -i ./public/input.css -o ./public/tailwind.css --minify", "inputs": ["app", "components", "../../registry/components", "public/input.css"] }] - }, - "start": { "before": ["node scripts/copy-registry.js", "npm run css:build"] } + "start": "webjs start" }, "dependencies": { "@webjsdev/cli": "^0.10.0", "@webjsdev/core": "^0.7.0", - "@webjsdev/server": "^0.8.0", - "@webjsdev/ui-registry": "*" + "@webjsdev/server": "^0.8.0" }, "devDependencies": { - "@tailwindcss/cli": "^4.2.2", "@types/node": "^24.0.0" + }, + "engines": { + "node": ">=24.0.0" } } diff --git a/packages/ui/packages/website/public/apple-touch-icon.png b/packages/ui/packages/website/public/apple-touch-icon.png deleted file mode 100644 index 62c768515..000000000 Binary files a/packages/ui/packages/website/public/apple-touch-icon.png and /dev/null differ diff --git a/packages/ui/packages/website/public/code-highlight.js b/packages/ui/packages/website/public/code-highlight.js deleted file mode 100644 index f313b7990..000000000 --- a/packages/ui/packages/website/public/code-highlight.js +++ /dev/null @@ -1,145 +0,0 @@ -/* - * Progressive-enhancement syntax highlighter for docs code samples. - * - * Code is server-rendered as plain monochrome text inside
 (readable
- * with JavaScript off). On the client this script tokenizes each block and
- * wraps tokens in  so they pick up the theme-aware cool
- * palette declared once in the layout stylesheet. It mirrors the website's
- * lib/highlight.ts tokenizer so the colors match across every webjs surface.
- *
- * A MutationObserver re-runs on client-router navigations (new 
 nodes
- * swapped into the DOM), and a data-hl guard prevents double processing.
- */
-(function () {
-  var KEYWORDS = {
-    import: 1, from: 1, export: 1, default: 1, async: 1, function: 1,
-    return: 1, const: 1, let: 1, var: 1, await: 1, new: 1, class: 1,
-    extends: 1, if: 1, else: 1, for: 1, of: 1, in: 1, true: 1, false: 1,
-    null: 1, undefined: 1, this: 1, typeof: 1, throw: 1, try: 1, catch: 1,
-    void: 1, static: 1, as: 1,
-  };
-  var CLASS = { com: 't-com', str: 't-str', num: 't-num', kw: 't-kw', fn: 't-fn', type: 't-type' };
-  var ident = /[A-Za-z0-9_$]/;
-  var identStart = /[A-Za-z_$@]/;
-  var numChar = /[0-9._a-fxA-FX]/;
-
-  function tokenize(src) {
-    var out = [];
-    var i = 0;
-    var n = src.length;
-    function push(t, v) { if (v) out.push({ t: t, v: v }); }
-    while (i < n) {
-      var c = src[i];
-      if (c === ' ' || c === '\t' || c === '\n') {
-        var j = i + 1;
-        while (j < n && (src[j] === ' ' || src[j] === '\t' || src[j] === '\n')) j++;
-        push('ws', src.slice(i, j)); i = j; continue;
-      }
-      if (c === '/' && src[i + 1] === '/') {
-        var j2 = i + 2;
-        while (j2 < n && src[j2] !== '\n') j2++;
-        push('com', src.slice(i, j2)); i = j2; continue;
-      }
-      if (c === '/' && src[i + 1] === '*') {
-        var j3 = i + 2;
-        while (j3 < n && !(src[j3] === '*' && src[j3 + 1] === '/')) j3++;
-        j3 = Math.min(n, j3 + 2);
-        push('com', src.slice(i, j3)); i = j3; continue;
-      }
-      if (c === "'" || c === '"' || c === '`') {
-        var j4 = i + 1;
-        var closed4 = false;
-        while (j4 < n) {
-          if (src[j4] === '\\') { j4 += 2; continue; }
-          if (src[j4] === '\n' && c !== '`') break; // ' and " do not span lines
-          if (src[j4] === c) { closed4 = true; j4++; break; }
-          j4++;
-        }
-        // A backtick template spans lines; a ' or " that never closes on its
-        // own line is not a string (e.g. an apostrophe in prose), so emit the
-        // quote as punctuation and keep tokenizing the rest of the line.
-        if (c === '`' || closed4) { push('str', src.slice(i, j4)); i = j4; continue; }
-        push('punc', c); i++; continue;
-      }
-      if (c >= '0' && c <= '9') {
-        var j5 = i + 1;
-        while (j5 < n && numChar.test(src[j5])) j5++;
-        push('num', src.slice(i, j5)); i = j5; continue;
-      }
-      if (c === '#' && src[i + 1] === ' ') {
-        // Shell-style line comment: '#' starts the line AND is followed by a
-        // space, so a CSS id selector (#app), a JS private field (#count), or a
-        // hex color (#fff) is not swallowed, only a real "# comment".
-        var bk = i - 1;
-        while (bk >= 0 && (src[bk] === ' ' || src[bk] === '\t')) bk--;
-        if (bk < 0 || src[bk] === '\n') {
-          var jh = i + 1;
-          while (jh < n && src[jh] !== '\n') jh++;
-          push('com', src.slice(i, jh)); i = jh; continue;
-        }
-      }
-      if (identStart.test(c)) {
-        var j6 = i + 1;
-        while (j6 < n && ident.test(src[j6])) j6++;
-        var word = src.slice(i, j6);
-        var k = j6;
-        while (k < n && src[k] === ' ') k++;
-        if (KEYWORDS[word]) push('kw', word);
-        else if (src[k] === '(') push('fn', word);
-        else if (/^[A-Z]/.test(word)) push('type', word);
-        else push('id', word);
-        i = j6; continue;
-      }
-      push('punc', c); i++;
-    }
-    return out;
-  }
-
-  function highlight(pre) {
-    if (pre.dataset.hl) return;
-    var code = pre.querySelector('code') || pre;
-    // Only highlight plain-text blocks. If the code already contains element
-    // markup (server-rendered spans, links, emphasis), leave it untouched
-    // rather than flatten it.
-    if (code.children.length) return;
-    pre.dataset.hl = '1';
-    var toks = tokenize(code.textContent.replace(/^\n+|\n+$/g, ''));
-    var frag = document.createDocumentFragment();
-    for (var i = 0; i < toks.length; i++) {
-      var cls = CLASS[toks[i].t];
-      if (cls) {
-        var s = document.createElement('span');
-        s.className = cls;
-        s.textContent = toks[i].v;
-        frag.appendChild(s);
-      } else {
-        frag.appendChild(document.createTextNode(toks[i].v));
-      }
-    }
-    code.textContent = '';
-    code.appendChild(frag);
-  }
-
-  function run(root) {
-    var list = (root || document).querySelectorAll('pre');
-    for (var i = 0; i < list.length; i++) highlight(list[i]);
-  }
-
-  function init() {
-    run(document);
-    new MutationObserver(function (muts) {
-      for (var a = 0; a < muts.length; a++) {
-        var added = muts[a].addedNodes;
-        for (var b = 0; b < added.length; b++) {
-          var node = added[b];
-          if (node.nodeType !== 1) continue;
-          if (node.matches && node.matches('pre')) highlight(node);
-          if (node.querySelectorAll) run(node);
-        }
-      }
-    }).observe(document.body, { childList: true, subtree: true });
-  }
-
-  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
-  else init();
-})();
diff --git a/packages/ui/packages/website/public/favicon-192.png b/packages/ui/packages/website/public/favicon-192.png
deleted file mode 100644
index 855b723eb..000000000
Binary files a/packages/ui/packages/website/public/favicon-192.png and /dev/null differ
diff --git a/packages/ui/packages/website/public/favicon.ico b/packages/ui/packages/website/public/favicon.ico
deleted file mode 100644
index 847ee6a73..000000000
Binary files a/packages/ui/packages/website/public/favicon.ico and /dev/null differ
diff --git a/packages/ui/packages/website/public/favicon.png b/packages/ui/packages/website/public/favicon.png
deleted file mode 100644
index ebcc07205..000000000
Binary files a/packages/ui/packages/website/public/favicon.png and /dev/null differ
diff --git a/packages/ui/packages/website/public/favicon.svg b/packages/ui/packages/website/public/favicon.svg
deleted file mode 100644
index 830c28833..000000000
--- a/packages/ui/packages/website/public/favicon.svg
+++ /dev/null
@@ -1,16 +0,0 @@
-
-  
-    
-      
-      
-    
-    
-  
-  
-  
-
\ No newline at end of file
diff --git a/packages/ui/packages/website/public/fonts/inter-tight.woff2 b/packages/ui/packages/website/public/fonts/inter-tight.woff2
deleted file mode 100644
index d8f79a69e..000000000
Binary files a/packages/ui/packages/website/public/fonts/inter-tight.woff2 and /dev/null differ
diff --git a/packages/ui/packages/website/public/fonts/inter.woff2 b/packages/ui/packages/website/public/fonts/inter.woff2
deleted file mode 100644
index d15208de0..000000000
Binary files a/packages/ui/packages/website/public/fonts/inter.woff2 and /dev/null differ
diff --git a/packages/ui/packages/website/public/fonts/jetbrains-mono.woff2 b/packages/ui/packages/website/public/fonts/jetbrains-mono.woff2
deleted file mode 100644
index cd5102a44..000000000
Binary files a/packages/ui/packages/website/public/fonts/jetbrains-mono.woff2 and /dev/null differ
diff --git a/packages/ui/packages/website/public/input.css b/packages/ui/packages/website/public/input.css
deleted file mode 100644
index 6874aa309..000000000
--- a/packages/ui/packages/website/public/input.css
+++ /dev/null
@@ -1,166 +0,0 @@
-/* Tailwind CSS v4 input - compiled to public/tailwind.css. */
-@import "tailwindcss";
-
-@source "../app/**/*.{ts,js,tsx,jsx}";
-@source "../../registry/components/**/*.{ts,js}";
-@source "../components/**/*.{ts,js,tsx,jsx}";
-
-/* --------------------------------------------------------------------------
-   Webjs site theme - used by the docs chrome (header, nav, footer, prose).
-   -------------------------------------------------------------------------- */
-@theme {
-  --color-fg:            var(--fg);
-  --color-fg-muted:      var(--fg-muted);
-  --color-fg-subtle:     var(--fg-subtle);
-
-  --color-bg:            var(--bg);
-  --color-bg-elev:       var(--bg-elev);
-  --color-bg-subtle:     var(--bg-subtle);
-  --color-bg-sunken:     var(--bg-sunken);
-
-  --color-border:        var(--border);
-  --color-border-strong: var(--border-strong);
-
-  --color-accent:        var(--accent);
-  --color-accent-hover:  var(--accent-hover);
-  --color-accent-fg:     var(--accent-fg);
-  --color-accent-tint:   var(--accent-tint);
-  --color-accent-live:   var(--accent-live);
-
-  /* Brand-accent aliases - same warm-orange tokens as the chrome `accent`,
-     but exposed under `brand` so they're NEVER overridden by the shadcn
-     `--color-accent` redefinition below. Use `bg-brand`/`text-brand-fg`/
-     `hover:bg-brand-hover` for marketing CTAs that must stay webjs-warm
-     regardless of which palette `bg-accent` happens to resolve to. */
-  --color-brand:         var(--accent);
-  --color-brand-hover:   var(--accent-hover);
-  --color-brand-fg:      var(--accent-fg);
-  --color-brand-tint:    var(--accent-tint);
-
-  --font-display: var(--font-display);
-  --font-sans:  var(--font-sans);
-  --font-serif: var(--font-serif);
-  --font-mono:  var(--font-mono);
-
-  /* Transition tokens - matched to webjs.dev. Generates Tailwind's
-     `duration-fast` and `duration-slow` utilities. */
-  --duration-fast: 140ms;
-  --duration-slow: 220ms;
-
-  /* ------------------------------------------------------------------------
-     Shadcn theme tokens - used by the ui-* component previews.
-     These are SEPARATE from the webjs chrome tokens above so the docs site
-     keeps its warm orange look while the component previews render with the
-     standard shadcn neutral palette (what users will see in their own apps).
-     ------------------------------------------------------------------------ */
-  --color-background:           var(--background);
-  --color-foreground:           var(--foreground);
-  --color-card:                 var(--card);
-  --color-card-foreground:      var(--card-foreground);
-  --color-popover:              var(--popover);
-  --color-popover-foreground:   var(--popover-foreground);
-  --color-primary:              var(--primary);
-  --color-primary-foreground:   var(--primary-foreground);
-  --color-secondary:            var(--secondary);
-  --color-secondary-foreground: var(--secondary-foreground);
-  --color-muted:                var(--muted);
-  --color-muted-foreground:     var(--muted-foreground);
-  /* `bg-accent` / `text-accent-foreground` (used by ghost / outline buttons,
-     hover states inside ui-* components) map to the shadcn neutral accent -
-     NOT the webjs warm brand accent. The brand accent stays available via
-     raw `var(--accent)` for the docs chrome (headlines, announce banner). */
-  --color-accent:               var(--accent-shadcn);
-  --color-accent-foreground:    var(--accent-shadcn-foreground);
-  --color-destructive:          var(--destructive);
-  --color-destructive-foreground: var(--destructive-foreground);
-  --color-input:                var(--input);
-  --color-ring:                 var(--ring);
-}
-
-/* --------------------------------------------------------------------------
-   Shadcn theme variable values (neutral base, light + dark mode).
-   Mirrors what `webjsui init` writes into the user's app - ui-* components
-   reference these via Tailwind classes like `bg-primary`, `text-foreground`.
-   -------------------------------------------------------------------------- */
-:root {
-  --radius: 0.625rem;
-  --background:               oklch(1 0 0);
-  --foreground:               oklch(0.145 0 0);
-  --card:                     oklch(1 0 0);
-  --card-foreground:          oklch(0.145 0 0);
-  --popover:                  oklch(1 0 0);
-  --popover-foreground:       oklch(0.145 0 0);
-  --primary:                  oklch(0.205 0 0);
-  --primary-foreground:       oklch(0.985 0 0);
-  --secondary:                oklch(0.97 0 0);
-  --secondary-foreground:     oklch(0.205 0 0);
-  --muted:                    oklch(0.97 0 0);
-  --muted-foreground:         oklch(0.556 0 0);
-  --accent-shadcn:            oklch(0.97 0 0);
-  --accent-shadcn-foreground: oklch(0.205 0 0);
-  --destructive:              oklch(0.577 0.245 27.325);
-  --destructive-foreground:   oklch(0.97 0.01 17);
-  --input:                    oklch(0.922 0 0);
-  --ring:                     oklch(0.708 0 0);
-}
-
-/* OS-preference dark, when no explicit toggle. Gated by
-   `:root:not([data-theme='light'])` so the theme toggle's explicit-light
-   choice still beats the OS in system mode. Mirrors how the webjs chrome
-   tokens (--fg / --bg / --accent / …) are darkened in `app/layout.ts`. */
-@media (prefers-color-scheme: dark) {
-  :root:not([data-theme='light']) {
-    --background:               oklch(0.145 0 0);
-    --foreground:               oklch(0.985 0 0);
-    --card:                     oklch(0.205 0 0);
-    --card-foreground:          oklch(0.985 0 0);
-    --popover:                  oklch(0.205 0 0);
-    --popover-foreground:       oklch(0.985 0 0);
-    --primary:                  oklch(0.922 0 0);
-    --primary-foreground:       oklch(0.205 0 0);
-    --secondary:                oklch(0.269 0 0);
-    --secondary-foreground:     oklch(0.985 0 0);
-    --muted:                    oklch(0.269 0 0);
-    --muted-foreground:         oklch(0.708 0 0);
-    --accent-shadcn:            oklch(0.371 0 0);
-    --accent-shadcn-foreground: oklch(0.985 0 0);
-    --destructive:              oklch(0.704 0.191 22.216);
-    --destructive-foreground:   oklch(0.58 0.22 27);
-    --input:                    oklch(1 0 0 / 15%);
-    --ring:                     oklch(0.556 0 0);
-  }
-}
-
-/* Explicit toggle to dark - wins over the OS regardless of preference. */
-:root[data-theme='dark'],
-:root.dark {
-  --background:               oklch(0.145 0 0);
-  --foreground:               oklch(0.985 0 0);
-  --card:                     oklch(0.205 0 0);
-  --card-foreground:          oklch(0.985 0 0);
-  --popover:                  oklch(0.205 0 0);
-  --popover-foreground:       oklch(0.985 0 0);
-  --primary:                  oklch(0.922 0 0);
-  --primary-foreground:       oklch(0.205 0 0);
-  --secondary:                oklch(0.269 0 0);
-  --secondary-foreground:     oklch(0.985 0 0);
-  --muted:                    oklch(0.269 0 0);
-  --muted-foreground:         oklch(0.708 0 0);
-  --accent-shadcn:            oklch(0.371 0 0);
-  --accent-shadcn-foreground: oklch(0.985 0 0);
-  --destructive:              oklch(0.704 0.191 22.216);
-  --destructive-foreground:   oklch(0.58 0.22 27);
-  --input:                    oklch(1 0 0 / 15%);
-  --ring:                     oklch(0.556 0 0);
-}
-
-/*
- * Self-hosted fonts (latin subset woff2 in public/fonts/), aligned to the
- * webjs.dev marketing site. One VARIABLE file per family covers its whole
- * weight range, so a font-weight range here resolves every used weight from
- * a single download. No Google Fonts CDN, no render-blocking third-party
- * stylesheet. font-display:swap paints with the fallback, then swaps.
- */
-@font-face{font-family:'Inter Tight';font-style:normal;font-weight:100 900;font-display:swap;src:url('/public/fonts/inter-tight.woff2') format('woff2');}
-@font-face{font-family:'Inter';font-style:normal;font-weight:100 900;font-display:swap;src:url('/public/fonts/inter.woff2') format('woff2');}
-@font-face{font-family:'JetBrains Mono';font-style:normal;font-weight:100 800;font-display:swap;src:url('/public/fonts/jetbrains-mono.woff2') format('woff2');}
diff --git a/packages/ui/packages/website/public/og.png b/packages/ui/packages/website/public/og.png
deleted file mode 100644
index ec41e7354..000000000
Binary files a/packages/ui/packages/website/public/og.png and /dev/null differ
diff --git a/packages/ui/packages/website/public/og.svg b/packages/ui/packages/website/public/og.svg
deleted file mode 100644
index 0d7fb0834..000000000
--- a/packages/ui/packages/website/public/og.svg
+++ /dev/null
@@ -1,65 +0,0 @@
-
-
-  
-    
-    
-      
-      
-      
-      
-    
-    
-      
-      
-    
-    
-    
-      
-      
-    
-  
-
-  
-  
-
-  
-  
-
-  
-  
-
-  
-  
-    
-    
-    webjs ui
-  
-
-  
-  
-    A component library
-    written for AI agents.
-  
-
-  
-  
-    32 primitives. shadcn API parity. Native HTML semantics.
-    Zero third-party deps. Copy-paste source. Works anywhere.
-  
-
-  
-  
-    
-    COMPOSITION-FIRST  ·  NATIVE SEMANTICS  ·  ZERO DEPS
-  
-
-  
-  UI.WEBJS.DEV
-
diff --git a/packages/ui/packages/website/scripts/copy-registry.js b/packages/ui/packages/website/scripts/copy-registry.js
deleted file mode 100644
index 88094a9a1..000000000
--- a/packages/ui/packages/website/scripts/copy-registry.js
+++ /dev/null
@@ -1,64 +0,0 @@
-#!/usr/bin/env node
-/**
- * Copy the @webjsdev/ui registry component sources into this website's
- * components/ui/ so the docs pages can import them and render live previews.
- *
- * We rewrite the relative path to `lib/utils.ts` from `../lib/utils.ts` (the
- * registry's local layout) to `../../lib/utils.ts` (the website's layout -
- * `components/ui/.ts` is one level deeper than `lib/utils.ts`).
- *
- * Run via `npm run preview:build` / automatically before `dev` and `start`.
- */
-import { readFileSync, writeFileSync, readdirSync, mkdirSync, existsSync, copyFileSync, rmSync } from 'node:fs';
-import { dirname, join, resolve } from 'node:path';
-import { fileURLToPath } from 'node:url';
-
-const __dirname = dirname(fileURLToPath(import.meta.url));
-const WEBSITE_ROOT = resolve(__dirname, '..');
-const REGISTRY_ROOT = resolve(__dirname, '..', '..', 'registry');
-
-const COMPONENTS_SRC = join(REGISTRY_ROOT, 'components');
-const LIB_SRC = join(REGISTRY_ROOT, 'lib', 'utils.ts');
-// onBeforeCache lives in its own client-only module since the #819 split.
-const LIB_DOM_SRC = join(REGISTRY_ROOT, 'lib', 'dom.ts');
-
-const COMPONENTS_DST = join(WEBSITE_ROOT, 'components', 'ui');
-const LIB_DST = join(WEBSITE_ROOT, 'lib', 'utils.ts');
-const LIB_DOM_DST = join(WEBSITE_ROOT, 'lib', 'dom.ts');
-
-if (!existsSync(COMPONENTS_SRC)) {
-  console.error(`[ui-website] registry components not found at ${COMPONENTS_SRC}`);
-  process.exit(1);
-}
-
-mkdirSync(COMPONENTS_DST, { recursive: true });
-mkdirSync(dirname(LIB_DST), { recursive: true });
-
-// 1. Clean the destination so removed-from-registry components don't linger as
-//    orphans (they'd still be importable from website code and confuse SSR /
-//    the dev server's module graph). We only remove .ts files we'd manage.
-for (const name of readdirSync(COMPONENTS_DST)) {
-  if (name.endsWith('.ts')) rmSync(join(COMPONENTS_DST, name));
-}
-
-// 2. Copy lib/utils.ts (pure cn helpers) and lib/dom.ts (onBeforeCache) verbatim.
-copyFileSync(LIB_SRC, LIB_DST);
-copyFileSync(LIB_DOM_SRC, LIB_DOM_DST);
-
-// 3. Copy each component, rewriting the `../lib/utils.ts` and `../lib/dom.ts`
-//    import paths so they point at the website's `lib/` (two levels up from
-//    `components/ui/.ts`). Missing the dom.ts rewrite 500s SSR (#819).
-let copied = 0;
-for (const name of readdirSync(COMPONENTS_SRC)) {
-  if (!name.endsWith('.ts')) continue;
-  const raw = readFileSync(join(COMPONENTS_SRC, name), 'utf8');
-  const rewritten = raw
-    .replaceAll("'../lib/utils.ts'", "'../../lib/utils.ts'")
-    .replaceAll('"../lib/utils.ts"', '"../../lib/utils.ts"')
-    .replaceAll("'../lib/dom.ts'", "'../../lib/dom.ts'")
-    .replaceAll('"../lib/dom.ts"', '"../../lib/dom.ts"');
-  writeFileSync(join(COMPONENTS_DST, name), rewritten);
-  copied++;
-}
-
-console.log(`[ui-website] copied ${copied} components + lib/utils.ts + lib/dom.ts from registry`);
diff --git a/packages/ui/packages/website/scripts/generate-og.mjs b/packages/ui/packages/website/scripts/generate-og.mjs
deleted file mode 100644
index 4ee7d4a2a..000000000
--- a/packages/ui/packages/website/scripts/generate-og.mjs
+++ /dev/null
@@ -1,119 +0,0 @@
-#!/usr/bin/env node
-/**
- * Generate the Open Graph card for ui.webjs.dev.
- *
- * Mirrors the design language of website/public/og.png (the marketing
- * site's card): warm-orange-on-dark, serif headline, mono rubric. Content
- * is swapped to describe the UI component library rather than the
- * framework itself.
- *
- * Run with:
- *   node scripts/generate-og.mjs
- *
- * Requires `rsvg-convert` on PATH (librsvg): installed system-wide on
- * the dev machine. Produces:
- *   public/og.svg   (vector source, committed for diff-friendly edits)
- *   public/og.png   (1200x630, committed and referenced from layout.ts)
- */
-import { writeFileSync } from 'node:fs';
-import { execFileSync } from 'node:child_process';
-import { dirname, join } from 'node:path';
-import { fileURLToPath } from 'node:url';
-
-const __dirname = dirname(fileURLToPath(import.meta.url));
-const publicDir = join(__dirname, '..', 'public');
-
-const W = 1200;
-const H = 630;
-
-// Warm-orange-on-dark palette pulled from the chrome's dark-mode tokens
-// (`app/layout.ts` :root[data-theme='dark']). Hex approximations so the
-// SVG renders identically regardless of the consumer's color management.
-const BG_BASE = '#1c1613';      // --bg (oklch 0.14 0.01 55)
-const ACCENT  = '#f6a06b';      // --accent (oklch 0.78 0.14 55)
-const FG      = '#f8f0e9';      // --fg (oklch 0.96 0.015 60)
-const FG_MUTED = '#c5b8ad';     // --fg-muted (oklch 0.72 0.02 60)
-const FG_SUBTLE = '#8a7d72';    // --fg-subtle (oklch 0.55 0.02 60)
-// The logo mark gradient, matching the dark navbar mark (--logo-from/--logo-to
-// in app/layout.ts, oklch 0.8 0.16 58 -> 0.62 0.18 44). Hex approximations so
-// rsvg-convert renders it identically regardless of color management.
-const LOGO_FROM = '#ffa146';    // --logo-from dark (oklch 0.8 0.16 58)
-const LOGO_TO   = '#da5801';    // --logo-to dark (oklch 0.62 0.18 44)
-
-const svg = `
-
-  
-    
-    
-      
-      
-      
-      
-    
-    
-      
-      
-    
-    
-    
-      
-      
-    
-  
-
-  
-  
-
-  
-  
-
-  
-  
-
-  
-  
-    
-    
-    webjs ui
-  
-
-  
-  
-    A component library
-    written for AI agents.
-  
-
-  
-  
-    32 primitives. shadcn API parity. Native HTML semantics.
-    Zero third-party deps. Copy-paste source. Works anywhere.
-  
-
-  
-  
-    
-    COMPOSITION-FIRST  ·  NATIVE SEMANTICS  ·  ZERO DEPS
-  
-
-  
-  UI.WEBJS.DEV
-
-`;
-
-const svgPath = join(publicDir, 'og.svg');
-const pngPath = join(publicDir, 'og.png');
-writeFileSync(svgPath, svg);
-
-// Convert SVG → PNG at native 1200x630. rsvg-convert respects font fallbacks
-// declared in the SVG (Liberation Serif, Liberation Sans, JetBrains Mono
-// Nerd Font): all guaranteed on the dev machine via fontconfig.
-execFileSync('rsvg-convert', ['-w', String(W), '-h', String(H), svgPath, '-o', pngPath]);
-console.log(`Wrote ${svgPath}`);
-console.log(`Wrote ${pngPath} (${W}x${H})`);
diff --git a/packages/ui/packages/website/tsconfig.json b/packages/ui/packages/website/tsconfig.json
index 6862e5d82..8e7f27232 100644
--- a/packages/ui/packages/website/tsconfig.json
+++ b/packages/ui/packages/website/tsconfig.json
@@ -3,37 +3,14 @@
     "target": "ES2022",
     "module": "NodeNext",
     "moduleResolution": "NodeNext",
-    "lib": [
-      "ES2022",
-      "DOM",
-      "DOM.Iterable"
-    ],
+    "lib": ["ES2022", "DOM", "DOM.Iterable"],
     "strict": true,
     "types": ["node"],
     "noEmit": true,
-    "checkJs": false,
-    "allowJs": true,
     "allowImportingTsExtensions": true,
     "skipLibCheck": true,
-    "isolatedModules": true,
-    "verbatimModuleSyntax": false,
-    "erasableSyntaxOnly": true,
-    "jsx": "preserve",
-    "plugins": [
-      {
-        "name": "@webjsdev/intellisense"
-      }
-    ]
+    "erasableSyntaxOnly": true
   },
-  "include": [
-    "app/**/*",
-    "components/**/*",
-    "lib/**/*",
-    "middleware.js",
-    "middleware.ts"
-  ],
-  "exclude": [
-    "node_modules",
-    ".webjs"
-  ]
+  "include": ["app/**/*", "middleware.ts"],
+  "exclude": ["node_modules", ".webjs"]
 }
diff --git a/packages/ui/src/registry/fetcher.js b/packages/ui/src/registry/fetcher.js
index faf80dd1d..d7f122d92 100644
--- a/packages/ui/src/registry/fetcher.js
+++ b/packages/ui/src/registry/fetcher.js
@@ -4,8 +4,16 @@ import { loadRegistryItem, loadRegistryIndex } from './local.js';
 /**
  * The canonical hosted registry. Local-first resolution applies ONLY for this
  * exact URL: it is the one registry whose sources ship inside the package.
+ *
+ * This moved from `https://ui.webjs.dev/registry` when the gallery merged into
+ * the marketing site (#1099). The OLD URL still works and must keep working
+ * forever, because every already-published version of this package carries it
+ * and cannot be corrected: `ui.webjs.dev` now permanently redirects here, and
+ * `fetch` follows redirects by default (verified against the real published
+ * 0.3.1 and 0.3.8 tarballs before the move). Pointing new releases straight at
+ * the destination just saves them the hop.
  */
-export const HOSTED_REGISTRY_URL = 'https://ui.webjs.dev/registry';
+export const HOSTED_REGISTRY_URL = 'https://webjs.dev/ui/registry';
 
 /**
  * Default registry URL. A `REGISTRY_URL` env var points at a CUSTOM registry,
diff --git a/packages/ui/src/registry/local.js b/packages/ui/src/registry/local.js
index d6b81b7c3..6ebfac605 100644
--- a/packages/ui/src/registry/local.js
+++ b/packages/ui/src/registry/local.js
@@ -9,7 +9,7 @@
  * are interchangeable for every consumer.
  *
  * It is the plain-JS twin of the ui-website composer
- * (`packages/ui/packages/website/app/_lib/registry.server.ts`): both read
+ * (`website/modules/ui/queries/registry.server.ts`): both read
  * `packages/registry/registry.json` + the `components/*.ts` sources and
  * synthesize the 6 non-neutral base-colour themes from `themes/base-colors.js`.
  * Keeping the CLI on this module (rather than always hitting the network) is
diff --git a/packages/ui/test/e2e/touch.e2e.mjs b/packages/ui/test/e2e/touch.e2e.mjs
index d90c08f67..0a133ae5d 100644
--- a/packages/ui/test/e2e/touch.e2e.mjs
+++ b/packages/ui/test/e2e/touch.e2e.mjs
@@ -5,10 +5,14 @@
  * desktop test caught. Touch *events* (`pointerType`, `matchMedia('(hover:none)')`)
  * emulate faithfully under Playwright's iPhone context (unlike the #730
  * engine-strictness quirk), so this runs on ordinary CI hardware: it boots the
- * ui-website and TAPS each component's primary trigger, asserting the open /
- * render outcome that was broken pre-#746.
+ * site serving the component gallery and TAPS each component's primary
+ * trigger, asserting the open / render outcome that was broken pre-#746.
  *
- * Self-contained: boots the website, runs the checks, tears down. Needs
+ * The gallery moved from ui.webjs.dev to webjs.dev/ui in #1099, so this boots
+ * the marketing app and reads the flat /ui/ paths rather than the
+ * retired nested ones under the old host's docs section.
+ *
+ * Self-contained: boots the site, runs the checks, tears down. Needs
  * Playwright + a browser. Run: `node packages/ui/test/e2e/touch.e2e.mjs`
  * (the runner sets WEBJS_E2E_TOUCH=1). Skips with a clear message if Playwright
  * or a browser is unavailable.
@@ -20,7 +24,7 @@ import { dirname, resolve } from 'node:path';
 import process from 'node:process';
 
 const HERE = dirname(fileURLToPath(import.meta.url));
-const WEBSITE = resolve(HERE, '../../packages/website');
+const WEBSITE = resolve(HERE, '../../../../website');
 const PORT = Number(process.env.WEBJS_E2E_PORT || 5181);
 const BASE = `http://localhost:${PORT}`;
 
@@ -34,9 +38,9 @@ try {
   process.exit(0);
 }
 
-// Boot the ui-website (copies the registry, then `webjs start`).
-const cli = resolve(WEBSITE, '../../../../node_modules/@webjsdev/cli/bin/webjs.js');
-spawn(process.execPath, [resolve(WEBSITE, 'scripts/copy-registry.js')], { cwd: WEBSITE, stdio: 'ignore' });
+// Boot the site (mirrors the registry sources in, then `webjs start`).
+const cli = resolve(WEBSITE, '../node_modules/@webjsdev/cli/bin/webjs.js');
+spawn(process.execPath, [resolve(WEBSITE, 'scripts/copy-registry.mjs')], { cwd: WEBSITE, stdio: 'ignore' });
 await sleep(800);
 const server = spawn(process.execPath, [cli, 'start', '--port', String(PORT)], {
   cwd: WEBSITE,
@@ -55,7 +59,7 @@ for (let i = 0; i < 40; i++) {
   } catch { /* retry */ }
   await sleep(500);
 }
-if (!up) { fail('ui-website did not become ready'); teardown(); process.exit(1); }
+if (!up) { fail('the gallery site did not become ready'); teardown(); process.exit(1); }
 
 // Chromium with the iPhone descriptor activates every touch path the fixes key
 // on (verified: matchMedia('(hover:none)') + '(pointer:coarse)' both match, and
@@ -76,7 +80,7 @@ const page = await ctx.newPage();
 const results = [];
 
 // 1) sonner: tap "Show toast" -> a toast renders.
-await page.goto(BASE + '/docs/components/sonner', { waitUntil: 'domcontentloaded' });
+await page.goto(BASE + '/ui/sonner', { waitUntil: 'domcontentloaded' });
 await page.waitForTimeout(1800);
 const sbtn = await page.evaluateHandle(() =>
   [...document.querySelectorAll('button')].find((b) => /show toast/i.test(b.textContent || '')));
@@ -87,7 +91,7 @@ const toastNodes = await page.evaluate(() =>
 results.push(['sonner toast renders on tap', toastNodes > 0]);
 
 // 2) hover-card: tap trigger -> opens, no navigation.
-await page.goto(BASE + '/docs/components/hover-card', { waitUntil: 'domcontentloaded' });
+await page.goto(BASE + '/ui/hover-card', { waitUntil: 'domcontentloaded' });
 await page.waitForTimeout(1800);
 const urlBefore = page.url();
 await (await page.$('ui-hover-card-trigger'))?.tap();
@@ -96,7 +100,7 @@ const hcOpen = await page.evaluate(() => !!document.querySelector('ui-hover-card
 results.push(['hover-card opens on tap without navigating', hcOpen && page.url() === urlBefore]);
 
 // 3) dropdown submenu: open menu, tap sub-trigger -> opens AND stays (past close delay).
-await page.goto(BASE + '/docs/components/dropdown-menu', { waitUntil: 'domcontentloaded' });
+await page.goto(BASE + '/ui/dropdown-menu', { waitUntil: 'domcontentloaded' });
 await page.waitForTimeout(1800);
 await (await page.$('ui-dropdown-menu-trigger button, ui-dropdown-menu-trigger'))?.tap();
 await page.waitForTimeout(400);
@@ -107,7 +111,7 @@ results.push(['dropdown submenu opens and stays open on tap', subOpen]);
 
 // 4) before-cache (#766): open a hover-card, then fire the event the router
 // fires when it snapshots the page for back/forward. The card must close.
-await page.goto(BASE + '/docs/components/hover-card', { waitUntil: 'domcontentloaded' });
+await page.goto(BASE + '/ui/hover-card', { waitUntil: 'domcontentloaded' });
 await page.waitForTimeout(1500);
 await (await page.$('ui-hover-card-trigger'))?.tap();
 await page.waitForTimeout(500);
@@ -120,12 +124,12 @@ results.push(['hover-card closes on webjs:before-cache', openedBC && closedBC]);
 // 5) real round-trip: open a hover-card, soft-nav away via an internal link,
 // then Back. The restored snapshot must show the card closed (the router fired
 // before-cache when it snapshotted the page being left).
-await page.goto(BASE + '/docs/components/hover-card', { waitUntil: 'domcontentloaded' });
+await page.goto(BASE + '/ui/hover-card', { waitUntil: 'domcontentloaded' });
 await page.waitForTimeout(1500);
 await (await page.$('ui-hover-card-trigger'))?.tap();
 await page.waitForTimeout(400);
 const navHref = await page.evaluate(() => {
-  const a = [...document.querySelectorAll('a[href^="/docs/components/"]')]
+  const a = [...document.querySelectorAll('a[href^="/ui/"]')]
     .find((x) => x.getAttribute('href') !== location.pathname);
   if (a) { a.click(); return a.getAttribute('href'); }
   return null;
diff --git a/packages/ui/test/fetcher.test.js b/packages/ui/test/fetcher.test.js
index e4759e706..8a1de6167 100644
--- a/packages/ui/test/fetcher.test.js
+++ b/packages/ui/test/fetcher.test.js
@@ -1,6 +1,11 @@
 import { test, beforeEach } from 'node:test';
 import assert from 'node:assert/strict';
-import { fetchRegistryItem, fetchRegistryIndex } from '../src/registry/fetcher.js';
+import {
+  fetchRegistryItem,
+  fetchRegistryIndex,
+  isDefaultRegistry,
+  HOSTED_REGISTRY_URL,
+} from '../src/registry/fetcher.js';
 
 const origFetch = globalThis.fetch;
 let calls = [];
@@ -66,3 +71,33 @@ test('fetchRegistryIndex: fetches the index', async () => {
 
 // Restore fetch after tests run
 globalThis.fetch = origFetch;
+
+/**
+ * The hosted registry URL is a RELEASED CONTRACT, so it is pinned rather than
+ * left to drift (#1099).
+ *
+ * Two separate properties matter here. The constant has to point at the live
+ * endpoint, because that is what a fresh install fetches from. And it has to
+ * stay the value `isDefaultRegistry` compares against, because that comparison
+ * is what selects local-first resolution: if the two ever disagree, every
+ * default install silently starts taking the network path instead of reading
+ * the sources that ship inside this package.
+ *
+ * The previous value, `https://ui.webjs.dev/registry`, is still live as a
+ * permanent redirect and must stay that way forever for already-published
+ * versions. That side of the contract is covered by
+ * `test/ui/ui-host-redirect.test.mjs`.
+ */
+test('the hosted registry URL points at the live endpoint', () => {
+  assert.equal(HOSTED_REGISTRY_URL, 'https://webjs.dev/ui/registry');
+});
+
+test('the hosted registry URL is what selects local-first resolution', () => {
+  assert.equal(isDefaultRegistry(HOSTED_REGISTRY_URL), true, 'the canonical URL is the default');
+  assert.equal(isDefaultRegistry(undefined), true, 'and so is passing nothing');
+  assert.equal(
+    isDefaultRegistry('https://ui.webjs.dev/registry'),
+    false,
+    'the old host is a custom registry now, so it takes the network path rather than being silently shadowed by the packaged sources',
+  );
+});
diff --git a/packages/ui/test/registry-composer.test.js b/packages/ui/test/registry-composer.test.js
index a3b1228d8..49808aeb4 100644
--- a/packages/ui/test/registry-composer.test.js
+++ b/packages/ui/test/registry-composer.test.js
@@ -1,6 +1,6 @@
 /**
- * Unit tests for the ui-website's registry composer
- * (`packages/ui/packages/website/app/_lib/registry.server.ts`).
+ * Unit tests for the registry composer
+ * (`website/modules/ui/queries/registry.server.ts`).
  *
  * The composer turns the on-disk registry.json + theme synthesis logic
  * into the JSON the CLI fetches at runtime via /registry/.json.
@@ -9,6 +9,11 @@
  * out of the index, or the cache returning a stale value) wouldn't
  * surface until production.
  *
+ * The composer moved out of this package with #1099, when the gallery and
+ * the registry API merged into the marketing site. It still reads the
+ * sources in `packages/ui/packages/registry/`, which is why its tests stay
+ * here next to the registry they describe.
+ *
  * Imports the .ts module directly: Node 22+ strips types natively, so
  * no transpile step is needed.
  */
@@ -21,10 +26,12 @@ import { fileURLToPath } from 'node:url';
 const COMPOSER_PATH = join(
   dirname(fileURLToPath(import.meta.url)),
   '..',
-  'packages',
+  '..',
+  '..',
   'website',
-  'app',
-  '_lib',
+  'modules',
+  'ui',
+  'queries',
   'registry.server.ts',
 );
 
diff --git a/scripts/dev-all.js b/scripts/dev-all.js
index 22d31605e..8f6e64b7f 100755
--- a/scripts/dev-all.js
+++ b/scripts/dev-all.js
@@ -61,8 +61,8 @@ const ports = {
 };
 
 // Use each workspace's `npm run dev` so the concurrently-spawned
-// tailwind CLI watcher (and, for the blog, the db migrate; for the UI
-// site, the predev copy-registry step) runs too.
+// tailwind CLI watcher (and, for the blog, the db migrate; for the website,
+// the registry-mirror step the /ui previews need) runs too.
 // Point every app's nav + footer cross-links at the sibling dev servers. Each
 // app's lib/links.ts reads these and otherwise falls back to the production
 // domains, which is why a local cross-link would otherwise open the live site.
@@ -70,17 +70,18 @@ const ports = {
 // BLOG_PORT override flows through to every app, not just the website.
 const links = {
   WEBSITE_URL: `http://localhost:${ports.website}`,
-  UI_URL: `http://localhost:${ports.ui}`,
   EXAMPLE_BLOG_URL: `http://localhost:${ports.blog}`,
 };
-// The docs are served BY the website now (webjs.dev/docs), so there is no
-// DOCS_URL cross-link. The docs app is a redirect-only host, and SITE_URL is
-// where it sends everything, which locally has to be the website dev server
-// rather than production.
+// The docs (webjs.dev/docs) and the component gallery (webjs.dev/ui) are both
+// served BY the website now, so there is no DOCS_URL or UI_URL cross-link.
+// Both old subdomains are redirect-only hosts, and SITE_URL is where each
+// sends everything, which locally has to be the website dev server rather than
+// production. Without it, hitting either one in local dev silently lands you
+// on the live site.
 const siteUrl = { SITE_URL: `http://localhost:${ports.website}` };
 start('website', resolve(root, 'website'), 'npm', ['run', 'dev'], { PORT: ports.website, ...links });
 start('docs',    resolve(root, 'docs'),    'npm', ['run', 'dev'], { PORT: ports.docs, ...links, ...siteUrl });
-start('ui',      resolve(root, 'packages', 'ui', 'packages', 'website'), 'npm', ['run', 'dev'], { PORT: ports.ui, ...links });
+start('ui',      resolve(root, 'packages', 'ui', 'packages', 'website'), 'npm', ['run', 'dev'], { PORT: ports.ui, ...links, ...siteUrl });
 start('blog',    resolve(root, 'examples', 'blog'), 'npm', ['run', 'dev'], { PORT: ports.blog, ...links });
 
 function cleanup() {
diff --git a/scripts/generate-favicon.mjs b/scripts/generate-favicon.mjs
index 89f5beef1..2ed1b7f23 100644
--- a/scripts/generate-favicon.mjs
+++ b/scripts/generate-favicon.mjs
@@ -16,13 +16,13 @@ import { fileURLToPath } from 'node:url';
 const __dirname = dirname(fileURLToPath(import.meta.url));
 const root = resolve(__dirname, '..');
 
-// The docs are served by the website (#1098), so they share its favicon. The
-// docs.webjs.dev host that remains renders no HTML at all, only redirects, so
-// it has no public/ to write into.
+// The docs (#1098) and the component gallery (#1099) are served by the website,
+// so they share its favicon. The docs.webjs.dev and ui.webjs.dev hosts that
+// remain render no HTML at all, only redirects, so neither has a public/ to
+// write into (and writing to a directory that no longer exists throws).
 const APPS = [
   resolve(root, 'website/public'),
   resolve(root, 'examples/blog/public'),
-  resolve(root, 'packages/ui/packages/website/public'),
 ];
 
 // SVG that matches the header logo mark in website/app/layout.ts: a rounded
diff --git a/test/bun/app-boot.mjs b/test/bun/app-boot.mjs
index 8b391ca8c..545cfa9c0 100644
--- a/test/bun/app-boot.mjs
+++ b/test/bun/app-boot.mjs
@@ -1,23 +1,24 @@
 /**
- * Cross-runtime app boot-check for the in-repo apps whose routes CI would
- * not otherwise exercise: `website` (which serves the documentation at
- * /docs) and `packages/ui/packages/website` (ui-website). The
- * docs.webjs.dev redirect host is deliberately absent: every route on it
- * is an empty 301, which would pass this check vacuously.
+ * Cross-runtime app boot-check for the routes CI would not otherwise
+ * exercise. That is now one app, `website`, which serves the marketing pages,
+ * the documentation at /docs (#1098), and the component gallery at /ui
+ * (#1099). The docs.webjs.dev and ui.webjs.dev redirect hosts are deliberately
+ * absent: every route on them is an empty 301, which would pass this check
+ * vacuously.
  * All the in-repo apps DEPLOY on Bun in production (#541), but only
  * `examples/blog` had Bun coverage in CI (the blog-on-bun e2e), so a per-route
  * break that occurs only on Bun could reach production undetected. The #526
- * incident was exactly this: ui.webjs.dev served 500s on its component detail
- * pages because the prod start bypassed the registry copy, and Railway's
- * liveness-only healthcheck never probed an individual route.
+ * incident was exactly this: the component detail pages served 500s because
+ * the prod start bypassed the registry copy, and Railway's liveness-only
+ * healthcheck never probed an individual route.
  *
  * This runs under WHICHEVER runtime executes it (Bun in the CI `bun` job, and
  * Node via `scripts/run-bun-tests.js`): it runs each app's `webjs.start.before`
- * presteps (the ui-website registry copy + each app's Tailwind build, exactly
- * what `webjs start` runs), boots the app via `createRequestHandler({ dev:
- * false })`, GETs real routes (including a ui-website component detail page, the
- * #526 route class), and asserts status < 400 plus no broken same-origin
- * `modulepreload` hint (the #158 / #159 probe). Fails LOUD with a non-zero exit.
+ * presteps (the registry copy + the Tailwind build, exactly what `webjs start`
+ * runs), boots the app via `createRequestHandler({ dev: false })`, GETs real
+ * routes (including a gallery component detail page, the #526 route class),
+ * and asserts status < 400 plus no broken same-origin `modulepreload` hint
+ * (the #158 / #159 probe). Fails LOUD with a non-zero exit.
  *
  * Left as-is: `examples/blog` already has its own Bun e2e (#523 / #525), so it
  * is not duplicated here.
@@ -31,18 +32,21 @@ import { createRequestHandler } from '@webjsdev/server';
 const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
 const runtime = process.versions.bun ? `bun ${process.versions.bun}` : `node ${process.versions.node}`;
 
-/** The apps + the real routes to probe. ui-website includes a component
- *  detail page (`/docs/components/[name]`), the exact route class that 500'd in
- *  #526 when the registry copy was skipped.
+/** The apps + the real routes to probe.
  *
- *  The website's list carries doc routes because the docs are served by it
- *  since #1098. The old `docs` app is gone from this list on purpose: it is a
- *  redirect-only host now, and every route on it answers 301 with an empty
- *  body, which passes the status check while probing zero preloads. Leaving it
- *  here would have looked like docs coverage while providing none. */
+ *  Everything the project serves as HTML is now one app: the docs moved onto
+ *  the website in #1098 and the component gallery in #1099. So the list is a
+ *  single entry, and it carries a gallery component detail page (`/ui/button`),
+ *  the exact route class that 500'd in #526 when the registry copy was skipped.
+ *
+ *  The old `docs` and `ui-website` apps are gone from this list on purpose:
+ *  both are redirect-only hosts now, and every route on them answers 301 with
+ *  an empty body, which passes a status check while probing zero preloads.
+ *  Leaving them here would have looked like coverage while providing none.
+ *  Their redirect MAPPINGS are asserted in test/ui/ui-host-redirect.test.mjs
+ *  and test/docs/docs-host-redirect.test.mjs instead. */
 const APPS = [
-  { name: 'website', dir: 'website', routes: ['/', '/docs/no-build', '/docs/components'] },
-  { name: 'ui-website', dir: 'packages/ui/packages/website', routes: ['/', '/docs/components/button'] },
+  { name: 'website', dir: 'website', routes: ['/', '/docs/no-build', '/docs/components', '/ui', '/ui/button'] },
 ];
 
 /** Run an app's `webjs.start.before` steps (registry copy, Tailwind build) the
@@ -92,4 +96,4 @@ if (failed) {
   console.error(`FAIL  app boot-check on ${runtime}`);
   process.exit(1);
 }
-console.log(`OK  app boot-check passed on ${runtime} (website incl. /docs + ui-website serve real routes, no broken preloads)`);
+console.log(`OK  app boot-check passed on ${runtime} (website incl. /docs + /ui serves real routes, no broken preloads)`);
diff --git a/test/preload-subset.test.mjs b/test/preload-subset.test.mjs
index 76a75081d..29b1402af 100644
--- a/test/preload-subset.test.mjs
+++ b/test/preload-subset.test.mjs
@@ -33,10 +33,15 @@ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
 // emit the layout's modulepreloads, which is what this test probes.
 const APPS = [
   { name: 'blog', dir: 'examples/blog', routes: ['/about', '/static-info'] },
-  // The docs are served by the website app (#1098), so their routes are probed
-  // as part of it. `docs/` itself is a redirect-only host with no pages left.
-  { name: 'website', dir: 'website', routes: ['/', '/docs/architecture', '/docs/components'] },
-  { name: 'ui-website', dir: 'packages/ui/packages/website', routes: ['/'] },
+  // The docs (#1098) and the component gallery (#1099) are both served by the
+  // website app, so their routes are probed as part of it. `docs/` and
+  // `packages/ui/packages/website/` are redirect-only hosts with no pages
+  // left, so there is no HTML there to carry a modulepreload at all.
+  //
+  // /ui/button is the gallery's heaviest page: it is the one that pulls the
+  // mirrored kit component sources, which is exactly the class of import a
+  // broken preload would show up in.
+  { name: 'website', dir: 'website', routes: ['/', '/docs/architecture', '/docs/components', '/ui', '/ui/button'] },
 ];
 
 const PRELOAD_RE = /]+rel=["']modulepreload["'][^>]*href=["']([^"']+)["']/g;
diff --git a/test/repo-health/dockerfile-bakes-ui-registry.test.mjs b/test/repo-health/dockerfile-bakes-ui-registry.test.mjs
index 3fef53cfc..bebb51fb2 100644
--- a/test/repo-health/dockerfile-bakes-ui-registry.test.mjs
+++ b/test/repo-health/dockerfile-bakes-ui-registry.test.mjs
@@ -1,19 +1,21 @@
 /**
  * Regression guard for issue #526.
  *
- * The ui-website's component DETAIL pages
- * (`packages/ui/packages/website/app/docs/components/[name]/page.ts`) statically
- * import the component SOURCES from `components/ui/*.ts`. Those files do not
- * exist in a fresh tree: the npm `prestart` hook generates them by copying out
- * of the registry (`scripts/copy-registry.js`).
+ * The gallery's component DETAIL pages (`website/app/ui/[name]/page.ts`)
+ * statically import the component SOURCES from `components/ui/*.ts`. Those
+ * files do not exist in a fresh tree: they are a gitignored mirror of
+ * `packages/ui/packages/registry/`, generated by `scripts/copy-registry.mjs`.
  *
  * The deploy image serves each app with a direct `bun .../webjs.js start`, which
  * BYPASSES npm `prestart`. So the generated `components/ui/` sources have to be
  * baked into the image at BUILD time (exactly like the Tailwind css is), or every
  * component page 500s in production with `Cannot find module
- * '../../../../components/ui/.ts'`. That is what happened on the Bun
- * cutover (#522): the home pages worked, the component pages did not, and a
- * home-route-only smoke missed it.
+ * '#components/ui/.ts'`. That is what happened on the Bun cutover (#522):
+ * the home pages worked, the component pages did not, and a home-route-only
+ * smoke missed it.
+ *
+ * The gallery moved from the ui-website to the marketing site in #1099, so the
+ * bake moved with it; the failure mode it guards against is unchanged.
  *
  * This asserts the Dockerfile bakes the registry copy at build time, so removing
  * that step (or never adding it) fails CI here instead of only at deploy time.
@@ -27,15 +29,15 @@ import { fileURLToPath } from 'node:url';
 const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
 const dockerfile = readFileSync(join(ROOT, 'Dockerfile'), 'utf8');
 
-test('the Dockerfile bakes the ui-website registry components at build time (#526)', () => {
+test('the Dockerfile bakes the ui registry components at build time (#526)', () => {
   // A RUN step must invoke the registry-copy script. Match the script name
   // rather than an exact command so a future `cd`/path tweak does not falsely
   // fail, while a dropped bake step (the actual regression) does.
-  const runsCopyRegistry = /^RUN\b.*copy-registry\.js/m.test(dockerfile);
+  const runsCopyRegistry = /^RUN\b.*copy-registry\.mjs/m.test(dockerfile);
   assert.ok(
     runsCopyRegistry,
-    'Dockerfile must RUN scripts/copy-registry.js so components/ui/*.ts ship in the image; ' +
-      'without it the ui-website component pages 500 when served directly on Bun (bypassing npm prestart, #526).',
+    'Dockerfile must RUN scripts/copy-registry.mjs so components/ui/*.ts ship in the image; ' +
+      'without it the /ui component pages 500 when served directly on Bun (bypassing npm prestart, #526).',
   );
 });
 
@@ -43,6 +45,21 @@ test('the registry-copy script the bake depends on still exists', () => {
   // If the script moves/renames, the Dockerfile RUN above silently no-ops at
   // build (a missing script is a hard `docker build` error, but this catches a
   // rename in the repo before the image build does).
-  const script = join(ROOT, 'packages', 'ui', 'packages', 'website', 'scripts', 'copy-registry.js');
-  assert.ok(existsSync(script), `copy-registry.js must exist at ${script} for the Dockerfile bake to work`);
+  const script = join(ROOT, 'website', 'scripts', 'copy-registry.mjs');
+  assert.ok(existsSync(script), `copy-registry.mjs must exist at ${script} for the Dockerfile bake to work`);
+});
+
+test('the registry copy runs BEFORE the Tailwind build', () => {
+  // Ordering is load-bearing and nothing else catches it. The generated
+  // sources are a scanned `@source` in website/public/input.css, so a
+  // stylesheet compiled before they exist is missing every utility class the
+  // kit's components use: the previews render structurally correct and
+  // completely unstyled, in production only.
+  const copyAt = dockerfile.search(/^RUN\b.*copy-registry\.mjs/m);
+  const tailwindAt = dockerfile.search(/^RUN\b.*tailwindcss/m);
+  assert.ok(copyAt >= 0 && tailwindAt >= 0, 'both build steps are present');
+  assert.ok(
+    copyAt < tailwindAt,
+    'copy-registry.mjs must run before the Tailwind build, or the kit utility classes are missing from the compiled stylesheet',
+  );
 });
diff --git a/test/repo-health/site-seo-tags.test.mjs b/test/repo-health/site-seo-tags.test.mjs
index 0ef86e828..fb499daad 100644
--- a/test/repo-health/site-seo-tags.test.mjs
+++ b/test/repo-health/site-seo-tags.test.mjs
@@ -32,9 +32,15 @@ import { fileURLToPath } from 'node:url';
 
 const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
 
+/**
+ * Only apps that actually SERVE HTML are checked here. `docs.webjs.dev` and,
+ * since #1099, `ui.webjs.dev` are redirect-only hosts: they render no markup
+ * at all, so they have no layout, no icons, and no canonical to assert. The
+ * pages they used to serve now live under `website/` (at /docs and /ui) and
+ * are covered by this app's entry.
+ */
 const APPS = [
-  { name: 'website (webjs.dev, incl. /docs)', dir: 'website' },
-  { name: 'ui website (ui.webjs.dev)', dir: 'packages/ui/packages/website' },
+  { name: 'website (webjs.dev, incl. /docs and /ui)', dir: 'website' },
 ];
 
 const layoutOf = (dir) => readFileSync(resolve(REPO_ROOT, dir, 'app', 'layout.ts'), 'utf8');
diff --git a/test/ui/ui-host-redirect.test.mjs b/test/ui/ui-host-redirect.test.mjs
new file mode 100644
index 000000000..5abab2e1e
--- /dev/null
+++ b/test/ui/ui-host-redirect.test.mjs
@@ -0,0 +1,135 @@
+/**
+ * ui.webjs.dev stays alive as a redirect-only host (#1099).
+ *
+ * The gallery moved to webjs.dev/ui, but this host can never be retired, and
+ * the reason is stronger here than it was for the docs host. `/registry/*` is
+ * a LIVE API: every already-published @webjsdev/ui and @webjsdev/cli fetches
+ * component sources from `https://ui.webjs.dev/registry/.json` when a
+ * user runs `webjs ui add`, and a published version cannot be corrected after
+ * the fact. If these URLs stop resolving, a documented command breaks for
+ * everyone on an older install, permanently.
+ *
+ * A 301 is safe because shipped clients follow it (fetch does by default,
+ * verified against the real 0.3.1 and 0.3.8 tarballs before the move), so
+ * these tests pin the MAPPING rather than the mere presence of a redirect. A
+ * redirect that resolves to the wrong path is the failure mode that looks
+ * healthy from the outside.
+ */
+import test, { before } from 'node:test';
+import assert from 'node:assert/strict';
+import { resolve, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { createRequestHandler } from '@webjsdev/server';
+
+const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
+
+let handle;
+
+before(async () => {
+  const app = await createRequestHandler({
+    appDir: resolve(ROOT, 'packages/ui/packages/website'),
+    dev: false,
+  });
+  await app.warmup?.();
+  handle = (path) => app.handle(new Request('http://localhost' + path));
+});
+
+test('the registry API keeps its exact URL shape, just moved under /ui', async () => {
+  // THE critical assertion of this file. These are the paths burned into
+  // published npm packages. Each must land on the endpoint that answers it,
+  // including the two reserved slugs (`index`, `registry`) the CLI relies on.
+  const cases = [
+    ['/registry', 'https://webjs.dev/ui/registry'],
+    ['/registry/index.json', 'https://webjs.dev/ui/registry/index.json'],
+    ['/registry/button.json', 'https://webjs.dev/ui/registry/button.json'],
+    ['/registry/alert-dialog.json', 'https://webjs.dev/ui/registry/alert-dialog.json'],
+    ['/registry/theme-zinc.json', 'https://webjs.dev/ui/registry/theme-zinc.json'],
+    ['/registry/registry.json', 'https://webjs.dev/ui/registry/registry.json'],
+  ];
+  for (const [from, to] of cases) {
+    const res = await handle(from);
+    assert.equal(res.status, 301, `${from} is a permanent redirect`);
+    assert.equal(res.headers.get('location'), to, `${from} maps to ${to}`);
+  }
+});
+
+test('the registry redirect carries CORS, so a browser consumer can follow it', async () => {
+  // The registry is fetched cross-origin by tooling. A redirect a browser
+  // context cannot follow is as good as a dead endpoint, and the 200 it
+  // replaces did send this header.
+  const res = await handle('/registry/button.json');
+  assert.equal(res.headers.get('access-control-allow-origin'), '*');
+});
+
+test('an asset URL keeps its path instead of moving under /ui', async () => {
+  // The deleted root layout published /public/og.png as its og:image and the
+  // favicons alongside it, so every social card already scraped from this host
+  // points at those URLs. The marketing site serves the same filenames at the
+  // same paths, so they must NOT pick up the /ui prefix the pages do: that
+  // would resolve a live image to a 404 and blank every cached card.
+  for (const path of [
+    '/public/og.png',
+    '/public/favicon-192.png',
+    '/public/favicon.svg',
+    '/public/apple-touch-icon.png',
+    '/favicon.ico',
+  ]) {
+    const res = await handle(path);
+    assert.equal(res.status, 301, `${path} redirects`);
+    assert.equal(res.headers.get('location'), `https://webjs.dev${path}`, `${path} keeps its path`);
+  }
+});
+
+test('a component page maps to its new flat path', async () => {
+  // The old site nested components under /docs/components/; the new one
+  // serves them at /ui/. A path-preserving redirect would have sent
+  // every existing link to /ui/docs/components/, which 404s.
+  for (const name of ['button', 'alert-dialog', 'native-select']) {
+    const res = await handle(`/docs/components/${name}`);
+    assert.equal(res.status, 301);
+    assert.equal(res.headers.get('location'), `https://webjs.dev/ui/${name}`);
+  }
+});
+
+test('a trailing slash on a component page maps the same way', async () => {
+  const res = await handle('/docs/components/button/');
+  assert.equal(res.headers.get('location'), 'https://webjs.dev/ui/button');
+});
+
+test('both old human entry points collapse onto the gallery', async () => {
+  // The old host had a marketing landing page at / and docs at /docs. The new
+  // one has neither: /ui IS the gallery, opening on the introduction.
+  for (const path of ['/', '/docs', '/docs/']) {
+    const res = await handle(path);
+    assert.equal(res.status, 301, `${path} redirects`);
+    assert.equal(res.headers.get('location'), 'https://webjs.dev/ui', `${path} lands on the gallery`);
+  }
+});
+
+test('the redirect is permanent, so ranking signal transfers', async () => {
+  const res = await handle('/docs/components/button');
+  assert.equal(res.status, 301, 'a 302 would keep the signal on the dead host');
+});
+
+test('a query string survives the redirect', async () => {
+  const res = await handle('/docs/components/button?utm_source=x');
+  assert.equal(res.headers.get('location'), 'https://webjs.dev/ui/button?utm_source=x');
+});
+
+test('an unknown path still redirects rather than 404ing', async () => {
+  const res = await handle('/whatever/old/path');
+  assert.equal(res.status, 301);
+  assert.equal(res.headers.get('location'), 'https://webjs.dev/ui/whatever/old/path');
+});
+
+test('the readiness probe is answered locally, so deploys can still gate on it', async () => {
+  // Redirecting /__webjs/ready would fail every healthcheck and the service
+  // would never come up, which is the one way to actually break this host.
+  const res = await handle('/__webjs/ready');
+  assert.ok(res.status < 300, `expected a local 2xx, got ${res.status}`);
+});
+
+test('the rest of the /__webjs namespace is left to the framework, not answered here', async () => {
+  const res = await handle('/__webjs/core/index-browser.js');
+  assert.notEqual(res.status, 301, 'framework paths are not redirected');
+});
diff --git a/website/.env.example b/website/.env.example
index 510bd6a39..968c199d8 100644
--- a/website/.env.example
+++ b/website/.env.example
@@ -3,5 +3,4 @@
 # Canonical localhost dev ports (one source of truth alongside the
 # matching production-domain fallbacks in lib/links.ts). Deployments
 # override these with real public URLs (e.g. via Railway service env).
-UI_URL=http://localhost:5003
 EXAMPLE_BLOG_URL=http://localhost:5004
diff --git a/website/.gitignore b/website/.gitignore
new file mode 100644
index 000000000..1830719fe
--- /dev/null
+++ b/website/.gitignore
@@ -0,0 +1,8 @@
+# Generated by scripts/copy-registry.mjs before dev/start (do not commit).
+# These mirror the canonical @webjsdev/ui sources in
+# packages/ui/packages/registry/, copied in so the /ui gallery pages can
+# import the components for live previews. Hand-written site components
+# and lib modules live directly in components/*.ts and lib/*.ts, which
+# stay tracked; only these generated subdirectories are ignored.
+/components/ui/
+/lib/ui/
diff --git a/website/AGENTS.md b/website/AGENTS.md
index 184e3677a..5ccc4fd7b 100644
--- a/website/AGENTS.md
+++ b/website/AGENTS.md
@@ -35,19 +35,51 @@ website/
     compare/           /compare hub + /compare/[slug]. Reads
                        ../../../compare/*.md. Emits per-page JSON-LD
                        (TechArticle + BreadcrumbList + FAQPage).
-    sitemap.ts         /sitemap.xml (enumerates articles + compare + blog)
+    docs/              /docs/, the reference documentation (#1098 moved
+                       it here from docs.webjs.dev). layout.ts holds the nav
+                       tree + docs-scoped metadata; the shell itself is shared,
+                       see lib/docs-shell.ts.
+    ui/                /ui, the @webjsdev/ui component gallery (#1099 moved it
+                       here from ui.webjs.dev). page.ts is the introduction,
+                       [name]/page.ts one page per component, layout.ts the
+                       sidebar built from the live registry index, and
+                       registry/** the JSON API that shipped CLI versions fetch
+                       (see modules/ui/ below). No landing page: /ui opens on
+                       the introduction, the way /docs opens on Getting Started.
+    sitemap.ts         /sitemap.xml (enumerates docs + ui + articles + compare + blog)
     robots.ts          /robots.txt (allow-all, points at the sitemap)
     llms.txt/route.ts  /llms.txt (llmstxt.org overview for AI agents)
   components/
     theme-toggle.ts    system/light/dark cycle
     copy-cmd.ts        click-to-copy command line (light DOM, always-on button)
+    doc-search.ts      the docs sidebar search field
+    preview-tabs.ts    Preview / Code toggle around a gallery demo
+    ui/                GITIGNORED mirror of the @webjsdev/ui registry sources,
+                       written by scripts/copy-registry.mjs. NEVER hand-write
+                       here: it is wiped every dev cycle and never reaches the
+                       deploy. Hand-written components go in components/ itself.
   lib/
     highlight.ts       SSR syntax highlighter for the code samples
     frontmatter.ts     parse changelog/blog markdown frontmatter
     faq.ts             parse a `## FAQ` markdown section into FAQPage JSON-LD
+    docs-shell.ts      the sidebar + drawer + .prose-docs typography, SHARED by
+                       /docs and /ui so the two sections cannot drift apart
+    docs-llms.server.ts  enumerates the doc pages on disk (sitemap, llms.txt)
+    links.ts           cross-app URLs + in-app paths for the header and footer
+    site-footer.ts     the footer, rendered by the root layout on every page
+    ui/                GITIGNORED, same as components/ui/ (the kit's cn helper)
+  modules/
+    ui/queries/registry.server.ts  composes the registry JSON on demand from
+                       packages/ui/packages/registry/ (the source of truth).
+                       This is what /ui/registry/** serves.
+    ui/utils/          tier classification, per-component examples + API metadata
   scripts/             manual dev tools, NOT part of build/deploy
     fetch-fonts.mjs    download the self-hosted variable woff2 fonts
     generate-og.mjs    regenerate the OG social card (needs playwright + ImageMagick)
+    copy-registry.mjs  mirror the kit sources into components/ui/ + lib/ui/.
+                       Runs via webjs.dev.before / webjs.start.before and is
+                       baked into the deploy image (#526), so a component page
+                       never boots without its imports.
   public/              favicon, og image, self-hosted fonts, static assets
 ```
 
@@ -104,7 +136,7 @@ is split by editorial intent, which is what decides where a piece goes:
 
 The layout (`app/layout.ts`) renders a top-of-page announcement strip
 just above the sticky header: a small utility-class `
` with a "New" -badge and a link (currently the `UI_URL` link, "Introducing the AI-first +badge and a link (currently the `UI_PATH` link, "Introducing the AI-first component library"). To swap the announcement, edit that `
` (its copy and the link `href`). The banner shows on every page. Remove the `
` to hide it. @@ -114,6 +146,22 @@ to hide it. `app/page.ts`: the hero block is at the top of the default-exported function. Edit the inline `

` / `

` text. +## SSR action seeding is OFF for this app + +`package.json` sets `"webjs": { "seed": false }`. Seeding (#472) serializes +every `'use server'` result invoked during SSR into the page so a shipping +async component can skip its refetch on hydration. Nothing here consumes that: +no component on this site 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. + +It was not small. Before turning it off, `/changelog` carried 304KB of seed in +a 1.1MB response and `/ui/dropdown-menu` 35KB in 141KB, roughly a quarter of +each page, duplicating content already in the visible HTML. + +If you add a component here that genuinely wants seeding (an async render +calling an action), re-enable it and delete the assertion in +`test/ssr/ui-gallery.test.ts` that pins this off. + ## Style - Light DOM, Tailwind utilities, `@theme` tokens from the root layout @@ -128,17 +176,22 @@ cd website && npm run dev # http://localhost:5001 ``` `npm run dev` and `webjs dev` behave identically (#550): `webjs.dev.before` -compiles `public/tailwind.css`, and `webjs.dev.regenerate` (#967) recompiles it -on request when a source changes, so the stylesheet never goes stale without a -live watcher. In prod, `npm start` and `webjs start` are equivalent too: -`webjs.start.before` runs `npm run css:build` before serving. - -Set `UI_URL` / `EXAMPLE_BLOG_URL` env vars to point the header links at the -right hosts when deploying. `EXAMPLE_BLOG_URL` is the live example-blog app -surfaced as the "Demo" link. Locally, `.env` in this directory sets them to -the sibling apps' localhost ports. Blog, Changelog, and Docs are in-app -routes, so they need no env var. (Docs used to need one: they were a separate -`docs.webjs.dev` app until #1098 moved them here under `app/docs/`.) +mirrors the kit sources in and compiles `public/tailwind.css`, and +`webjs.dev.regenerate` (#967) re-runs both on request when a source changes, so +neither the mirror nor the stylesheet goes stale without a live watcher. The +mirror has to be refreshed as part of that command rather than only in +`before`, because the generated sources are a scanned `@source`: recompiling +the stylesheet against a stale mirror silently omits the utility classes the +gallery previews need. In prod, `npm start` and `webjs start` are equivalent +too, via `webjs.start.before`. + +Set `EXAMPLE_BLOG_URL` to point the "Demo" link at the live example-blog app +when deploying; locally, `.env` in this directory sets it to the sibling app's +localhost port. Everything else in the nav is an in-app route and needs no env +var: Blog, Changelog, Docs, and the UI gallery. (Docs and the gallery used to +need one each. They were separate `docs.webjs.dev` and `ui.webjs.dev` apps +until #1098 and #1099 moved them here under `app/docs/` and `app/ui/`, so +`DOCS_URL` and `UI_URL` are both gone.) --- diff --git a/website/app/docs/layout.ts b/website/app/docs/layout.ts index 99ab294d9..50aeceb0b 100644 --- a/website/app/docs/layout.ts +++ b/website/app/docs/layout.ts @@ -1,4 +1,5 @@ import { html } from '@webjsdev/core'; +import { docsShell } from '#lib/docs-shell.ts'; import '#components/doc-search.ts'; /** @@ -7,12 +8,17 @@ import '#components/doc-search.ts'; * This is a NON-ROOT layout (invariant 8), so it writes no document shell. * The header, footer, theme toggle, fonts, and design tokens all come from * `app/layout.ts`, exactly as they do on /what-is-webjs and /blog. The - * sidebar below is the ONLY docs-specific chrome, which is the whole point - * of serving the docs from webjs.dev instead of a separate subdomain: one + * sidebar is the ONLY docs-specific chrome, which is the whole point of + * serving the docs from webjs.dev instead of a separate subdomain: one * design system, one set of tokens, no parallel shell to drift. * + * The shell itself (sidebar, mobile drawer, .prose-docs typography) lives in + * lib/docs-shell.ts, shared with the component library at /ui, so the two + * sections cannot drift apart. This file contributes only the docs nav tree + * and the docs-scoped metadata. + * * Doc page bodies are plain HTML with no component wrapper, so their - * typography is styled through the `.prose-docs` rules below rather than + * typography is styled through the shell's `.prose-docs` rules rather than * per-element utility classes across 45 pages. */ const NAV_SECTIONS = [ @@ -69,9 +75,16 @@ const NAV_SECTIONS = [ ], }, { + // The component library is its own section at /ui, with the same shell as + // these pages (see lib/docs-shell.ts). This entry is a cross-link out of + // the docs rather than a doc page: /docs/ui used to hold a second, + // hand-written description of the kit that had drifted badly (it + // advertised roughly 55 components against an actual 32 and showed a + // API the kit does not have), so it is gone and its URL + // permanently redirects to /ui. title: 'Component Library', items: [ - { href: '/docs/ui', label: '@webjsdev/ui (AI-first)' }, + { href: '/ui', label: '@webjsdev/ui (AI-first)' }, ], }, { @@ -140,257 +153,12 @@ export function generateMetadata(ctx: { url: string }) { } export default function DocsLayout({ children }: { children: unknown }) { - return html` - - -

- - -
- -
- -
${children}
-
-
- `; + return docsShell({ + nav: NAV_SECTIONS, + label: 'Documentation', + menuLabel: 'Documentation menu', + asideTop: html``, + contentClass: 'prose-docs', + children, + }); } diff --git a/website/app/docs/ui/page.ts b/website/app/docs/ui/page.ts deleted file mode 100644 index ee459287b..000000000 --- a/website/app/docs/ui/page.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { html } from '@webjsdev/core'; - -export const metadata = { title: '@webjsdev/ui: AI-first component library' }; - -export default function UiDocs() { - return html` -

@webjsdev/ui

-

- An AI-first component library with two-tier composition: pure class-helper - functions (buttonClass, cardClass, inputClass) for - visual primitives, plus a small set of stateful custom elements - (<ui-dialog>, <ui-tabs>, <ui-dropdown-menu>, etc.) - where state matters. Source-copied into your project so you own the code and edit it freely. - Variant names, sizes, and data attributes mirror shadcn so existing shadcn knowledge maps - directly. Works in any project with Tailwind v4 and the small @webjsdev/core - runtime: webjs, Next, Astro, Vite, SvelteKit, Lit, vanilla HTML. -

- -

For WebJs users

-

- Nothing to install. @webjsdev/ui is a hard dependency of @webjsdev/cli, so a global - WebJs install already includes it, and webjs ui add resolves the kit from there. A - scaffolded app does NOT pin @webjsdev/ui: webjs ui add copies the component - source into components/ui/ (those files import @webjsdev/core, not the kit). -

-
webjs ui init
-webjs ui add button card dialog input label
- -

For everyone else (Next, Astro, Vite, SvelteKit, Lit, vanilla, …)

-

Two npm installs (the CLI and the runtime base class), then run the CLI:

-
npm install -D @webjsdev/ui
-npm install @webjsdev/core
-npx webjsui init
-npx webjsui add button card dialog
-

- The webjsui binary is standalone. It does NOT require @webjsdev/cli. - It auto-detects your project type (Next / Astro / Vite / Lit / plain) and picks sensible defaults. -

- -

Commands

- - - - - - - - - - -
CommandWhat it does
initWrites components.json, copies lib/utils.ts, installs the theme tokens (exits non-zero if they cannot be written)
add <names...>Copy components in, install needed deps, self-heal the theme tokens (Tier-1 files keep the helpers plus a pointer, not the worked example)
listList all components in the registry
view <name>Print a component's projected view (helpers plus the paste-ready example) and full source
diff [name]Show differences between your local copy and the live registry
infoProject diagnostics
-

- Resolution is local-first: init / add / list / view - read the registry that ships inside the installed @webjsdev/ui package, so they need no - network. A Tier-1 component's worked structural example is served on demand by - webjsui view <name> (and the read-only MCP ui tool), not copied into your file. -

- -

Usage

-

Every component is a standards-compliant custom element. Tag convention: single ui- prefix, sub-components hyphenated.

-
<ui-card>
-  <ui-card-header>
-    <ui-card-title>Hello</ui-card-title>
-    <ui-card-description>A web component card.</ui-card-description>
-  </ui-card-header>
-  <ui-card-content>
-    <ui-input placeholder="Type here..." />
-  </ui-card-content>
-  <ui-card-footer>
-    <ui-button variant="default">Save</ui-button>
-  </ui-card-footer>
-</ui-card>
- -

Migrating from shadcn-react

-

Translation is mechanical: <Button><ui-button>, <DialogContent><ui-dialog-content>. Variant and size props match exactly. Components project children via DOM nesting. There is no asChild / Radix Slot pattern, so wrap an element directly instead.

- -

What's in the registry?

-

~55 components matching shadcn's new-york-v4 style:

-

- accordion, alert, alert-dialog, aspect-ratio, avatar, badge, breadcrumb, button, button-group, - calendar, card, carousel, chart, checkbox, collapsible, combobox, command, context-menu, - dialog, direction, drawer, dropdown-menu, empty, field, form, hover-card, input, input-group, - input-otp, item, kbd, label, menubar, native-select, navigation-menu, pagination, popover, - progress, radio-group, resizable, scroll-area, select, separator, sheet, sidebar, skeleton, - slider, sonner, spinner, switch, table, tabs, textarea, toggle, toggle-group, tooltip. -

- -

Why web components?

-

Standards. Custom elements work in every framework that supports them: webjs, Next.js, Astro, Vite, Remix, SvelteKit, Nuxt, SolidStart, Lit projects, and plain HTML. One library, every host.

- -

For AI agents

-

- The whole point is rapid scaffolding. Instead of generating 200 lines of Tailwind for a login form, - an agent can call webjs ui add button card input label and compose 15 lines of tag soup. - Visual consistency comes for free; the agent makes zero design decisions. -

- `; -} diff --git a/website/app/layout.ts b/website/app/layout.ts index 2e6c15340..2aa8e9a04 100644 --- a/website/app/layout.ts +++ b/website/app/layout.ts @@ -1,6 +1,6 @@ import { html, cspNonce } from '@webjsdev/core'; import '#components/theme-toggle.ts'; -import { DOCS_START_PATH, UI_URL, EXAMPLE_BLOG_URL, GH_URL, NEW_TAB } from '#lib/links.ts'; +import { DOCS_START_PATH, UI_PATH, EXAMPLE_BLOG_URL, GH_URL, NEW_TAB } from '#lib/links.ts'; import { siteFooter } from '#lib/site-footer.ts'; /** @@ -13,7 +13,7 @@ import { siteFooter } from '#lib/site-footer.ts'; * clamp, the fixed static glow layer, the hover-only scrollbar (`.scroll-thin`), * and the
icon swap. Everything else is Tailwind. * - * Shared link config (DOCS_START_PATH / UI_URL / EXAMPLE_BLOG_URL / GH_URL / NEW_TAB) lives in + * Shared link config (DOCS_START_PATH / UI_PATH / EXAMPLE_BLOG_URL / GH_URL / NEW_TAB) lives in * lib/links.ts, imported here and by app/page.ts. */ @@ -22,7 +22,7 @@ const DESCRIPTION = 'An AI-first full-stack web framework built on web component const NAV = [ { label: 'Docs', href: DOCS_START_PATH, ext: false }, - { label: 'UI', href: UI_URL, ext: true }, + { label: 'UI', href: UI_PATH, ext: false }, { label: 'Demo', href: EXAMPLE_BLOG_URL, ext: true }, { label: 'Blog', href: '/blog', ext: false }, { label: 'Compare', href: '/compare', ext: false }, @@ -150,28 +150,78 @@ export default function RootLayout({ children }: { children: unknown }) { if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', measure); else measure(); })(); - // The docs drawer's open state is an attribute on , which is - // OUTSIDE every swap range, so a soft navigation carries it along: - // it locks scrolling on whatever page you land on, and re-opens the - // drawer over the next docs page you visit. Clearing it here, in the - // root layout, is what makes that impossible, because this listener - // survives navigation while anything the docs sub-layout registers - // would be swapped away with it. - function closeDocsNav() { document.body.removeAttribute('data-docs-nav-open'); } + // The sidebar drawer shared by /docs and /ui (lib/docs-shell.ts) is + // driven entirely from here, and both halves of that are deliberate. + // + // Its open state is an attribute on , which is OUTSIDE every swap + // range, so a soft navigation carries it along: it locks scrolling on + // whatever page you land on, and re-opens the drawer over the next page + // you visit. Clearing it in the ROOT layout is what makes that + // impossible, because this listener survives navigation while anything + // the sub-layout registered would be swapped away with it. + // + // The open/close handlers live here too, rather than as inline onclick + // attributes in the shell. That keeps the shell module inert at load so + // the page/layout modules importing it can be elided, and it puts every + // close path through one function, so the toggle's aria-expanded cannot + // drift out of step with the attribute (dismissing via the backdrop used + // to leave the button advertising an expanded drawer indefinitely). + function syncDocsNav() { + var open = document.body.hasAttribute('data-docs-nav-open'); + var btn = document.querySelector('.docs-nav-toggle'); + if (btn) btn.setAttribute('aria-expanded', String(open)); + return open; + } + function closeDocsNav() { + document.body.removeAttribute('data-docs-nav-open'); + syncDocsNav(); + } window.addEventListener('popstate', closeDocsNav); document.addEventListener('webjs:navigate', closeDocsNav); document.addEventListener('click', function (e) { var t = e.target; if (!t || !t.closest) return; - if (t.closest('a')) closeDocsNav(); + + // Header mobile menu FIRST, and unconditionally, because the drawer + // toggle is a click OUTSIDE that menu and every other outside click + // dismisses it. Handling it after an early return for the toggle left + // both navigation surfaces open at once, which is neither what the + // outside-click rule promises nor what a reader expects from a menu + // they just clicked away from. var a = t.closest('.mobile-menu a'); - if (a) { var d = a.closest('details'); if (d) d.removeAttribute('open'); return; } - var open = document.querySelectorAll('.mobile-menu[open]'); - for (var i = 0; i < open.length; i++) if (!open[i].contains(t)) open[i].removeAttribute('open'); + if (a) { + var d = a.closest('details'); + if (d) d.removeAttribute('open'); + } else { + var open = document.querySelectorAll('.mobile-menu[open]'); + // A click INSIDE an open menu (its summary) is left alone, so the + // details element toggles natively. + for (var i = 0; i < open.length; i++) if (!open[i].contains(t)) open[i].removeAttribute('open'); + } + + // Then the sidebar drawer. + if (t.closest('.docs-nav-toggle')) { + document.body.toggleAttribute('data-docs-nav-open'); + syncDocsNav(); + return; + } + // Any link, and the backdrop, dismiss it. The backdrop needs no + // navigation to be clicked, which is why it cannot rely on the link. + if (t.closest('a') || t.closest('.docs-backdrop')) closeDocsNav(); }); document.addEventListener('keydown', function (e) { if (e.key !== 'Escape') return; + // The drawer wins when both it and the header menu are open, and + // returns, so focus lands on the control that opened THIS one. Without + // the return both closed on one Escape and the header summary took + // focus, which is not where the reader was. + if (document.body.hasAttribute('data-docs-nav-open')) { + closeDocsNav(); + var btn = document.querySelector('.docs-nav-toggle'); + if (btn) btn.focus(); + return; + } var open = document.querySelectorAll('.mobile-menu[open]'); for (var i = 0; i < open.length; i++) { open[i].removeAttribute('open'); diff --git a/website/app/llms.txt/route.ts b/website/app/llms.txt/route.ts index d93e574be..d932393ac 100644 --- a/website/app/llms.txt/route.ts +++ b/website/app/llms.txt/route.ts @@ -2,7 +2,9 @@ import { listComparisons } from '#modules/compare/queries/list-comparisons.serve import { listArticles } from '#modules/articles/queries/list-articles.server.ts'; import { listPosts } from '#modules/blog/queries/list-posts.server.ts'; import { renderDocsIndexSection } from '#lib/docs-llms.server.ts'; -import { UI_URL, EXAMPLE_BLOG_URL, GH_URL } from '#lib/links.ts'; +import { loadRegistryIndex } from '#modules/ui/queries/registry.server.ts'; +import { splitByTier } from '#modules/ui/utils/tier.ts'; +import { UI_PATH, EXAMPLE_BLOG_URL, GH_URL } from '#lib/links.ts'; /** * GET /llms.txt @@ -36,11 +38,12 @@ function section(title: string, items: string[]): string[] { } export async function GET(): Promise { - const [comparisons, articles, posts, docLinks] = await Promise.all([ + const [comparisons, articles, posts, docLinks, registry] = await Promise.all([ listComparisons(), listArticles(), listPosts(), renderDocsIndexSection(SITE_URL), + loadRegistryIndex(), ]); // Blog is capped so the file stays a concise index rather than a full @@ -81,9 +84,27 @@ export async function GET(): Promise { // HTML it has to strip. lines.push(...section('Documentation', docLinks)); + // Every component, enumerated the same way the doc pages are. Without this + // an agent entering through llms.txt could not discover a single component + // page, while the sitemap advertised all of them, and the whole point of an + // AI-first kit is that an agent can find the piece it needs. Split by tier + // because the tier decides HOW you use one: a class helper on a native + // element, or a custom element tag. + const uiComponents = registry.filter((i) => i.type === 'registry:ui'); + const { tier1, tier2 } = splitByTier(uiComponents); + const uiLink = (i: { name: string }) => `- [${i.name}](${SITE_URL}${UI_PATH}/${i.name})`; + lines.push(...section( + `UI components, Tier 1 (class helpers on native elements)`, + tier1.map(uiLink), + )); + lines.push(...section( + `UI components, Tier 2 (stateful custom elements)`, + tier2.map(uiLink), + )); + lines.push(...section('Project', [ `- [GitHub repository](${GH_URL}): source, issues, and the framework monorepo (plain JS with JSDoc, so what you read is what runs)`, - `- [UI component library](${UI_URL}): the AI-first web-component kit (\`webjs ui add\`)`, + `- [UI component library](${SITE_URL}${UI_PATH}): the AI-first web-component kit (\`webjs ui add\`)`, `- [Live demo](${EXAMPLE_BLOG_URL}): a real WebJs app (the example blog)`, `- [Changelog](${SITE_URL}/changelog): the unified per-package release feed`, ])); diff --git a/website/app/sitemap.ts b/website/app/sitemap.ts index 3ea558704..a612e9643 100644 --- a/website/app/sitemap.ts +++ b/website/app/sitemap.ts @@ -3,6 +3,7 @@ import { listComparisons } from '#modules/compare/queries/list-comparisons.serve import { listArticles } from '#modules/articles/queries/list-articles.server.ts'; import { listPosts } from '#modules/blog/queries/list-posts.server.ts'; import { getDocPages } from '#lib/docs-llms.server.ts'; +import { loadRegistryIndex } from '#modules/ui/queries/registry.server.ts'; /** * /sitemap.xml @@ -23,11 +24,12 @@ import { getDocPages } from '#lib/docs-llms.server.ts'; const SITE_URL = ((globalThis as any).process?.env?.SITE_URL || 'https://webjs.dev').replace(/\/$/, ''); export default async function Sitemap() { - const [comparisons, articles, posts, docPages] = await Promise.all([ + const [comparisons, articles, posts, docPages, registry] = await Promise.all([ listComparisons(), listArticles(), listPosts(), getDocPages(), + loadRegistryIndex(), ]); // `/what-is-webjs` is a primary page, not a hub, so it carries a priority @@ -36,7 +38,7 @@ export default async function Sitemap() { // that happen to share the name. const PRIORITY: Record = { '/': 1.0, '/what-is-webjs': 0.9 }; - const staticRoutes = ['/', '/what-is-webjs', '/blog', '/articles', '/compare', '/why-webjs', '/changelog'].map((path) => ({ + const staticRoutes = ['/', '/what-is-webjs', '/blog', '/articles', '/compare', '/why-webjs', '/changelog', '/ui'].map((path) => ({ url: `${SITE_URL}${path}`, changeFrequency: 'weekly' as const, priority: PRIORITY[path] ?? 0.7, @@ -51,6 +53,22 @@ export default async function Sitemap() { priority: p.slug === 'getting-started' ? 0.9 : 0.8, })); + // One entry per component in the gallery, enumerated from the live registry + // index rather than a hardcoded list, so a component added to + // packages/ui/packages/registry is crawlable the moment it exists. Only + // `registry:ui` items have a page; themes and lib items are registry + // artifacts with nothing to render (and /ui/ deliberately 404s). + // + // ui.webjs.dev served no sitemap at all, which is a large part of why moving + // the gallery was cheap: there was almost nothing indexed to lose. + const uiRoutes = registry + .filter((item) => item.type === 'registry:ui') + .map((item) => ({ + url: `${SITE_URL}/ui/${item.name}`, + changeFrequency: 'monthly' as const, + priority: 0.7, + })); + const compareRoutes = comparisons.map((c) => ({ url: `${SITE_URL}/compare/${c.slug}`, lastModified: c.date || undefined, @@ -72,5 +90,5 @@ export default async function Sitemap() { priority: 0.6, })); - return sitemap([...staticRoutes, ...docRoutes, ...compareRoutes, ...articleRoutes, ...blogRoutes]); + return sitemap([...staticRoutes, ...docRoutes, ...uiRoutes, ...compareRoutes, ...articleRoutes, ...blogRoutes]); } diff --git a/website/app/ui/[name]/page.ts b/website/app/ui/[name]/page.ts new file mode 100644 index 000000000..42c9d7594 --- /dev/null +++ b/website/app/ui/[name]/page.ts @@ -0,0 +1,444 @@ +import { html, unsafeHTML, notFound } from '@webjsdev/core'; +import { + getExample, + getVariantExamples, + getSizeExamples, + getIconSizeExamples, + renderExample, + codeExample, +} from '#modules/ui/utils/examples.ts'; +import { getComponentApi, type ComponentApi } from '#modules/ui/utils/component-api.ts'; +import { loadRegistryItem } from '#modules/ui/queries/registry.server.ts'; +import { tierOf } from '#modules/ui/utils/tier.ts'; +// Side-effect import: register so every preview pane below can +// flip between the live demo and its source snippet. +import '#components/preview-tabs.ts'; + +// --------------------------------------------------------------------------- +// Side-effect imports: the TIER-2 component modules, so their custom elements +// register and a preview containing a tag upgrades in the browser. The +// modules are mirrored from packages/ui/packages/registry into components/ui/ +// by scripts/copy-registry.mjs (run by webjs.dev.before / webjs.start.before +// and baked into the deploy image), so the source the server renders is the +// same source the browser is served. +// +// Only Tier 2 is imported here, and that is load-bearing rather than tidiness. +// A Tier-1 file exports class-helper functions and registers no element, so a +// bare side-effect import of one is a client-effecting NON-component import, +// which pins this whole page module into the browser bundle (AGENTS.md's +// execution model, and #963 on the path-aware verdict). The helpers are still +// imported BY NAME where they are used, in modules/ui/utils/examples.ts, which +// is what evaluates them during SSR. Importing all 32 here shipped the page, +// its snippet map, and its API metadata to every reader for nothing. +// --------------------------------------------------------------------------- +import '#components/ui/alert-dialog.ts'; +import '#components/ui/dialog.ts'; +import '#components/ui/dropdown-menu.ts'; +import '#components/ui/hover-card.ts'; +import '#components/ui/sonner.ts'; +import '#components/ui/tabs.ts'; +import '#components/ui/toggle.ts'; +import '#components/ui/toggle-group.ts'; +import '#components/ui/tooltip.ts'; + +/** + * Per-component metadata. + * + * Titles follow the documentation's shape, " |
", so a browser + * tab or a search result reads the same whether it came from /docs or here. + * The registry name is the lowercase identifier you pass to the add command, + * so it is title-cased for the human-facing title only. + * + * Each page describes ITSELF rather than inheriting one section description + * from the layout for all 33 URLs. A set of byte-identical descriptions across + * a section is exactly the duplicate-content shape this migration exists to + * avoid, and it would have been self-defeating to introduce 33 of them in a + * change whose stated purpose is search consolidation. + * + * The sentence is DERIVED rather than hand-written. The registry carries no + * per-item description (checked: every `registry:ui` item omits it), so a + * hand-written map would be 33 more strings to drift out of sync with the kit. + * Tier plus the naming convention is enough to say something true and distinct + * about every component, and it stays correct when one moves between tiers. + */ +export async function generateMetadata({ params }: { params: { name: string } }) { + const name = startCase(params.name); + const title = `${name} | WebJs UI`; + let description: string; + + if (tierOf({ name: params.name }) === 'tier-2') { + description = `${name} is a stateful custom element in WebJs UI. Compose it as a real ${''} tag, which wires its own ARIA and keyboard handling, then copy the source into your project and own it.`; + } else { + const item = await loadRegistryItem(params.name); + const helper = primaryHelper(params.name, item?.files?.[0]?.content ?? ''); + description = helper + ? `${name} is a class helper in WebJs UI. Apply ${helper}() to a native HTML element for full browser semantics, styled with Tailwind v4, with the source copied into your project.` + : `${name} is a class helper in WebJs UI. Apply it to a native HTML element for full browser semantics, styled with Tailwind v4, with the source copied into your project.`; + } + return { title, description, openGraph: { title, description } }; +} + +/** + * The helper a reader reaches for first, read out of the component's own + * source rather than guessed from its name. + * + * The convention (`alert-dialog` to `alertDialogClass`) holds for most of the + * kit but not all of it, and a description naming a function that does not + * exist is worse than a vaguer one. `breadcrumb` exports `breadcrumbListClass`, + * `popover` exports `popoverContentClass`, and `switch` exports + * `switchInputClass` and `switchTrackClass`, with no bare `xClass` in any of + * the three. Preferring the exact conventional name and falling back to the + * first exported helper keeps every description true, and true automatically + * when the kit adds or renames one. + */ +function primaryHelper(name: string, source: string): string | null { + const exported = [...source.matchAll(/export\s+(?:function|const)\s+([a-zA-Z0-9_$]*Class)\b/g)].map((m) => m[1]); + if (!exported.length) return null; + const conventional = name.replace(/-([a-z])/g, (_, c) => c.toUpperCase()) + 'Class'; + return exported.includes(conventional) ? conventional : exported[0]; +} + +/** + * One preview pane around an example snippet. + * + * `.ui-preview` is what makes the demo render in the KIT's neutral palette + * rather than this site's warm editorial one (the raw shadcn variables are + * declared on that class in public/input.css). A preview has to look like the + * reader's own app, not like the page around it. + * + * Light DOM plus `unsafeHTML` is required because ui-* custom elements capture + * their innerHTML in connectedCallback, which does not run during SSR; + * deferring to the browser lets the upgrade fire correctly. + * + * flex-wrap so multi-value combined panes (all 8 button sizes, all 6 button + * variants) lay out in a row and wrap when narrow. + */ +function previewPane(snippet: string, opts: { minH?: string } = {}) { + const minH = opts.minH ?? '160px'; + return html` +
+ ${unsafeHTML(snippet)} +
+ `; +} + +// Strip the shared leading indentation off a snippet so the Code view reads +// like hand-written markup rather than template-literal-indented source. +function dedent(snippet: string): string { + const lines = snippet.replace(/^\n+/, '').replace(/\s+$/, '').split('\n'); + const widths = lines + .filter((l) => l.trim().length > 0) + .map((l) => (l.match(/^[ \t]*/)?.[0].length ?? 0)); + const trim = widths.length ? Math.min(...widths) : 0; + return lines.map((l) => l.slice(trim)).join('\n'); +} + +/** + * The "Code" side of a preview: the idiomatic snippet that composes the demo, + * escaped (text interpolation, not unsafeHTML) so the markup shows as source. + * + * Wrapped in `.prose-docs` on purpose. That is the site's one code surface + * (declared in lib/docs-shell.ts) and the scope the client highlighter reads, + * so a snippet here gets the same card and the same token colors as one in the + * documentation, for free. `.ui-code` drops the trailing block margin the + * prose rules add, since a tab pane is not a paragraph flow. + */ +function codePane(code: string) { + return html`
${dedent(code)}
`; +} + +// Wraps a live preview and its source in so the reader can flip +// between them. Both sides derive from ONE authored snippet: renderExample() +// evaluates the frozen class-helper holes for the live demo, codeExample() +// keeps them as calls so the Code tab teaches the idiomatic helper usage. +function previewWithCode(snippet: string, opts: { minH?: string } = {}) { + return html` + +
${previewPane(renderExample(snippet), opts)}
+
${codePane(codeExample(snippet))}
+
+ `; +} + +// Concatenate all defined values from a variant/size example map into one +// snippet for the combined preview pane. Skips keys missing from the +// examples map so a stale metadata entry doesn't blank the pane. +function combineExamples(keys: string[], examples: Record): string { + return keys + .map((k) => examples[k]) + .filter((s): s is string => typeof s === 'string' && s.length > 0) + .join('\n'); +} + +function startCase(s: string): string { + return s.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); +} + +const SECTION_HEADING = 'text-base font-medium mb-3 text-fg-muted'; + +// Render a value-keyed preview section in one of two modes. +// 'combined': one preview pane with every value rendered side by side. +// 'cards': one preview pane per value, each with a heading above. +// The cards mode is for cases where the example markup is identical +// across values and only the visual style differs (e.g. tabs default vs +// underline both render the same Account/Password tabs). +function renderValueSection( + heading: string, + keys: string[], + examples: Record, + mode: 'combined' | 'cards' = 'combined', +) { + if (mode === 'cards') { + return html` +
+

${heading}

+
+ ${keys.map((k) => + examples[k] + ? html` +
+

${startCase(k)}

+ ${previewWithCode(examples[k])} +
+ ` + : '', + )} +
+
+ `; + } + return html` +
+

${heading}

+ ${previewWithCode(combineExamples(keys, examples))} +
+ `; +} + +const TABLE_WRAP = 'rounded-lg border border-border overflow-hidden'; +const TH = 'px-3 py-2 font-medium text-fg-muted'; +const TD = 'px-3 py-2 align-top'; +const TD_MUTED = 'px-3 py-2 align-top text-fg-muted'; +const ROW = 'border-t border-border'; + +/** + * One component's page. + * + * The heading is title-cased for reading, while the registry identifier stays + * lowercase everywhere it is a value you type (the Installation command, the + * source path, the sidebar links). There is no back link to the index: the + * sidebar lists every component on every page, so a dedicated "up" control + * would be a second way to do what the nav already does. + */ +export default async function ComponentDoc({ params }: { params: { name: string } }) { + const item = await loadRegistryItem(params.name); + // Only components are addressable here. The registry also holds themes and + // lib items, which have no page to render and would otherwise 200 on an + // empty shell. + if (!item || item.type !== 'registry:ui') throw notFound(); + + const source = item.files?.[0]?.content || ''; + const npmDeps = (item.dependencies || []).filter((d: string) => d !== '@webjsdev/core'); + const registryDeps = item.registryDependencies || []; + const example = getExample(params.name); + const api: ComponentApi | null = getComponentApi(params.name); + const variantExamples = getVariantExamples(params.name); + const sizeExamples = getSizeExamples(params.name); + const iconSizeExamples = getIconSizeExamples(params.name); + + return html` + + +
+

${startCase(item.name)}

+ ${item.description ? html`

${item.description}

` : ''} +
+ ${item.type.replace('registry:', '')} + ${registryDeps.map((d: string) => html`↳ ${d}`)} + ${npmDeps.map((d: string) => html`${d}`)} +
+
+ + ${ + example + ? html` +
+

Preview

+ ${previewWithCode(example, { minH: '280px' })} +
+ ` + : html` +
+
+ No live preview available for this component yet. The source code below shows + the full implementation; webjs ui add ${item.name} copies it into your project. +
+
+ ` + } + +
+

Installation

+
webjs ui add ${item.name}
+
+ + ${ + api?.variants && variantExamples && !api.hideVariantsSection + ? renderValueSection( + api.variantsLabel ?? 'Variants', + api.variants, + variantExamples, + api.variantsPreviewMode ?? 'combined', + ) + : '' + } + + ${ + api?.sizes && sizeExamples && !api.hideSizesSection + ? renderValueSection( + api.sizesLabel ?? 'Sizes', + api.sizes, + sizeExamples, + api.sizesPreviewMode ?? 'combined', + ) + : '' + } + + ${ + api?.iconSizes && iconSizeExamples + ? renderValueSection(api.iconSizesLabel ?? 'Icon', api.iconSizes, iconSizeExamples, 'combined') + : '' + } + + ${ + api && (api.props?.length || api.subcomponents?.length || api.events?.length) + ? html` +
+

API Reference

+ + ${ + api.subcomponents?.length + ? html` +
+

Parts

+
+ + + + + + + + + ${api.subcomponents.map( + (p) => html` + + + + + `, + )} + +
NameDescription
${p.name}${p.description}
+
+
+ ` + : '' + } + + ${ + api.props?.length + ? html` +
+

Props

+
+ + + + + + + ${api.props.some((p) => p.description) + ? html`` + : ''} + + + + ${api.props.map( + (p) => html` + + + + + ${api.props!.some((q) => q.description) + ? html`` + : ''} + + `, + )} + +
PropTypeDefaultDescription
${p.name}${p.type}${p.default ?? ''}${p.description ?? ''}
+
+
+ ` + : '' + } + + ${ + api.events?.length + ? html` +
+

Events

+
+ + + + + + + + + + ${api.events.map( + (e) => html` + + + + + + `, + )} + +
NameDetailDescription
${e.name}${e.detail ?? ''}${e.description ?? ''}
+
+
+ ` + : '' + } +
+ ` + : '' + } + +
+

Source: components/ui/${item.name}.ts

+ +
${source}
+
+ `; +} diff --git a/website/app/ui/layout.ts b/website/app/ui/layout.ts new file mode 100644 index 000000000..a5d7e9ed2 --- /dev/null +++ b/website/app/ui/layout.ts @@ -0,0 +1,89 @@ +import { docsShell } from '#lib/docs-shell.ts'; +import { loadRegistryIndex } from '#modules/ui/queries/registry.server.ts'; +import { splitByTier } from '#modules/ui/utils/tier.ts'; + +/** + * Component-library sub-layout: the same shell as /docs (lib/docs-shell.ts), + * with the component list in the sidebar instead of the docs page tree. + * + * This is a NON-ROOT layout (invariant 8), so it writes no document shell. + * The header, footer, theme toggle, fonts, and design tokens all come from + * app/layout.ts, exactly as they do on /docs and /what-is-webjs. That is the + * point of serving the gallery from webjs.dev instead of ui.webjs.dev: one + * design system, one set of tokens, no parallel shell to drift. + * + * The sidebar is generated from the registry index at request time so adding + * a new component to the registry shows up here automatically. Section + * headers are deliberately just "Tier 1" / "Tier 2" (the intro page explains + * what the tiers mean; the sidebar only needs to group). + * + * There is no landing page: /ui opens straight onto this shell with the + * introduction in the content column, the way /docs opens on Getting + * Started. + */ + +const UI_DESCRIPTION = + 'The AI-first component library for WebJs: 32 primitives in two tiers, class-helper functions for visuals and custom elements only where state matters, source-copied into your project and styled with Tailwind v4.'; +const UI_OG_TITLE = 'WebJs UI components'; + +/** + * UI-scoped metadata, merged over the root layout's for every page under + * /ui. Same shallow-merge caveat as the docs layout: `openGraph` and + * `twitter` REPLACE the root's objects, so every field is restated here. + * Keep these in step with app/layout.ts. + */ +export function generateMetadata(ctx: { url: string }) { + const { origin, pathname } = new URL(ctx.url); + const image = `${origin}/public/og.png`; + return { + description: UI_DESCRIPTION, + openGraph: { + type: 'article', + title: UI_OG_TITLE, + description: UI_DESCRIPTION, + url: origin + pathname, + image, + 'image:width': '1200', + 'image:height': '630', + 'image:alt': UI_OG_TITLE, + 'site_name': 'WebJs', + }, + twitter: { + card: 'summary_large_image', + title: UI_OG_TITLE, + description: UI_DESCRIPTION, + image, + }, + }; +} + +export default async function UiLayout({ children }: { children: unknown }) { + const all = await loadRegistryIndex(); + const { tier1, tier2 } = splitByTier(all.filter((i) => i.type === 'registry:ui')); + + return docsShell({ + nav: [ + { + title: 'Getting Started', + items: [{ href: '/ui', label: 'Introduction' }], + }, + { + title: 'Tier 1', + count: tier1.length, + items: tier1.map((c) => ({ href: `/ui/${c.name}`, label: c.name })), + }, + { + title: 'Tier 2', + count: tier2.length, + items: tier2.map((c) => ({ href: `/ui/${c.name}`, label: c.name })), + }, + ], + label: 'Component library', + menuLabel: 'Components menu', + // No prose wrapper: the component pages carry live previews that the + // unlayered .prose-docs rules would restyle (they win over Tailwind + // utilities). Pages wrap their genuinely prose regions themselves. + contentClass: '', + children, + }); +} diff --git a/website/app/ui/page.ts b/website/app/ui/page.ts new file mode 100644 index 000000000..afa168630 --- /dev/null +++ b/website/app/ui/page.ts @@ -0,0 +1,169 @@ +import { html } from '@webjsdev/core'; +import { loadRegistryIndex } from '#modules/ui/queries/registry.server.ts'; +import { splitByTier } from '#modules/ui/utils/tier.ts'; + +// Same " |
" shape the documentation uses, so a tab or a search +// result reads consistently across /docs and /ui. +export const metadata = { + title: 'WebJs UI | AI-first components for WebJs', +}; + +/** + * The component library's introduction, served at /ui. + * + * There is no separate landing page. /ui opens straight into the gallery + * shell with this page in the content column, the way /docs opens on Getting + * Started, so a reader arrives at the components rather than at a pitch for + * them. + * + * Counts come from the live registry index rather than prose, because the + * hand-written ones drifted badly on the page this replaces: it advertised + * "~55 components" against an actual 32, listed two dozen that had been + * removed, and showed a / API the kit does not have. + */ +export default async function UiIntro() { + const all = await loadRegistryIndex(); + const { tier1, tier2 } = splitByTier(all.filter((i) => i.type === 'registry:ui')); + const total = tier1.length + tier2.length; + + return html` +
+

WebJs UI

+

+ An AI-first component library of ${total} primitives, built on native HTML and styled with + Tailwind v4. The source is copied into your project, so you own every line and edit it + freely. Variant names, sizes, and the data-state conventions mirror shadcn, + so an agent trained on shadcn maps its knowledge across directly. +

+

+ It is built for WebJs apps and styled with Tailwind v4. Tier-1 helpers are plain functions + returning class strings, and Tier-2 elements extend WebComponent from + @webjsdev/core, so the kit assumes the runtime a WebJs app already has. +

+ +

Two tiers, one mental model

+

+ Tier 1 (${tier1.length} components) is pure class-helper functions. + buttonClass(), cardClass(), and inputClass() return + Tailwind class strings that you apply to a real element, so a button is a real + <button> and a checkbox is a real <input>. Form + submission, autofill, browser validation, and screen readers all work natively, never + proxied. A few wrap a platform primitive instead of a plain element: accordion and + collapsible sit on <details>, progress on + <progress value max>, popover on the popover attribute. +

+
import { cardClass, cardHeaderClass, cardTitleClass, cardContentClass } from '#components/ui/card.ts';
+import { buttonClass } from '#components/ui/button.ts';
+
+<div class=\${cardClass()}>
+  <div class=\${cardHeaderClass()}>
+    <h3 class=\${cardTitleClass()}>Hello</h3>
+  </div>
+  <div class=\${cardContentClass()}>
+    <button class=\${buttonClass({ variant: 'default' })}>Click me</button>
+  </div>
+</div>
+ +

+ Tier 2 (${tier2.length} components) is a small set of stateful custom + elements, for the behaviour the platform still does not ship: dialogs, tabs, menus, + tooltips, hover cards, toggle groups, and toasts. Each one wraps the closest primitive it + can (<ui-dialog> drives a native <dialog>, tooltip + and hover-card use popover="manual") and adds only the open-state tracking, + focus trap, or queue on top. +

+
<ui-dialog>
+  <ui-dialog-trigger>
+    <button class=\${buttonClass({ variant: 'outline' })}>Edit profile</button>
+  </ui-dialog-trigger>
+  <ui-dialog-content>
+    <h2 data-slot="dialog-title" class=\${dialogTitleClass()}>Edit profile</h2>
+    <form action="/profile" method="post">…</form>
+  </ui-dialog-content>
+</ui-dialog>
+

+ Reach for Tier 1 by default. Reach for Tier 2 only when the browser does not ship the + behaviour natively. +

+ +

Install

+

+ In a WebJs app there is nothing to install. @webjsdev/ui is a hard dependency + of @webjsdev/cli, so a global WebJs install already carries it. A scaffolded + app does not pin the kit either: webjs ui add copies component source into + components/ui/, and those files import @webjsdev/core rather + than the kit. +

+
webjs ui init
+webjs ui add button card dialog input label
+

+ init writes components.json, copies the cn() helper + into lib/utils.ts, and installs the theme tokens. + add resolves a component's transitive dependencies and copies the source into + components/ui/, which is yours to edit from that point on. +

+ +

Commands

+ + + + + + + + + + +
CommandWhat it does
initWrites components.json, copies lib/utils.ts, installs the theme tokens. Exits non-zero if the tokens cannot be written, because an unstyled install with a clean exit code is worse than a failure.
add <names...>Resolves transitive dependencies, copies the source in, installs npm dependencies, and self-heals missing theme tokens.
list [filter]Lists everything in the registry.
view <name>Prints a component's helpers, its paste-ready example, and its full source.
diff [name]Compares your local copy against the live registry.
infoProject diagnostics: the resolved config and registry URL.
+

+ Resolution is local-first. init, add, list, and + view read the registry that ships inside the installed + @webjsdev/ui package, so an install is deterministic and works offline. Only + diff and an explicit custom --registry go to the network. A Tier-1 + component's worked structural example is served on demand by + webjsui view <name> and by the read-only MCP ui tool, rather + than copied into your file. +

+ +

Accessibility

+

+ Responsibility splits by tier, and it matters which half you own. + Tier-2 elements wire their own ARIA, so do not hand-add it: tabs + cross-links its triggers and panels and reports orientation, toggle-group runs a roving + tabindex with Arrow, Home, and End, dropdown-menu declares orientation and reflects + aria-disabled, dialog and alert-dialog name themselves from their title and + description on open, tooltip wires aria-describedby, hover-card exposes the + popup relationship, and sonner is a live region. +

+

+ Tier-1 helpers return only classes, so the semantic element, the role, and + the ARIA are yours. Every Tier-1 component's JSDoc carries an + A11y (required for accessible output) block naming exactly what to supply: a + name on an icon-only button, a role on an alert, scope on table headers, + alt on an avatar image, a labelled <nav> with + aria-current="page" on pagination and breadcrumb. +

+ +

Dependencies

+

+ None beyond Tailwind v4 and @webjsdev/core. No Radix, no clsx, no + tailwind-merge, no Floating UI, no Sonner. The cn() helper, the positioning, + the focus trap, and the toast queue are all hand-rolled, so the whole kit is auditable in + an afternoon and every line is yours to edit. +

+ +

Migrating from shadcn

+

+ A Tier-2 element maps mechanically: <DialogContent> becomes + <ui-dialog-content>, and variant and size props keep their names. A + Tier-1 component has no wrapper at all, so <Button variant="outline"> + becomes a real <button> carrying + buttonClass({ variant: 'outline' }). There is no Radix + asChild slot pattern to translate, because applying a class to the element you + already have is what asChild was working around. An + onValueChange prop becomes an + addEventListener('ui-value-change', fn). +

+
+ `; +} diff --git a/website/app/ui/registry/[name]/route.ts b/website/app/ui/registry/[name]/route.ts new file mode 100644 index 000000000..015eba424 --- /dev/null +++ b/website/app/ui/registry/[name]/route.ts @@ -0,0 +1,37 @@ +import { + loadRegistryItem, + loadRegistryIndex, + loadRegistryManifest, +} from '#modules/ui/queries/registry.server.ts'; +import { REGISTRY_HEADERS } from '#modules/ui/utils/registry-headers.ts'; + +/** + * GET /ui/registry/.json: one registry item. + * + * This is the endpoint a shipped `webjs ui add` hits (the fetcher builds + * `/.json`), so its response shape is a released contract that + * cannot change. Two reserved slugs are carried over from the old host + * verbatim: + * + * index the flat list, same as the sibling /registry/index.json route + * registry the full manifest, same as /registry itself + * + * The `.json` suffix is stripped rather than required, because the CLI appends + * it and a hand-written link usually does not. + */ +export async function GET(_req: Request, { params }: { params: { name: string } }) { + const slug = params.name.replace(/\.json$/, ''); + + if (slug === 'index') { + return new Response(JSON.stringify(await loadRegistryIndex(), null, 2), { headers: REGISTRY_HEADERS }); + } + if (slug === 'registry') { + return new Response(await loadRegistryManifest(), { headers: REGISTRY_HEADERS }); + } + + const item = await loadRegistryItem(slug); + if (!item) { + return Response.json({ error: `Registry item "${slug}" not found` }, { status: 404 }); + } + return new Response(JSON.stringify(item, null, 2), { headers: REGISTRY_HEADERS }); +} diff --git a/website/app/ui/registry/index.json/route.ts b/website/app/ui/registry/index.json/route.ts new file mode 100644 index 000000000..60a97ff15 --- /dev/null +++ b/website/app/ui/registry/index.json/route.ts @@ -0,0 +1,11 @@ +import { loadRegistryIndex } from '#modules/ui/queries/registry.server.ts'; +import { REGISTRY_HEADERS } from '#modules/ui/utils/registry-headers.ts'; + +/** + * GET /ui/registry/index.json: the flat item list, metadata only, which is + * what `webjs ui list` reads. + */ +export async function GET() { + const items = await loadRegistryIndex(); + return new Response(JSON.stringify(items, null, 2), { headers: REGISTRY_HEADERS }); +} diff --git a/website/app/ui/registry/route.ts b/website/app/ui/registry/route.ts new file mode 100644 index 000000000..07f4ef319 --- /dev/null +++ b/website/app/ui/registry/route.ts @@ -0,0 +1,16 @@ +import { loadRegistryManifest } from '#modules/ui/queries/registry.server.ts'; +import { REGISTRY_HEADERS } from '#modules/ui/utils/registry-headers.ts'; + +/** + * GET /ui/registry: the full registry manifest, every item's content inlined. + * + * This is an API, not a page, and it is the reason the ui.webjs.dev host has + * to keep answering forever: already-published @webjsdev/ui and @webjsdev/cli + * versions fetch their components from the old origin, and a published version + * can never be corrected after the fact. The old host now 301s here, which + * shipped clients follow (fetch does by default, verified against the real + * 0.3.1 and 0.3.8 tarballs). + */ +export async function GET() { + return new Response(await loadRegistryManifest(), { headers: REGISTRY_HEADERS }); +} diff --git a/packages/ui/packages/website/app/_components/preview-tabs.ts b/website/components/preview-tabs.ts similarity index 58% rename from packages/ui/packages/website/app/_components/preview-tabs.ts rename to website/components/preview-tabs.ts index b1381b1e1..c273714eb 100644 --- a/packages/ui/packages/website/app/_components/preview-tabs.ts +++ b/website/components/preview-tabs.ts @@ -58,30 +58,78 @@ export class PreviewTabs extends WebComponent { [hidden] { display: none !important; } `; + /** + * Arrow / Home / End move between the two tabs, per the APG tab pattern. + * + * The roles this element declares (`tablist` / `tab` / `tabpanel`) promise + * this behaviour, so it has to actually be here: a widget that announces + * itself as tabs but only responds to clicks is worse than one that never + * claimed to be tabs. Paired with the roving tabindex below, so Tab enters + * the group once and lands on the selected tab rather than walking both. + */ + private onKeydown(e: KeyboardEvent) { + const keys = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End']; + if (!keys.includes(e.key)) return; + e.preventDefault(); + const next: 'preview' | 'code' = + e.key === 'Home' ? 'preview' + : e.key === 'End' ? 'code' + : this.mode.get() === 'preview' ? 'code' + : 'preview'; + this.mode.set(next); + // Follow-focus selection, the APG default for a tablist whose panels are + // already in the DOM (both slots stay mounted here). + this.shadowRoot?.querySelector(`#tab-${next}`)?.focus(); + } + render() { const mode = this.mode.get(); const isPreview = mode === 'preview'; + // The ids are scoped to this shadow root, so several toggles on one page + // cannot collide on them. return html` -
+
this.onKeydown(e)}>
- - + + + `; } } diff --git a/website/lib/docs-shell.ts b/website/lib/docs-shell.ts new file mode 100644 index 000000000..469102f64 --- /dev/null +++ b/website/lib/docs-shell.ts @@ -0,0 +1,323 @@ +import { html } from '@webjsdev/core'; + +/** + * The shared documentation shell: the page-tree sidebar column plus the + * content column, extracted from app/docs/layout.ts so the component + * library at /ui renders the exact same chrome as /docs instead of + * growing a parallel copy that drifts. + * + * Both consumers are NON-ROOT layouts (invariant 8), so this writes no + * document shell. The header, footer, theme toggle, fonts, and design + * tokens all come from app/layout.ts; the sidebar below is the only + * section-specific chrome. + * + * The mobile drawer rides the same body attribute for every consumer + * (data-docs-nav-open): the ROOT layout owns the listener that clears it + * on navigation (it survives client-router swaps precisely because it is + * outside every swap range), so a second attribute would need a second + * root-level listener. Only one shell is ever on a page at a time, so + * sharing the attribute and the #docs-sidebar id is safe. + */ + +export type ShellNavItem = { href: string; label: string }; +export type ShellNavSection = { + title: string; + /** + * Optional item count rendered right-aligned on the section header, the + * way the component library labels its tiers. Absent on the docs. + */ + count?: number; + items: ShellNavItem[]; +}; + +export type DocsShellOptions = { + /** Sidebar sections, rendered in order. */ + nav: ShellNavSection[]; + /** aria-label for the