Skip to content

Commit 09a296e

Browse files
authored
feat(ui): Tier-2 components -> WebComponent + render() + <slot> (rebased) (#28)
* refactor(ui): toggle.ts -> WebComponent + render() + <slot> First Tier-2 component converted from extends Base to extends WebComponent. This is the pattern-locking conversion; remaining nine components follow the same shape. Pattern summary: - extends WebComponent (was: extends Base from local utils) - static properties = { ... reflect: true } (was: observedAttributes getter + manual attr/property bridges) - declare prop: Type + constructor defaults (was: imperative gets) - render() returns html`<slot></slot>`, sets host-level class + aria + data-state. <slot> has display: contents in every browser's user-agent stylesheet, so children layout as direct flex children of the host with no visible wrapper. - firstUpdated() handles one-time event listener setup - .register('tag-name') instead of defineElement() Public API unchanged: tag <ui-toggle>, attributes pressed/variant/ size/disabled, ui-pressed-change event. Behavior preserved. * refactor(ui): sonner.ts -> WebComponent + reactive state Toast items now live in this.state.items via setState. The previous imperative _render() that did replaceChildren is replaced by html`...${repeat(items, ...)} ` returning a TemplateResult. escapeHTML manual escape is replaced by webjs's html`` interpolation which escapes text by default; the SVG icon strings are wrapped in unsafeHTML() since they are trusted, internal markup. Public API unchanged: toast() function + .success/.error/.info/ .warning/.loading/.dismiss/.promise methods, <ui-sonner position> element with imperative addToast() escape hatch. * refactor(ui): toggle-group.ts -> WebComponent + render() + <slot> Both classes (UiToggleGroup parent + UiToggleGroupItem children) now extend WebComponent. Children project through the slot in both cases. Item-state synchronization (data-state, aria-pressed driven by the parent's comma-separated value) runs from the parent's render() via queueMicrotask after projection settles. Item class computation reads parent attributes via closest(), preserved from the original. Public API unchanged. * refactor(ui): tooltip.ts -> WebComponent + render() + <slot> All three classes (UiTooltip, UiTooltipTrigger, UiTooltipContent) convert from extends Base. UiTooltip's child queries change from :scope > ui-tooltip-content to plain ui-tooltip-content descendant search since children now live under the projection slot wrapper. The hover-with-delay state machine, native popover manual mode, positionFloating call, and skip-delay-duration window are all preserved unchanged. Public API unchanged: tags <ui-tooltip>, <ui-tooltip-trigger>, <ui-tooltip-content>; attributes delay-duration, skip-delay-duration, side, align, side-offset, align-offset, open. * refactor(ui): hover-card.ts -> WebComponent + render() + <slot> Same pattern as tooltip.ts: three classes all converted to extends WebComponent + render() returning html`<slot></slot>`. Child queries change from :scope > to descendant search. Native popover manual mode, hover delays, and positionFloating preserved unchanged. * refactor(ui): tabs.ts -> WebComponent + render() + <slot> All four classes (UiTabs, UiTabsList, UiTabsTrigger, UiTabsContent) converted. Parent broadcasts active value to children via _syncChildren() scheduled in queueMicrotask after the slot projection settles. The ui-value-change event fires on actual value mutations rather than every attribute callback. Keyboard navigation (arrow keys, Home/End, Enter/Space) preserved unchanged. Public API: <ui-tabs value orientation>, <ui-tabs-list variant>, <ui-tabs-trigger value>, <ui-tabs-content value>. * refactor(ui): dialog.ts -> WebComponent + render() + named slots This is the refactor where slots earn their keep. The previous imperative pattern (find <ui-dialog-content>, create a <dialog> programmatically, replaceWith + appendChild to reparent the content into the <dialog>) is replaced by a declarative template: render() { return html` <slot></slot> <dialog ... class=${NATIVE_DIALOG_CLASS}> <slot name="dialog-content"></slot> </dialog> `; } The user keeps writing <ui-dialog-content> as a normal child. In connectedCallback the component sets slot="dialog-content" on that element so the projection machinery routes it inside the <dialog>. The user-facing API is unchanged. scroll-lock refcounting, showModal/close native API, escape-to- close via native cancel event, ::backdrop styling, focus trap (all native dialog behavior) are preserved unchanged. All five classes (UiDialog, UiDialogTrigger, UiDialogContent, UiDialogClose, UiDialogFooter) converted to extends WebComponent. * refactor(ui): alert-dialog.ts -> WebComponent + render() + named slots Same shape as the dialog.ts refactor: render() returns a <dialog> template with <slot name='alert-dialog-content'> inside, and the connectedCallback routes the user's <ui-alert-dialog-content> child to that named slot. Native dialog's escape-cancel behavior preserved via the cancel event listener (preventDefault on alert dialogs). All five classes converted (UiAlertDialog, UiAlertDialogTrigger, UiAlertDialogContent, UiAlertDialogCancel, UiAlertDialogAction). * refactor(ui): dropdown-menu.ts -> WebComponent + render() + <slot> The biggest of the Tier-2 refactors. All 11 classes converted from extends Base: UiDropdownMenu (parent), UiDropdownMenuTrigger, UiDropdownMenuContent, UiDropdownMenuItem, UiDropdownMenuLabel, UiDropdownMenuSeparator, UiDropdownMenuShortcut, UiDropdownMenuGroup, UiDropdownMenuSub, UiDropdownMenuSubTrigger, UiDropdownMenuSubContent. Each class follows the established pattern: extends WebComponent, static properties + declare for reactive props, _userClass snapshot on connect, host attribute writes in render(), event-listener wiring in firstUpdated, render() returns html`<slot></slot>`. Selector changes: :scope > ui-dropdown-menu-content -> descendant ui-dropdown-menu-content parentElement -> closest('ui-dropdown-menu-sub') The latter handles the slot wrapper between the sub and its trigger/content (parentElement is now the slot, not the sub). closest() walks past the slot wrapper to find the ancestor sub. Keyboard navigation, document-level click-outside, scroll/resize reposition, type-ahead, submenu pointer-leave delay, native popover showPopover/hidePopover, and positionFloating are all preserved unchanged. ui-open-change-like state coordination moves into a queueMicrotask after render so projection settles first. * release: @webjskit/ui 0.1.0 -> 0.2.0 The 10 Tier-2 components were rewritten from extends Base + imperative DOM walking to extends WebComponent + render() + <slot> projection. Public API (tag names, attributes, events) is unchanged: existing markup keeps working. Internals are now consistent with the rest of the webjs framework's component idiom. The slot work that landed in @webjskit/core 0.5.0 (light-DOM <slot> with full shadow-DOM spec parity) is the prerequisite that made this refactor possible. Previously the ui kit had to use extends Base because slots only worked inside shadow DOM, which the kit avoids for Tailwind compatibility. * refactor(ui): add back isOpen getters + consolidate sonner imports Tests assume el.isOpen as a back-compat getter alongside the new reactive 'open' property. Added on dialog, alert-dialog, tooltip, hover-card. Sonner now imports repeat from @webjskit/core (which re-exports it from ./src/repeat.js) rather than the redundant double-import that WTR's esbuild plugin was struggling to resolve at dynamic-import time. * fix(ui): Tier-2 WebComponent lifecycle + test architecture mismatch Three root causes behind the 35 browser-test failures on this branch: 1. queueMicrotask was too eager for nested slot projection. Parents that queried for descendants (tabs / toggle-group / tooltip / hover-card / dialog / alert-dialog / dropdown-menu) ran sync helpers in a microtask after their own render, but descendant WebComponents had not finished their own first render + slot projection by then. Switch to requestAnimationFrame so the nested cascade settles first. 2. Listeners attached in firstUpdated were orphaned by the disconnect/reconnect cycle of light-DOM slot projection on first mount. firstUpdated runs once; disconnectedCallback removes listeners. The intermediate disconnect during projection left the reconnected element with no handlers. Move listener registration to connectedCallback (runs every reconnect), keep removal symmetric in disconnectedCallback. 3. showPopover() throws InvalidStateError on disconnected elements. The RAF-scheduled sync could fire after the host was torn down (test teardown, route transition). Add an isConnected guard in tooltip + hover-card _syncContent. Test cleanup: - Stale ui-popover / ui-accordion / ui-collapsible suites removed: those components are Tier 1 on main, no custom element to test. - :scope > ui-X-content selectors updated to descendant search (ui-X-content): WebComponent rendering moves content into nested slots, so direct-child matches fail. - Escape-closes-dialog test rewritten to fire a native close event on the inner <dialog> (the UA-internal step). dispatching keydown on document does not reach the native dialog Escape handler. - Click-the-overlay test removed: <ui-dialog-overlay> was replaced by native ::backdrop in the WebComponent dialog. Other fixes: - sonner.ts: import unsafeHTML from '@webjskit/core' directly (the /directives subpath was removed when the package exports were consolidated). Results: - Unit: 936/936 pass - Browser: 81/81 pass * fix(ui): alert-dialog button-in-button + classify toggle as Tier 2 Two issues observed on the live docs preview for /docs/components/alert-dialog and /docs/components/toggle. 1. alert-dialog showed a "button inside a button" visual for both Cancel and Action. The back-compat path in `applyAlertDialogButton` skipped self-styling when the user authored a `<button>` inside the host: if (host.querySelector('button')) return; The old Base-class implementation kept authored children on the host, so the query worked. With the WebComponent + light-DOM slot architecture, `captureAuthoredChildren` moves the children OUT of the host into an off-tree assignment table BEFORE render() runs, so the query found nothing. The host got styled as a button AND the user's own button rendered inside it after slot projection. Fix: capture the "user provided a button" flag in connectedCallback (which runs before the slot machinery hides authored children) and store it on the instance as `_hasAuthoredButton`. render() consults the flag, not a live DOM query. 2. toggle was classified as Tier 1 in the website's `_lib/tier.ts`. That was correct on main (where toggle is class-helper + native <button> + attachToggle). On this branch, toggle.ts was migrated to a WebComponent custom element (`<ui-toggle>`), so it belongs in TIER_2_NAMES. * refactor(ui): toggle.ts to Lit-idiomatic pattern Eliminates ~50 lines of connectedCallback / disconnectedCallback / firstUpdated / addEventListener / setAttribute boilerplate by rendering a native <button> inside the host's template and binding the click handler declaratively. - render() returns html`<button ...><slot></slot></button>` - @click=${this._onClick} on the inner <button> auto-wires - aria-pressed / data-state / class / disabled bound declaratively on the inner button instead of imperative setAttribute calls - Native <button> handles Enter/Space + focus + disabled semantics for free; the manual _onKeyDown handler is gone - The host's class attribute is no longer touched (no _userClass capture/merge); the inner button carries the visual class Semantics shift: aria-pressed + data-state live on the inner <button> now, not the host. More correct for screen readers (focusable element carries its state). The host's `pressed` reactive prop still reflects to a `pressed` attribute for declarative consumers. * refactor(ui): Lit-idiomatic Tier-2 components Tier-2 component classes were verbose with manual lifecycle wiring, imperative setAttribute calls in render(), addEventListener + removeEventListener pairs in connectedCallback/disconnectedCallback, and `_userClass` capture-and-merge to avoid stomping author classes. Lit-idiomatic patterns eliminate the boilerplate: Declarative attribute bindings (`?attr=${bool}`, `attr=${val}`, `class=${...}`) replace imperative setAttribute in render(). Declarative event bindings (`@click=${this._onClick}`) replace manual addEventListener / removeEventListener around the host. The framework wires/unwires them automatically as the rendered element enters/leaves the DOM. Inner element rendering pattern. Instead of styling the host and catching events on it (which forces connectedCallback boilerplate and a `_userClass` capture for class merging), each component renders the role-bearing element inside its slot output: <ui-toggle> renders <button>, <ui-tabs-trigger> renders <button role="tab">, <ui-tooltip-content> renders <div popover="manual" role="tooltip">, etc. The host stays a thin slot wrapper; the user's class attribute on the host is untouched; semantics live on the focusable / popover element where they belong. Native button elements get Enter/Space activation, focus, and disabled semantics for free; removed manual keydown handlers and tabindex/role setAttribute calls. Native dialog @close / @cancel event bindings replace addEventListener + removeEventListener pairs. Constraint: webjs core has firstUpdated() but not updated(changedProps), so transitions across renders (e.g. "open just became true, call showModal") use a manual `_lastOpen` flag + requestAnimationFrame in render(). This is the documented pattern when an `updated()` hook is absent. Test updates: tests previously queried role / data-state / event targets on the <ui-*> hosts. The Lit refactor moved those to the inner rendered elements, so tests now query [data-slot="X"], [role="Y"], or [popover] for the actual rendered element. Behavior assertions (open transitions, value updates, event firing) are unchanged. Browser tests: 81 / 81 pass Unit tests: 936 / 936 pass * fix(ui): SSR + visual regressions in the Lit-idiomatic refactor Three classes of bug introduced by the previous refactor commit: 1. SSR crashes. render() runs server-side via linkedom, which doesn't implement closest() on custom elements, dispatchEvent / CustomEvent, or requestAnimationFrame. The refactored components called all three unconditionally and brought the dev server down. Guards added: - `if (typeof this.closest !== 'function') return null;` in every parent-lookup getter - `if (typeof requestAnimationFrame !== 'undefined') ...` around every RAF schedule - `if (typeof window !== 'undefined') { ... }` around the dispatchEvent + queueMicrotask block in <ui-tabs> The client re-renders after hydration so the omitted side effects run at the correct time anyway. 2. Tabs content stayed visible when inactive. The old code had inline CSS hiding `<ui-tabs-content[data-state="inactive"]>`; the refactor removed it and relied on `?hidden` on the inner `<section>`, which leaves the host taking visual space as an empty box. Toggle `hidden` on the host directly via `this.toggleAttribute('hidden', !active)` so the entire <ui-tabs-content> is removed from layout when inactive. 3. <ui-toggle-group-item> lost joined-corner styling. The refactor moved toggleClass + data-state to an inner <button>, but the CSS uses sibling selectors (`:first-child`, `:last-child` plus data-spacing variants) that need the styled element to be a direct sibling of other items. With a button inside each host, the button is the only child of its host, so `:first-child` is always true and the rounded-corner rules misfire. The fix: keep the host as the styled element for this compound-component pattern. The click + keydown listeners attach in connectedCallback (host is the click target, not an inner element). New reactive `pressed` prop replaces the parent's imperative `setAttribute('data-state', ...)` walk, so the item's render() re-runs cleanly on state change. 4. Dialog X close button stopped rendering. The previous attempt set the inner SVG via `.innerHTML=${...}` on a <ui-dialog-close>, but the custom element's own render() overwrites the host's children so the SVG was lost. Render the X button inline in <ui-dialog-content>'s template using `unsafeHTML(SVG)` for the icon, with a local @click handler that hides the parent dialog. Tests: 81 / 81 browser pass, 936 / 936 unit pass. * refactor(ui): finish Tier-2 to Lit-style WebComponent Migrate the 9 Tier-2 stateful custom elements from the local Base / defineElement HTMLElement-decorator pattern to Lit-shaped WebComponent from @webjskit/core. Every component now uses static properties, render() returning html`...` templates, declarative bindings (@click, ?attr, attr=, .prop), and <slot></slot> for projecting authored children. No imperative DOM mutation in event handlers; host-styled escape hatches (toggle-group-item, tabs-content) use dataset/IDL properties only. Architectural fix for dialog and alert-dialog: the content child now owns the native <dialog> element (its render emits the <dialog> wrapper around its slotted content), and the parent drives showModal / close on the child via the open prop. Every slot is a default slot, which closes the SSR named-slot routing bug that earlier required setting slot="..." in connectedCallback (a pattern that also breaks in shadow DOM with DSD). alert-dialog Cancel and Action drop the broken wrap-a-button back-compat (SSR was producing invalid nested-button HTML). Bare-text authoring is now the canonical shape; the docs example matches. Also normalises docstrings on the 9 Tier-2 components to a canonical structure (header, prose, APG link, shadcn parity map, Usage, Attributes, Events, Programmatic API, Keyboard, Design tokens). Fixes stale architectural claims in packages/ui/AGENTS.md + README.md (which still described Tier-2 as HTMLElement subclasses) and the docs/ui page reference to ui-popover as Tier-2 (popover is Tier-1 now). Updates the registry-contents test assertions to match the new WebComponent.register + declarative role= patterns. * docs(ui): normalise Tier-1 component docstrings to canonical structure Standardise the top-of-file JSDoc for every Tier-1 component to the same section order: header line, prose paragraph on implementation, shadcn parity map, Usage code example, optional component-specific notes, Design tokens used. Adds explicit shadcn parity tables to input and textarea (previously missing). Tightens accordion, collapsible, popover, and progress to fit the same shape without losing their migration / browser-support notes. No code changes. * fix(docker): build ui-website tailwind CSS in the image The Dockerfile RUN tailwindcss line was building CSS for website, docs, and examples/blog but skipping packages/ui/packages/website. The omission has been there since the UI website was added in 3d20a85. ui.webjs.dev still ships styled today because Railway invokes npm start, which fires the prestart css:build hook at container boot; but the compose path (and any non-npm-start CMD override) ships an unstyled image. Add the missing line so the behaviour matches the other three apps and cold-start CSS-build cost moves from container boot to image build. * docs: clarify npm run dev vs webjs dev across all app-level AGENTS Add guidance that npm run dev / npm start are the entrypoints inside an app, not webjs dev / webjs start. The webjs CLI commands are framework primitives (only run the server); the npm scripts compose them with the app-specific watchers (Tailwind, Prisma) via concurrently and pre* hooks. Skipping the npm wrapper produces silent breakage (stale Prisma client, missing tailwind.css, unmigrated DB). Same split Rails 7+ ships (bin/rails server vs bin/dev). In Docker / Railway, prefer npm start as the CMD so the prestart hook fires. Lands the explainer in each of the four runnable apps' AGENTS.md (website, docs, blog, ui-website), in the scaffold template AGENTS (so every newly-created app picks it up), and a one-line pointer in the framework's own root AGENTS so an agent working from the repo root knows to defer to per-app guidance. Also corrects a stray "cd docs && webjs dev" in the root README. No changes to AGENTS.md sections that pertain to framework development workflow. * feat(website): default UI_URL to localhost + ship .env.example UI_URL fallback in app/layout.ts and app/page.ts now points at the canonical local dev port (http://localhost:5001, matching the ui-website's webjs:dev script and the compose.yaml CMD), mirroring how DOCS_URL and BLOG_URL already default to their local ports. The production value still comes from the Railway service env. Ship a website/.env.example committed alongside (the .env itself is gitignored at the repo root) so a fresh clone documents the three sibling URLs in one place. ---------
1 parent 2923738 commit 09a296e

52 files changed

Lines changed: 2104 additions & 1927 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

AGENTS.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -818,6 +818,14 @@ webjs ui list / view <name> # browse the registry
818818
819819
`PORT` env is honoured by `dev` and `start` when `--port` is absent.
820820
821+
> **Running this repo's own apps locally** (`website/`, `docs/`,
822+
> `examples/blog/`, `packages/ui/packages/website/`): always `cd` into
823+
> the app and use **its** `npm run dev` / `npm start`, never `webjs dev`
824+
> / `webjs start` directly. Each app composes `webjs dev` with its own
825+
> watchers (Tailwind, Prisma, registry copy) via `concurrently` + `pre*`
826+
> hooks; skipping the npm wrapper renders pages unstyled or with stale
827+
> generated code. See each app's `AGENTS.md` for the specifics.
828+
821829
---
822830
823831
## Environment variables: server-only by default, `WEBJS_PUBLIC_*` reaches the browser

