Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions .agents/skills/webjs/references/ui-kit.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,9 @@ So the loop is: `add` the component, then query `ui <name>` (MCP) or
## Inventory (run `npx webjsdev ui list` or the MCP `ui` tool for the authoritative, current set)

**Tier 1 (class helpers):** accordion, alert, aspect-ratio, avatar, badge,
breadcrumb, button, card, checkbox, collapsible, input, kbd, label,
native-select, pagination, popover, progress, radio-group, separator, skeleton,
switch, table, textarea.
breadcrumb, button, card, checkbox, collapsible, empty, field, input, kbd,
label, native-select, pagination, popover, progress, radio-group, separator,
skeleton, spinner, switch, table, textarea.

**Tier 2 (custom elements, own their ARIA):** alert-dialog, dialog,
dropdown-menu, hover-card, sonner, tabs, tooltip, plus toggle and toggle-group
Expand All @@ -93,6 +93,17 @@ dropdown-menu, hover-card, sonner, tabs, tooltip, plus toggle and toggle-group

- A helper is a function, so compose it: `class=${buttonClass({ variant: 'outline' })}`.
The unquoted `${...}` is a normal `html` attribute hole.
- The three states a screen owes its user each have a primitive, so do not hand-roll
them. A list or table with no rows renders `empty` (title as a real heading, a
description saying what to do next, and the create action). A form field is
`field` (`fieldClass()` holding a `<label for>`, the control, `fieldDescriptionClass()`
text, and on a validation error `data-invalid="true"`, `aria-invalid` on the control,
and a `fieldErrorClass()` message with `role="alert"` whose id the control's
`aria-describedby` lists, with the typed value kept). A pending save puts
`spinner({ decorative: true })` in a `disabled` + `aria-busy="true"` submit button
that keeps its text. The `fieldClass` / `fieldLabelClass` in `components/ui/field.ts`
are not the same-named rhythm helpers in `lib/utils/cn.ts`; import each from its own
module and alias one if a file needs both.
- Tier-1 helpers assume the design tokens exist; if a component paints unstyled,
the tokens are missing (re-run `npx webjsdev ui init` or let `add` self-heal them).
- Custom elements are display-only-safe at SSR and hydrate in the browser, the
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ older, unrelated Java framework also used the name WebJS.
- **Metadata routes are functions rather than static files.** `sitemap.ts`, `robots.ts`, `manifest.ts`, `icon.ts`, and `opengraph-image.ts` generate SEO and PWA metadata dynamically.
- **REST endpoints come from `route.ts`.** Expose a server action over HTTP by importing it into a `route.ts` handler, or reach for the one-line `route(action)` adapter from `@webjsdev/server`. Input validation is optional.
- **WebJs is production ready.** CSRF protection, gzip and brotli, HTTP/2, 103 Early Hints, CSP nonces, modulepreload, rate limiting, health probes, graceful shutdown, and streaming Suspense all ship with the framework.
- **WebJs UI is the matching AI-first component library.** Its 32 primitives at [webjs.dev/ui](https://webjs.dev/ui) are written for AI agents, in two tiers: pure class-helper functions (`buttonClass`, `cardClass`, `inputClass`) for visual primitives, plus a small set of stateful custom elements (`<ui-dialog>`, `<ui-tabs>`, `<ui-popover>`) for the cases where state matters. Running `webjs ui add button card dialog` copies the source into your project, so you own it and can edit it. It ships as a hard dependency of `@webjsdev/cli`, so a WebJs app that installed the CLI needs no separate install, and a WebJs app that skipped the global install runs `npm install -D @webjsdev/ui` and `npm install @webjsdev/core` first, then `npx webjsui init` and `npx webjsui add button card dialog`.
- **WebJs UI is the matching AI-first component library.** Its 35 primitives at [webjs.dev/ui](https://webjs.dev/ui) are written for AI agents, in two tiers: pure class-helper functions (`buttonClass`, `cardClass`, `inputClass`) for visual primitives, plus a small set of stateful custom elements (`<ui-dialog>`, `<ui-tabs>`, `<ui-popover>`) for the cases where state matters. Running `webjs ui add button card dialog` copies the source into your project, so you own it and can edit it. It ships as a hard dependency of `@webjsdev/cli`, so a WebJs app that installed the CLI needs no separate install, and a WebJs app that skipped the global install runs `npm install -D @webjsdev/ui` and `npm install @webjsdev/core` first, then `npx webjsui init` and `npx webjsui add button card dialog`.

## Quickstart

Expand Down
10 changes: 8 additions & 2 deletions packages/ui/AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# AGENTS.md : @webjsdev/ui

The webjs **AI-first component library + CLI**, `webjsui init` / `add` /
`list` / `view` / `diff` / `info` / `build` / `lint`. Ships 32 primitives across two
`list` / `view` / `diff` / `info` / `build` / `lint`. Ships 35 primitives across two
tiers: class-helper functions for visual components, custom elements only
where state matters. Variant names, sizes, and data-attribute conventions
mirror shadcn so existing shadcn knowledge transfers directly.
Expand Down Expand Up @@ -161,7 +161,7 @@ This trap exists ONLY in the marketing site. Scaffolded user apps,
`examples/blog`, and every other WebJs app keep `components/ui/` as normal
tracked source, the standard shadcn "you own it" pattern.

## v1 component inventory (32 components)
## Component inventory (35 components)

| Tier | Component | Surface |
|---|---|---|
Expand All @@ -177,6 +177,9 @@ tracked source, the standard shadcn "you own it" pattern.
| 1a | `avatar` | `avatarClass`, `avatarImageClass`, `avatarFallbackClass`, `avatarBadgeClass`, `avatarGroupClass`, `avatarGroupCountClass` |
| 1a | `separator` | `separatorClass({ orientation })` |
| 1a | `skeleton` | `skeletonClass` |
| 1a | `spinner` | `spinnerClass`, plus `spinner({ class, label, decorative })`, which returns an `html` inline SVG (the Lucide loader-circle path) with `role="status"` + `aria-label="Loading"`. It imports `html` from `@webjsdev/core` but registers no element, so it stays Tier 1. |
| 1a | `empty` | `emptyClass`, `emptyHeaderClass`, `emptyMediaClass({ variant })`, `emptyTitleClass`, `emptyDescriptionClass`, `emptyContentClass`. The root has `border-dashed` but no width (shadcn parity), so add `border` for the outline. |
| 1a | `field` | `fieldSetClass`, `fieldLegendClass({ variant })`, `fieldGroupClass`, `fieldClass({ orientation })`, `fieldContentClass`, `fieldLabelClass`, `fieldTitleClass`, `fieldDescriptionClass`, `fieldErrorClass`, `fieldSeparatorClass`, `fieldSeparatorContentClass`. Invalid state is `data-invalid="true"` on the field plus `aria-invalid` on the control. `fieldClass` and `fieldLabelClass` share their names with the older rhythm helpers in `lib/utils.ts` (copied to `lib/utils/cn.ts`); both sets keep working from their own modules, so a file needing both aliases one. |
| 1a | `aspect-ratio` | `aspectRatioClass`, use Tailwind `aspect-[16/9]` directly |
| 1a | `kbd` | `kbdClass`, `kbdGroupClass` |
| 1a | `table` | `tableContainerClass`, `tableClass`, `tableHeaderClass`, `tableBodyClass`, `tableFooterClass`, `tableRowClass`, `tableHeadClass`, `tableCellClass`, `tableCaptionClass` |
Expand Down Expand Up @@ -268,6 +271,9 @@ obligations:
- `alert`: choose `role="alert"` (urgent) or `role="status"` (polite).
- `separator`: `role="separator"` + `aria-orientation`, or `role="none"` when decorative.
- `skeleton`: `aria-hidden="true"` (or `aria-busy` on the region), since it is a placeholder.
- `spinner`: `spinner()` is a named `role="status"`. Inside a busy button, mark the BUTTON `disabled` + `aria-busy="true"`, keep its visible text, and render `spinner({ decorative: true })` so a second live status is not nested in the button's name.
- `empty`: a real heading for `emptyTitleClass()`, `aria-hidden` on the decorative media, and `role="status"` on the root only when the empty state answers a user action (a search or filter that matched nothing).
- `field`: a real `<label for>` per control, `aria-describedby` listing the description id and, when invalid, the error id (only ids that exist), `aria-invalid` on the control, `role="alert"` on the error, and the typed values kept when the server re-renders the form with an error. A radio or checkbox set goes in a `<fieldset>` named by its `<legend>`.
- `avatar`: an `alt` that names the person, plus the text fallback.
- `table`: `scope="col"` / `scope="row"` on header cells, and a `<caption>`.
- `pagination` / `breadcrumb`: a labelled `<nav>`, `aria-current="page"`, and hidden separators / icon-only control names.
Expand Down
8 changes: 5 additions & 3 deletions packages/ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ function calls, not for a layered React abstraction over every primitive:

- **Tier 1, class-helper functions** (`buttonClass`, `cardClass`,
`inputClass`, `labelClass`, `alertClass`, `popoverContentClass`,
`accordionItemClass`, `collapsibleTriggerClass`, …). Pure functions that
`accordionItemClass`, `collapsibleTriggerClass`, `emptyClass`,
`fieldClass`, `fieldErrorClass`, `spinnerClass`, …). Pure functions that
return Tailwind class strings. You spread them onto raw native elements
, including `<button class=${buttonClass({ variant: 'outline' })}>`,
`<details name="faq" class=${accordionItemClass()}>`, and
Expand Down Expand Up @@ -55,8 +56,9 @@ Tier-1 class helpers return only classes, so the semantic element and ARIA are
yours to supply. Each one's JSDoc carries an `A11y (required for accessible
output)` block stating exactly what to add: 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, and
so on. Follow that block and the markup is fully accessible.
labelled `<nav>` with `aria-current="page"` on pagination and breadcrumb,
`aria-describedby` from a control to its field description and error, a
disabled `aria-busy` button around a decorative spinner, and so on. Follow that block and the markup is fully accessible.

## Install

Expand Down
88 changes: 88 additions & 0 deletions packages/ui/packages/registry/components/empty.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/**
* Empty: the empty state for a list, table, search result, or any region with
* nothing to show yet. Tier-1 class helpers; compose with `<div>` (or a heading
* and paragraph) for each subpart.
*
* shadcn parity:
* Empty → emptyClass()
* EmptyHeader → emptyHeaderClass()
* EmptyMedia (variant: default | icon) → emptyMediaClass({ variant })
* EmptyTitle → emptyTitleClass()
* EmptyDescription → emptyDescriptionClass()
* EmptyContent → emptyContentClass()
*
* The root carries `border-dashed` but no border width, exactly as shadcn
* does, so it renders borderless by default. Add `border` beside it for the
* dashed outline: `class=${cn(emptyClass(), 'border')}`.
*
* Design tokens used: --muted, --foreground, --muted-foreground, --primary,
* --border.
*
* A11y (required for accessible output):
* Use a REAL HEADING for `emptyTitleClass()`, at the level the surrounding
* document wants (`<h2>` under the page `<h1>`, `<h3>` inside a section). The
* empty state usually replaces the list a heading already introduces, so the
* title is often a level below it.
* The media is decorative: put `aria-hidden="true"` on the icon or
* illustration, since the title already says what is missing.
* The action in `emptyContentClass()` is a real `<a href>` (navigates, e.g.
* to the create page) or a real `<button>` (acts in place). A create action
* that is a form submit keeps working without JavaScript.
* When the empty state appears in response to a user action (a search or a
* filter that matched nothing), put `role="status"` on the root so the change
* is announced; a page that simply loads empty needs no role.
*
* @example
* ```html
* <div class=${cn(emptyClass(), 'border')}>
* <div class=${emptyHeaderClass()}>
* <div class=${emptyMediaClass({ variant: 'icon' })}>
* <svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-6l-2 3h-4l-2-3H2"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg>
* </div>
* <h3 class=${emptyTitleClass()}>No projects yet</h3>
* <p class=${emptyDescriptionClass()}>Create your first project to get started.</p>
* </div>
* <div class=${emptyContentClass()}>
* <a class=${buttonClass()} href="/projects/new">Create project</a>
* </div>
* </div>
* ```
*/

const MEDIA_BASE =
'mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0';

const MEDIA_VARIANTS = {
default: 'bg-transparent',
icon: "flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground [&_svg:not([class*='size-'])]:size-6",
} as const;

export type EmptyMediaVariant = keyof typeof MEDIA_VARIANTS;

/** Empty root: centred column with generous padding. Add `border` for the dashed outline. */
export const emptyClass = (): string =>
'flex min-w-0 flex-1 flex-col items-center justify-center gap-6 rounded-lg border-dashed p-6 text-center text-balance md:p-12';

/** Empty header: groups the media, title, and description. */
export const emptyHeaderClass = (): string =>
'flex max-w-sm flex-col items-center gap-2 text-center';

/**
* Empty media: the icon or illustration above the title. `icon` puts the icon
* on a muted tile; `default` leaves it bare (for an avatar or an illustration).
* Set `data-variant="<variant>"` on the same element for shadcn parity.
*/
export function emptyMediaClass(opts: { variant?: EmptyMediaVariant } = {}): string {
return MEDIA_BASE + ' ' + MEDIA_VARIANTS[opts.variant ?? 'default'];
}

/** Empty title: what is missing, in a few words. */
export const emptyTitleClass = (): string => 'text-lg font-medium tracking-tight';

/** Empty description: the muted line under the title, saying what to do next. */
export const emptyDescriptionClass = (): string =>
'text-sm/relaxed text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary';

/** Empty content: the row for the action button(s) or a small form. */
export const emptyContentClass = (): string =>
'flex w-full max-w-sm min-w-0 flex-col items-center gap-4 text-sm text-balance';
Loading
Loading