Dockerfile

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,14 @@ RUN cd examples/blog && npx prisma generate
5050
# (see packages/ui/packages/website/app/_lib/registry.server.ts).
5151

5252
# Tailwind: compile per-app CSS (all four use the CLI, no browser runtime).
53-
RUN npx tailwindcss -i website/public/input.css -o website/public/tailwind.css --minify \
54-
&& npx tailwindcss -i docs/public/input.css -o docs/public/tailwind.css --minify \
55-
&& npx tailwindcss -i examples/blog/public/input.css -o examples/blog/public/tailwind.css --minify
53+
# Each compose service's command invokes `webjs.js start` directly, which
54+
# bypasses the per-package `prestart: css:build` hook in npm; the CSS has
55+
# to be ready in the image. Keep this list in sync with the apps that
56+
# have a public/input.css and a `css:build` script in their package.json.
57+
RUN npx tailwindcss -i website/public/input.css -o website/public/tailwind.css --minify \
58+
&& npx tailwindcss -i docs/public/input.css -o docs/public/tailwind.css --minify \
59+
&& npx tailwindcss -i examples/blog/public/input.css -o examples/blog/public/tailwind.css --minify \
60+
&& npx tailwindcss -i packages/ui/packages/website/public/input.css -o packages/ui/packages/website/public/tailwind.css --minify
5661

5762
# Defaults - Railway / compose override per service.
5863
ENV NODE_ENV=production

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@ const resp = await app.handle(new Request('http://x/api/hello'));
192192
The docs site is built on webjs itself:
193193

194194
```sh
195-
cd docs && webjs dev --port 4000
195+
cd docs && npm run dev # runs webjs dev + tailwind --watch together (see AGENTS.md)
196196
```
197197

198198
37 pages covering: getting started, AI-first development, routing,

docs/AGENTS.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,13 @@ That's it. No separate manifest, no rebuild.
5555
cd docs && npm run dev # http://localhost:4000
5656
```
5757

58+
**Use `npm run dev`, not `webjs dev` directly.** `webjs dev` only runs
59+
the server; this app's `npm run dev` uses `concurrently` to also spawn
60+
`tailwindcss --watch`, which is what produces `public/tailwind.css`.
61+
Running `webjs dev` alone ships pages with no Tailwind utilities applied
62+
(code blocks, sidebar, headings all look broken). Same in prod: prefer
63+
`npm start` over `webjs start` so the `prestart: css:build` hook fires.
64+
5865
---
5966

6067
Framework-wide rules and full API reference:

docs/app/docs/ui/page.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ export default function UiDocs() {
99
An <strong>AI-first component library</strong> with two-tier composition: pure class-helper
1010
functions (<code>buttonClass</code>, <code>cardClass</code>, <code>inputClass</code>) for
1111
visual primitives, plus a small set of stateful custom elements
12-
(<code>&lt;ui-dialog&gt;</code>, <code>&lt;ui-tabs&gt;</code>, <code>&lt;ui-popover&gt;</code>)
12+
(<code>&lt;ui-dialog&gt;</code>, <code>&lt;ui-tabs&gt;</code>, <code>&lt;ui-dropdown-menu&gt;</code>, etc.)
1313
where state matters. Source-copied into your project so you own the code and edit it freely.
1414
Variant names, sizes, and data attributes mirror shadcn so existing shadcn knowledge maps
1515
directly. Works in any project with Tailwind v4 and the small <code>@webjskit/core</code>

examples/blog/AGENTS.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,32 @@ https://ui.webjs.dev). Tier-1 additions auto-export class helpers
144144
from their `components/ui/<name>.ts` file; Tier-2 additions register
145145
their custom element on import.
146146

147+
## Running the app
148+
149+
```sh
150+
cp .env.example .env # AUTH_SECRET, SESSION_SECRET, DATABASE_URL
151+
npm run db:migrate # creates prisma/dev.db + applies migrations
152+
npm run dev # http://localhost:3456
153+
```
154+
155+
**Always `npm run dev` / `npm start`, never `webjs dev` / `webjs start`
156+
directly.** `webjs dev` and `webjs start` are framework primitives,
157+
they only run the webjs server. They do **not** run `prisma generate`,
158+
do **not** run `prisma migrate deploy`, do **not** spawn the Tailwind
159+
watcher. `npm run dev` and `npm start` are the app-level entrypoints,
160+
they run the server **plus** every other process the app needs, wired
161+
via `predev` / `prestart` hooks and `concurrently` for parallel
162+
watchers. Skipping the npm wrapper produces silent breakage: stale
163+
Prisma client, missing `public/tailwind.css`, unmigrated DB in prod.
164+
165+
Same split Rails 7+ uses: `bin/rails server` is the framework
166+
primitive, `bin/dev` is the orchestrator. webjs uses npm scripts +
167+
hooks for the same role.
168+
169+
In Docker / Railway, prefer `npm start` as the CMD over `node ...
170+
webjs.js start ...`. The npm form fires `prestart` (which runs
171+
`prisma migrate deploy`); the direct binary form skips it.
172+
147173
## Conventions
148174

149175
- **One exported function per action/query file.** Name the file after the function.

packages/cli/templates/AGENTS.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,29 @@ npm run db:migrate # creates prisma/dev.db + migration
275275
npm run dev # webjs dev + prisma generate via predev
276276
```
277277

278+
### Always `npm run dev` / `npm start`, never `webjs dev` / `webjs start` directly
279+
280+
`webjs dev` and `webjs start` are framework primitives, they only run
281+
the webjs server. They do **not** run `prisma generate`, do **not** run
282+
`prisma migrate deploy`, do **not** spawn the Tailwind watcher, do
283+
**not** run any other per-app process this `package.json` composes.
284+
285+
`npm run dev` and `npm start` are the app-level entrypoints. They run
286+
the webjs server **plus** every other process the app needs, wired
287+
together via `predev` / `prestart` hooks and (where present)
288+
`concurrently` for parallel watchers. Skipping the npm wrapper produces
289+
silent breakage: a stale Prisma client, missing `public/tailwind.css`,
290+
an unmigrated database in production, etc.
291+
292+
Same split Rails 7+ uses: `bin/rails server` is the framework
293+
primitive, `bin/dev` is the orchestrator. webjs uses npm scripts +
294+
hooks for the same role, because as a no-build framework Tailwind /
295+
Prisma / etc. cannot be bundler plugins.
296+
297+
In Docker / Railway, prefer `npm start` (or `node node_modules/.bin/npm
298+
start`) as the CMD over `node ... webjs.js start ...`. The npm form
299+
fires `prestart`; the direct binary form skips it.
300+
278301
Scripts:
279302

280303
- `npm run db:migrate`: `prisma migrate dev` (dev-time schema changes + migration + generate)

packages/ui/AGENTS.md

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,18 @@ Helpers that take options accept an object: `buttonClass({ variant: 'outline', s
4444

4545
### Tier 2 : stateful custom elements
4646

47-
For things the browser doesn't provide natively: dialogs, popovers, tabs,
48-
accordions, dropdowns. Plain `HTMLElement` subclasses (not `WebComponent`)
49-
so they DECORATE their host (set classes, listen for events) without
50-
replacing children. Children flow naturally.
47+
For things the browser doesn't provide natively: dialogs, alert-dialogs,
48+
tabs, dropdowns, tooltips, hover-cards, toggle / toggle-group, sonner.
49+
Tier-2 components extend `WebComponent` from `@webjskit/core` and are
50+
Lit-shaped: `static properties` for reactive attributes, `render()`
51+
returning an `` html`...` `` template, declarative bindings (`@click`,
52+
`?attr`, `attr=`, `.prop`), and `<slot></slot>` for projecting authored
53+
children. Light DOM throughout, full shadow-DOM slot parity.
54+
55+
popover, accordion, and collapsible used to live here but migrated to
56+
Tier 1 once their behaviour could be expressed by native primitives
57+
(the HTML Popover API + `<details>` / `<summary>` + CSS Anchor
58+
Positioning).
5159

5260
```ts
5361
html`
@@ -99,7 +107,7 @@ packages/ui/
99107
100108
packages/registry/ the registry (internal, not published)
101109
components/ .ts files, one per component
102-
lib/utils.ts cn() + Base + defineElement + layout/typography helpers
110+
lib/utils.ts cn() helper + layout/typography helpers (Tier-2 components extend WebComponent from @webjskit/core directly)
103111
themes/
104112
index.css @theme block + CSS variables (light + dark, neutral defaults)
105113
base-colors.js per-base-colour overrides (stone/zinc/mauve/olive/mist/taupe) + mergeThemeCss
@@ -209,10 +217,12 @@ full per-directory breakdown.
209217
Same shape, so a shadcn-compatible client could in principle consume
210218
our registry (modulo TS vs TSX extensions).
211219

212-
4. **Light DOM + Tailwind everywhere.** Custom elements extend `HTMLElement`
213-
(NOT `WebComponent`), they decorate the host element rather than
214-
render replacement children. Light DOM means Tailwind utility classes
215-
apply directly.
220+
4. **Light DOM + Tailwind everywhere.** Tier-2 custom elements extend
221+
`WebComponent` from `@webjskit/core` and use Lit-shaped `render()` +
222+
`html` `` templates with declarative bindings. Light DOM means
223+
Tailwind utility classes apply directly to authored children that
224+
project through `<slot>`. No shadow root anywhere; full shadow-DOM
225+
slot parity in light DOM.
216226

217227
5. **API parity with shadcn.** Variant names, size names, subcomponent
218228
breakdown, `data-state` / `data-orientation` / `data-side` /

packages/ui/README.md

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,18 +20,23 @@ function calls, not for a layered React abstraction over every primitive:
2020
`<ui-sonner>`, …). Reserved for the behavior the browser still doesn't
2121
give you for free: hover-with-delay tooltips, roving-focus keyboard nav
2222
for menus and tabs, toast queue with stack and dismiss. Dialog and
23-
alert-dialog use a thin custom element on top of the native
24-
`<dialog>.showModal()`, focus trap, Escape, and backdrop overlay all
25-
come from the platform. Decorate the host, no shadow DOM.
23+
alert-dialog wrap the native `<dialog>.showModal()`, so focus trap,
24+
Escape, and backdrop overlay all come from the platform. Light DOM
25+
throughout (no shadow DOM); authored children project through `<slot>`.
2626

2727
Works with any project that uses Tailwind CSS v4 and supports custom elements:
2828
webjs, Next, Astro, Vite, SvelteKit, Lit, vanilla HTML, as long as Tailwind
2929
is configured, the components render correctly. Variant names, sizes, and
3030
data-attribute conventions mirror shadcn's so an AI agent's existing
3131
knowledge of shadcn maps directly.
3232

33-
Tier-2 elements extend `Base` (a Node-safe `HTMLElement` shim) from a small
34-
shared `lib/utils.ts` the CLI writes into your project.
33+
Tier-2 elements extend `WebComponent` from `@webjskit/core`, a tiny
34+
Lit-shaped base class with `static properties` for reactive attributes,
35+
`render()` returning an `` html`...` `` template, and declarative
36+
bindings (`@click`, `?attr`, `attr=`). Light DOM throughout, so Tailwind
37+
utility classes on authored children apply directly. The `webjsui add`
38+
CLI installs `@webjskit/core` automatically when you add a Tier-2
39+
component.
3540

3641
## Install
3742

packages/ui/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@webjskit/ui",
3-
"version": "0.1.0",
3+
"version": "0.2.0",
44
"type": "module",
55
"description": "An AI-first component library - class-helper functions for visuals, custom elements only where state matters. Source-copied into your repo, you own it. Works with any Tailwind v4 project.",
66
"bin": {

0 commit comments

Comments
 (0)