@webjsdev/ui 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +1 -1
  2. package/package.json +2 -2
  3. package/packages/registry/blocks/dashboard-01/components/app-sidebar.ts +1 -1
  4. package/packages/registry/blocks/dashboard-01/components/data-table.ts +10 -3
  5. package/packages/registry/blocks/login-02/page.ts +1 -1
  6. package/packages/registry/blocks/login-04/components/login-form.ts +1 -1
  7. package/packages/registry/blocks/preview/cards/no-team-members.ts +1 -1
  8. package/packages/registry/blocks/preview-02/cards/new-milestone.ts +2 -2
  9. package/packages/registry/blocks/preview-02/cards/sidebar-nav.ts +2 -2
  10. package/packages/registry/blocks/preview-02/cards/social-links.ts +2 -2
  11. package/packages/registry/blocks/preview-03/components/chat-card.ts +5 -5
  12. package/packages/registry/blocks/preview-03/components/part-reasoning.ts +2 -2
  13. package/packages/registry/blocks/sidebar-01/components/app-sidebar.ts +2 -2
  14. package/packages/registry/blocks/sidebar-01/components/search-form.ts +2 -2
  15. package/packages/registry/blocks/sidebar-02/components/app-sidebar.ts +2 -2
  16. package/packages/registry/blocks/sidebar-02/components/search-form.ts +2 -2
  17. package/packages/registry/blocks/sidebar-03/components/app-sidebar.ts +2 -2
  18. package/packages/registry/blocks/sidebar-04/components/app-sidebar.ts +3 -3
  19. package/packages/registry/blocks/sidebar-05/components/app-sidebar.ts +2 -2
  20. package/packages/registry/blocks/sidebar-05/components/search-form.ts +2 -2
  21. package/packages/registry/blocks/sidebar-06/components/nav-main.ts +2 -2
  22. package/packages/registry/blocks/sidebar-07/components/app-sidebar.ts +1 -1
  23. package/packages/registry/blocks/sidebar-08/components/app-sidebar.ts +1 -1
  24. package/packages/registry/blocks/sidebar-09/components/app-sidebar.ts +3 -3
  25. package/packages/registry/blocks/sidebar-10/components/nav-main.ts +2 -2
  26. package/packages/registry/blocks/sidebar-12/components/app-sidebar.ts +1 -1
  27. package/packages/registry/blocks/sidebar-13/components/settings-dialog.ts +2 -2
  28. package/packages/registry/blocks/sidebar-14/components/app-sidebar.ts +2 -2
  29. package/packages/registry/blocks/sidebar-15/components/nav-main.ts +2 -2
  30. package/packages/registry/blocks/sidebar-15/components/sidebar-right.ts +1 -1
  31. package/packages/registry/blocks/sidebar-16/components/app-sidebar.ts +1 -1
  32. package/packages/registry/blocks/sidebar-16/components/search-form.ts +2 -2
  33. package/packages/registry/blocks/signup-02/page.ts +1 -1
  34. package/packages/registry/blocks/signup-04/components/signup-form.ts +1 -1
  35. package/packages/registry/lib/dom.ts +3 -4
  36. package/packages/registry/lib/utils.ts +3 -4
  37. package/src/commands/add.js +30 -2
  38. package/src/commands/init.js +2 -2
  39. package/src/lint/rules/no-restyle.js +1 -1
  40. package/src/registry/extract.js +2 -3
package/README.md CHANGED
@@ -138,7 +138,7 @@ are registry items too: `theme-<name>` for shadcn's 24 themes and
138
138
 
139
139
  Copies the component's `.ts` source into `components/ui/<name>.ts` (or your
140
140
  configured alias). Resolves transitive deps via `registryDependencies` and
141
- auto-installs npm deps like `@floating-ui/dom` for popover-style components. For
141
+ installs any npm dependency the app's `package.json` does not already declare. For
142
142
  a Tier-1 class-helper component it copies the helpers plus a lean header and a
143
143
  one-line pointer, and leaves the worked structural example OUT of the file (get
144
144
  it on demand with `npx @webjsdev/ui view <name>`, which is what the pointer it
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/ui",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "description": "The AI-first component library for WebJs, a full-stack JavaScript framework built on web components with server-side rendering and no build step. Class-helper functions for visuals, custom elements only where state matters, source-copied into your repo so you own it.",
6
6
  "bin": {
@@ -26,7 +26,7 @@
26
26
  "test:e2e:a11y-tree": "node test/e2e/a11y-tree.e2e.mjs"
27
27
  },
28
28
  "dependencies": {
29
- "@webjsdev/core": "^0.8.0",
29
+ "@webjsdev/core": "^0.9.0",
30
30
  "commander": "^14.0.0",
31
31
  "kleur": "^4.1.5",
32
32
  "prompts": "^2.4.2",
@@ -53,7 +53,7 @@ const data: AppSidebarData = {
53
53
  user: {
54
54
  name: 'vivek',
55
55
  email: 'vivek@example.com',
56
- avatar: 'https://webjs.dev/public/avatars/vivek.jpg',
56
+ avatar: 'https://webjs.dev/avatars/vivek.jpg',
57
57
  },
58
58
  navMain: [
59
59
  { title: 'Dashboard', url: '#', icon: LayoutDashboardIcon },
@@ -23,7 +23,7 @@
23
23
  * in place of `dashboardData`. Until then the inline forms submit as a GET
24
24
  * back to the page.
25
25
  */
26
- import { WebComponent, html, prop, render, repeat } from '@webjsdev/core';
26
+ import { WebComponent, html, prop, render, repeat, nothing } from '@webjsdev/core';
27
27
  import { cn } from '../../../lib/utils.ts';
28
28
  import { badgeClass } from '../../../components/badge.ts';
29
29
  import { buttonClass } from '../../../components/button.ts';
@@ -115,7 +115,7 @@ function reviewerCell(row: DashboardRow, edit: RowEdit) {
115
115
  function row(r: DashboardRow, state: RowState) {
116
116
  const { selected, edit, hidden } = state;
117
117
  return html`
118
- <tr data-slot="table-row" data-row-id=${String(r.id)} data-dragging="false" data-state=${selected ? 'selected' : null} class=${cn(tableRowClass(), 'relative z-0 data-[dragging=true]:z-10 data-[dragging=true]:opacity-80')}>
118
+ <tr data-slot="table-row" data-row-id=${String(r.id)} data-dragging="false" data-state=${selected ? 'selected' : nothing} class=${cn(tableRowClass(), 'relative z-0 data-[dragging=true]:z-10 data-[dragging=true]:opacity-80')}>
119
119
  <td data-slot="table-cell" class=${cell()}>
120
120
  <button type="button" draggable="true" data-drag-handle data-slot="button" data-variant="ghost" data-size="icon" class=${cn(buttonClass({ variant: 'ghost', size: 'icon' }), 'size-7 cursor-grab text-muted-foreground hover:bg-transparent')}>
121
121
  ${GripVerticalIcon('size-3 text-muted-foreground')}
@@ -124,7 +124,14 @@ function row(r: DashboardRow, state: RowState) {
124
124
  </td>
125
125
  <td data-slot="table-cell" class=${cell()}>
126
126
  <div class="flex items-center justify-center">
127
- <input type="checkbox" data-slot="checkbox" data-row-select class=${checkboxClass()} aria-label="Select row" .checked=${selected}>
127
+ ${state === FIRST_PAINT
128
+ // The first paint is part of the PAGE's template (the page never
129
+ // hydrates), so it states the checkbox as an attribute; a page
130
+ // template may not carry a `.prop` binding. The element's own
131
+ // repaints set the live property, which a box the user has
132
+ // toggled still follows.
133
+ ? html`<input type="checkbox" data-slot="checkbox" data-row-select class=${checkboxClass()} aria-label="Select row" ?checked=${selected}>`
134
+ : html`<input type="checkbox" data-slot="checkbox" data-row-select class=${checkboxClass()} aria-label="Select row" .checked=${selected}>`}
128
135
  </div>
129
136
  </td>
130
137
  <td data-slot="table-cell" class=${cell()}>
@@ -29,7 +29,7 @@ export default function LoginPage() {
29
29
  </div>
30
30
  </div>
31
31
  <div class="relative hidden bg-muted lg:block">
32
- <img src="/public/placeholder.svg" alt="" class="absolute inset-0 h-full w-full object-cover dark:brightness-[0.2] dark:grayscale">
32
+ <img src="/placeholder.svg" alt="" class="absolute inset-0 h-full w-full object-cover dark:brightness-[0.2] dark:grayscale">
33
33
  </div>
34
34
  </div>
35
35
  `;
@@ -79,7 +79,7 @@ export function loginForm(opts: { class?: string } = {}) {
79
79
  </div>
80
80
  </form>
81
81
  <div class="relative hidden bg-muted md:block">
82
- <img src="/public/placeholder.svg" alt="" class="absolute inset-0 h-full w-full object-cover dark:brightness-[0.2] dark:grayscale">
82
+ <img src="/placeholder.svg" alt="" class="absolute inset-0 h-full w-full object-cover dark:brightness-[0.2] dark:grayscale">
83
83
  </div>
84
84
  </div>
85
85
  </div>
@@ -25,7 +25,7 @@ import {
25
25
  } from '../../../components/empty.ts';
26
26
 
27
27
  const MEMBERS = [
28
- { src: 'https://webjs.dev/public/avatars/vivek.jpg', alt: '@vivek', fallback: 'VK' },
28
+ { src: 'https://webjs.dev/avatars/vivek.jpg', alt: '@vivek', fallback: 'VK' },
29
29
  { src: 'https://github.com/maxleiter.png', alt: '@maxleiter', fallback: 'LR' },
30
30
  { src: 'https://github.com/evilrabbit.png', alt: '@evilrabbit', fallback: 'ER' },
31
31
  ];
@@ -7,7 +7,7 @@
7
7
  * The `<form>` has no `action` yet. Bind a server action from a
8
8
  * `'use server'` file (`<form action=${createGoal}>`) before you ship it.
9
9
  */
10
- import { html } from '@webjsdev/core';
10
+ import { html, nothing } from '@webjsdev/core';
11
11
  import { buttonClass } from '../../../components/button.ts';
12
12
  import {
13
13
  cardClass,
@@ -24,7 +24,7 @@ import { cn } from '../../../lib/utils.ts';
24
24
  function field(id: string, name: string, label: string, attrs: { placeholder?: string; value?: string }) {
25
25
  return html`<div role="group" data-slot="field" data-orientation="vertical" class=${fieldClass()}>
26
26
  <label data-slot="field-label" for=${id} class=${fieldLabelClass()}>${label}</label>
27
- <input data-slot="input" id=${id} name=${name} placeholder=${attrs.placeholder ?? null} value=${attrs.value ?? null} class=${inputClass()}>
27
+ <input data-slot="input" id=${id} name=${name} placeholder=${attrs.placeholder ?? nothing} value=${attrs.value ?? nothing} class=${inputClass()}>
28
28
  </div>`;
29
29
  }
30
30
 
@@ -11,7 +11,7 @@
11
11
  * a real route, marked `data-active="true"` and `aria-current="page"` on the
12
12
  * current one.
13
13
  */
14
- import { html } from '@webjsdev/core';
14
+ import { html, nothing } from '@webjsdev/core';
15
15
  import type { TemplateResult } from '@webjsdev/core';
16
16
  import { cn } from '../../../lib/utils.ts';
17
17
  import { cardClass } from '../../../components/card.ts';
@@ -37,7 +37,7 @@ function group(label: string, cls: string, entries: Entry[]) {
37
37
  <ul data-slot="sidebar-menu" data-sidebar="menu" class=${sidebarMenuClass()}>
38
38
  ${entries.map(([icon, text, active]) => html`
39
39
  <li data-slot="sidebar-menu-item" data-sidebar="menu-item" class=${sidebarMenuItemClass()}>
40
- <a href="#" data-slot="sidebar-menu-button" data-sidebar="menu-button" data-size="default" data-active=${active ? 'true' : 'false'} aria-current=${active ? 'page' : null} class=${sidebarMenuButtonClass()}>${icon()}${text}</a>
40
+ <a href="#" data-slot="sidebar-menu-button" data-sidebar="menu-button" data-size="default" data-active=${active ? 'true' : 'false'} aria-current=${active ? 'page' : nothing} class=${sidebarMenuButtonClass()}>${icon()}${text}</a>
41
41
  </li>`)}
42
42
  </ul>
43
43
  </div>
@@ -7,7 +7,7 @@
7
7
  * The `<form>` has no `action` yet. Bind a server action from a
8
8
  * `'use server'` file (`<form action=${saveSocialLinks}>`) before you ship it.
9
9
  */
10
- import { html } from '@webjsdev/core';
10
+ import { html, nothing } from '@webjsdev/core';
11
11
  import type { TemplateResult } from '@webjsdev/core';
12
12
  import { buttonClass } from '../../../components/button.ts';
13
13
  import {
@@ -41,7 +41,7 @@ export function socialLinks() {
41
41
  <div role="group" data-slot="field" data-orientation="vertical" class=${fieldClass()}>
42
42
  <label data-slot="field-label" for=${link.id} class=${fieldLabelClass()}>${link.label}</label>
43
43
  <div data-slot="input-group" role="group" class=${inputGroupClass()}>
44
- <input data-slot="input-group-control" id=${link.id} name=${link.id} value=${link.value ?? null} placeholder=${link.placeholder ?? null} class=${inputGroupInputClass()}>
44
+ <input data-slot="input-group-control" id=${link.id} name=${link.id} value=${link.value ?? nothing} placeholder=${link.placeholder ?? nothing} class=${inputGroupInputClass()}>
45
45
  <label data-slot="input-group-addon" data-align="inline-start" for=${link.id} class=${inputGroupAddonClass()}>${link.icon()}</label>
46
46
  </div>
47
47
  </div>
@@ -10,7 +10,7 @@
10
10
  * `components/preview-03/chat-card.ts`. Ported from shadcn/ui's preview-03,
11
11
  * where each card repeats this frame inline.
12
12
  */
13
- import { html } from '@webjsdev/core';
13
+ import { html, nothing } from '@webjsdev/core';
14
14
  import { cn } from '../../../lib/utils.ts';
15
15
  import { buttonClass } from '../../../components/button.ts';
16
16
  import {
@@ -51,12 +51,12 @@ export function chatItem(opts: {
51
51
  }) {
52
52
  return html`<ui-message-scroller-item
53
53
  hidden
54
- message-id=${opts.id ?? null}
54
+ message-id=${opts.id ?? nothing}
55
55
  ?scroll-anchor=${!!opts.anchor}
56
56
  data-chat-turn=${opts.turn}
57
57
  data-chat-kind=${opts.kind}
58
- data-chat-delay=${opts.delay ?? null}
59
- data-chat-name=${opts.name ?? null}
58
+ data-chat-delay=${opts.delay ?? nothing}
59
+ data-chat-name=${opts.name ?? nothing}
60
60
  >${opts.content}</ui-message-scroller-item>`;
61
61
  }
62
62
 
@@ -71,7 +71,7 @@ export function chatMessage(opts: { role: 'user' | 'assistant'; align: 'start' |
71
71
  /** A marker in the transcript, a join or leave notice or a typing line. */
72
72
  export function chatMarker(opts: { text: string; variant?: 'default' | 'separator'; status?: boolean; icon?: unknown }) {
73
73
  const variant = opts.variant ?? 'default';
74
- return html`<div data-slot="marker" data-variant=${variant} role=${opts.status ? 'status' : null} class=${markerClass({ variant })}>
74
+ return html`<div data-slot="marker" data-variant=${variant} role=${opts.status ? 'status' : nothing} class=${markerClass({ variant })}>
75
75
  ${opts.icon ? html`<span data-slot="marker-icon" class=${markerIconClass()}>${opts.icon}</span>` : ''}
76
76
  <span data-slot="marker-content" class=${markerContentClass()}>${opts.text}</span>
77
77
  </div>`;
@@ -6,7 +6,7 @@
6
6
  * `components/preview-03/part-reasoning.ts`. Ported from shadcn/ui's
7
7
  * preview-03.
8
8
  */
9
- import { html } from '@webjsdev/core';
9
+ import { html, nothing } from '@webjsdev/core';
10
10
  import { cn } from '../../../lib/utils.ts';
11
11
 
12
12
  const REASONING_LEVEL_LABELS = ['Understanding', 'Analyzing', 'Prioritizing', 'Refining'] as const;
@@ -17,7 +17,7 @@ export function getReasoningLevelLabel(index: number): string {
17
17
  }
18
18
 
19
19
  export function partReasoning(opts: { text: string; label?: string; class?: string }) {
20
- return html`<div data-slot="part-reasoning" data-label=${opts.label ?? null} class=${cn('w-full max-w-full min-w-0', opts.class)}>
20
+ return html`<div data-slot="part-reasoning" data-label=${opts.label ?? nothing} class=${cn('w-full max-w-full min-w-0', opts.class)}>
21
21
  <div class="w-full shimmer">
22
22
  <span class="whitespace-pre-wrap">${opts.text}</span>
23
23
  </div>
@@ -8,7 +8,7 @@
8
8
  * The data below is sample data. Replace it with your own, point each `url`
9
9
  * at a real route, and mark the current page with `isActive`.
10
10
  */
11
- import { html } from '@webjsdev/core';
11
+ import { html, nothing } from '@webjsdev/core';
12
12
  import {
13
13
  sidebarContentClass,
14
14
  sidebarGroupClass,
@@ -99,7 +99,7 @@ export function appSidebar() {
99
99
  <ul data-slot="sidebar-menu" data-sidebar="menu" class=${sidebarMenuClass()}>
100
100
  ${section.items.map((item) => html`
101
101
  <li data-slot="sidebar-menu-item" data-sidebar="menu-item" class=${sidebarMenuItemClass()}>
102
- <a href=${item.url} data-slot="sidebar-menu-button" data-sidebar="menu-button" data-size="default" data-active=${item.isActive ? 'true' : 'false'} aria-current=${item.isActive ? 'page' : null} class=${sidebarMenuButtonClass()}>${item.title}</a>
102
+ <a href=${item.url} data-slot="sidebar-menu-button" data-sidebar="menu-button" data-size="default" data-active=${item.isActive ? 'true' : 'false'} aria-current=${item.isActive ? 'page' : nothing} class=${sidebarMenuButtonClass()}>${item.title}</a>
103
103
  </li>
104
104
  `)}
105
105
  </ul>
@@ -8,7 +8,7 @@
8
8
  * file (`<form action=${search}>`). Until then a submit reloads the page with
9
9
  * `?q=` in the URL.
10
10
  */
11
- import { html } from '@webjsdev/core';
11
+ import { html, nothing } from '@webjsdev/core';
12
12
  import { cn } from '../../../lib/utils.ts';
13
13
  import { labelClass } from '../../../components/label.ts';
14
14
  import {
@@ -20,7 +20,7 @@ import { SearchIcon } from '../../../lib/icons.ts';
20
20
 
21
21
  export function searchForm(opts: { class?: string } = {}) {
22
22
  return html`
23
- <form role="search" class=${opts.class ?? null}>
23
+ <form role="search" class=${opts.class ?? nothing}>
24
24
  <div data-slot="sidebar-group" data-sidebar="group" class=${cn(sidebarGroupClass(), 'py-0')}>
25
25
  <div data-slot="sidebar-group-content" data-sidebar="group-content" class=${cn(sidebarGroupContentClass(), 'relative')}>
26
26
  <label data-slot="label" for="search" class=${cn(labelClass(), 'sr-only')}>Search</label>
@@ -9,7 +9,7 @@
9
9
  * The data below is sample data. Replace it with your own, point each `url`
10
10
  * at a real route, and mark the current page with `isActive`.
11
11
  */
12
- import { html } from '@webjsdev/core';
12
+ import { html, nothing } from '@webjsdev/core';
13
13
  import { cn } from '../../../lib/utils.ts';
14
14
  import { collapsibleClass, collapsibleContentClass } from '../../../components/collapsible.ts';
15
15
  import {
@@ -115,7 +115,7 @@ export function appSidebar() {
115
115
  <ul data-slot="sidebar-menu" data-sidebar="menu" class=${sidebarMenuClass()}>
116
116
  ${section.items.map((item) => html`
117
117
  <li data-slot="sidebar-menu-item" data-sidebar="menu-item" class=${sidebarMenuItemClass()}>
118
- <a href=${item.url} data-slot="sidebar-menu-button" data-sidebar="menu-button" data-size="default" data-active=${item.isActive ? 'true' : 'false'} aria-current=${item.isActive ? 'page' : null} class=${sidebarMenuButtonClass()}>${item.title}</a>
118
+ <a href=${item.url} data-slot="sidebar-menu-button" data-sidebar="menu-button" data-size="default" data-active=${item.isActive ? 'true' : 'false'} aria-current=${item.isActive ? 'page' : nothing} class=${sidebarMenuButtonClass()}>${item.title}</a>
119
119
  </li>
120
120
  `)}
121
121
  </ul>
@@ -8,7 +8,7 @@
8
8
  * file (`<form action=${search}>`). Until then a submit reloads the page with
9
9
  * `?q=` in the URL.
10
10
  */
11
- import { html } from '@webjsdev/core';
11
+ import { html, nothing } from '@webjsdev/core';
12
12
  import { cn } from '../../../lib/utils.ts';
13
13
  import { labelClass } from '../../../components/label.ts';
14
14
  import {
@@ -20,7 +20,7 @@ import { SearchIcon } from '../../../lib/icons.ts';
20
20
 
21
21
  export function searchForm(opts: { class?: string } = {}) {
22
22
  return html`
23
- <form role="search" class=${opts.class ?? null}>
23
+ <form role="search" class=${opts.class ?? nothing}>
24
24
  <div data-slot="sidebar-group" data-sidebar="group" class=${cn(sidebarGroupClass(), 'py-0')}>
25
25
  <div data-slot="sidebar-group-content" data-sidebar="group-content" class=${cn(sidebarGroupContentClass(), 'relative')}>
26
26
  <label data-slot="label" for="search" class=${cn(labelClass(), 'sr-only')}>Search</label>
@@ -7,7 +7,7 @@
7
7
  * The data below is sample data. Replace it with your own, point each `url`
8
8
  * at a real route, and mark the current page with `isActive`.
9
9
  */
10
- import { html } from '@webjsdev/core';
10
+ import { html, nothing } from '@webjsdev/core';
11
11
  import { cn } from '../../../lib/utils.ts';
12
12
  import {
13
13
  sidebarContentClass,
@@ -118,7 +118,7 @@ export function appSidebar() {
118
118
  <ul data-slot="sidebar-menu-sub" data-sidebar="menu-sub" class=${sidebarMenuSubClass()}>
119
119
  ${section.items.map((item) => html`
120
120
  <li data-slot="sidebar-menu-sub-item" data-sidebar="menu-sub-item" class=${sidebarMenuSubItemClass()}>
121
- <a href=${item.url} data-slot="sidebar-menu-sub-button" data-sidebar="menu-sub-button" data-size="md" data-active=${item.isActive ? 'true' : 'false'} aria-current=${item.isActive ? 'page' : null} class=${sidebarMenuSubButtonClass()}>${item.title}</a>
121
+ <a href=${item.url} data-slot="sidebar-menu-sub-button" data-sidebar="menu-sub-button" data-size="md" data-active=${item.isActive ? 'true' : 'false'} aria-current=${item.isActive ? 'page' : nothing} class=${sidebarMenuSubButtonClass()}>${item.title}</a>
122
122
  </li>
123
123
  `)}
124
124
  </ul>
@@ -10,7 +10,7 @@
10
10
  * The data below is sample data. Replace it with your own, point each `url`
11
11
  * at a real route, and mark the current page with `isActive`.
12
12
  */
13
- import { html } from '@webjsdev/core';
13
+ import { html, nothing } from '@webjsdev/core';
14
14
  import { cn } from '../../../lib/utils.ts';
15
15
  import {
16
16
  sidebarContentClass,
@@ -95,7 +95,7 @@ const data: { navMain: NavSection[] } = {
95
95
 
96
96
  export function appSidebar(opts: { class?: string } = {}) {
97
97
  return html`
98
- <ui-sidebar variant="floating" class=${opts.class ?? null}>
98
+ <ui-sidebar variant="floating" class=${opts.class ?? nothing}>
99
99
  <div data-slot="sidebar-header" data-sidebar="header" class=${sidebarHeaderClass()}>
100
100
  <ul data-slot="sidebar-menu" data-sidebar="menu" class=${sidebarMenuClass()}>
101
101
  <li data-slot="sidebar-menu-item" data-sidebar="menu-item" class=${sidebarMenuItemClass()}>
@@ -121,7 +121,7 @@ export function appSidebar(opts: { class?: string } = {}) {
121
121
  <ul data-slot="sidebar-menu-sub" data-sidebar="menu-sub" class=${cn(sidebarMenuSubClass(), 'ms-0 border-s-0 px-1.5')}>
122
122
  ${section.items.map((item) => html`
123
123
  <li data-slot="sidebar-menu-sub-item" data-sidebar="menu-sub-item" class=${sidebarMenuSubItemClass()}>
124
- <a href=${item.url} data-slot="sidebar-menu-sub-button" data-sidebar="menu-sub-button" data-size="md" data-active=${item.isActive ? 'true' : 'false'} aria-current=${item.isActive ? 'page' : null} class=${sidebarMenuSubButtonClass()}>${item.title}</a>
124
+ <a href=${item.url} data-slot="sidebar-menu-sub-button" data-sidebar="menu-sub-button" data-size="md" data-active=${item.isActive ? 'true' : 'false'} aria-current=${item.isActive ? 'page' : nothing} class=${sidebarMenuSubButtonClass()}>${item.title}</a>
125
125
  </li>
126
126
  `)}
127
127
  </ul>
@@ -9,7 +9,7 @@
9
9
  * The data below is sample data. Replace it with your own, point each `url`
10
10
  * at a real route, and mark the current page with `isActive`.
11
11
  */
12
- import { html } from '@webjsdev/core';
12
+ import { html, nothing } from '@webjsdev/core';
13
13
  import { cn } from '../../../lib/utils.ts';
14
14
  import { collapsibleClass, collapsibleContentClass } from '../../../components/collapsible.ts';
15
15
  import { searchForm } from './search-form.ts';
@@ -129,7 +129,7 @@ export function appSidebar() {
129
129
  <ul data-slot="sidebar-menu-sub" data-sidebar="menu-sub" class=${sidebarMenuSubClass()}>
130
130
  ${section.items.map((item) => html`
131
131
  <li data-slot="sidebar-menu-sub-item" data-sidebar="menu-sub-item" class=${sidebarMenuSubItemClass()}>
132
- <a href=${item.url} data-slot="sidebar-menu-sub-button" data-sidebar="menu-sub-button" data-size="md" data-active=${item.isActive ? 'true' : 'false'} aria-current=${item.isActive ? 'page' : null} class=${sidebarMenuSubButtonClass()}>${item.title}</a>
132
+ <a href=${item.url} data-slot="sidebar-menu-sub-button" data-sidebar="menu-sub-button" data-size="md" data-active=${item.isActive ? 'true' : 'false'} aria-current=${item.isActive ? 'page' : nothing} class=${sidebarMenuSubButtonClass()}>${item.title}</a>
133
133
  </li>
134
134
  `)}
135
135
  </ul>
@@ -8,7 +8,7 @@
8
8
  * file (`<form action=${search}>`). Until then a submit reloads the page with
9
9
  * `?q=` in the URL.
10
10
  */
11
- import { html } from '@webjsdev/core';
11
+ import { html, nothing } from '@webjsdev/core';
12
12
  import { cn } from '../../../lib/utils.ts';
13
13
  import { labelClass } from '../../../components/label.ts';
14
14
  import {
@@ -20,7 +20,7 @@ import { SearchIcon } from '../../../lib/icons.ts';
20
20
 
21
21
  export function searchForm(opts: { class?: string } = {}) {
22
22
  return html`
23
- <form role="search" class=${opts.class ?? null}>
23
+ <form role="search" class=${opts.class ?? nothing}>
24
24
  <div data-slot="sidebar-group" data-sidebar="group" class=${cn(sidebarGroupClass(), 'py-0')}>
25
25
  <div data-slot="sidebar-group-content" data-sidebar="group-content" class=${cn(sidebarGroupContentClass(), 'relative')}>
26
26
  <label data-slot="label" for="search" class=${cn(labelClass(), 'sr-only')}>Search</label>
@@ -6,7 +6,7 @@
6
6
  * Each page is a real link inside its menu item, so a click follows it. Give
7
7
  * the item for the current page `aria-current="page"` on its link.
8
8
  */
9
- import { html } from '@webjsdev/core';
9
+ import { html, nothing } from '@webjsdev/core';
10
10
  import type { TemplateResult } from '@webjsdev/core';
11
11
  import { cn } from '../../../lib/utils.ts';
12
12
  import '../../../components/dropdown-menu.ts';
@@ -42,7 +42,7 @@ export function navMain({ items }: { items: NavMainItem[] }) {
42
42
  ? html`<ui-dropdown-menu-content class="min-w-56 rounded-lg" side="right" align="start">
43
43
  ${item.items.map((sub) => html`
44
44
  <ui-dropdown-menu-item>
45
- <a href=${sub.url} class="flex-1 text-inherit no-underline" aria-current=${sub.isActive ? 'page' : null}>${sub.title}</a>
45
+ <a href=${sub.url} class="flex-1 text-inherit no-underline" aria-current=${sub.isActive ? 'page' : nothing}>${sub.title}</a>
46
46
  </ui-dropdown-menu-item>
47
47
  `)}
48
48
  </ui-dropdown-menu-content>`
@@ -30,7 +30,7 @@ const data: {
30
30
  user: {
31
31
  name: 'vivek',
32
32
  email: 'vivek@example.com',
33
- avatar: 'https://webjs.dev/public/avatars/vivek.jpg',
33
+ avatar: 'https://webjs.dev/avatars/vivek.jpg',
34
34
  },
35
35
  teams: [
36
36
  { name: 'Acme Inc', logo: GalleryVerticalEndIcon, plan: 'Enterprise' },
@@ -33,7 +33,7 @@ const data: {
33
33
  user: {
34
34
  name: 'vivek',
35
35
  email: 'vivek@example.com',
36
- avatar: 'https://webjs.dev/public/avatars/vivek.jpg',
36
+ avatar: 'https://webjs.dev/avatars/vivek.jpg',
37
37
  },
38
38
  navMain: [
39
39
  {
@@ -14,7 +14,7 @@
14
14
  *
15
15
  * The data below is sample data. Replace it with your own.
16
16
  */
17
- import { html } from '@webjsdev/core';
17
+ import { html, nothing } from '@webjsdev/core';
18
18
  import type { TemplateResult } from '@webjsdev/core';
19
19
  import { cn } from '../../../lib/utils.ts';
20
20
  import { labelClass } from '../../../components/label.ts';
@@ -57,7 +57,7 @@ const data: {
57
57
  user: {
58
58
  name: 'vivek',
59
59
  email: 'vivek@example.com',
60
- avatar: 'https://webjs.dev/public/avatars/vivek.jpg',
60
+ avatar: 'https://webjs.dev/avatars/vivek.jpg',
61
61
  },
62
62
  navMain: [
63
63
  { title: 'Inbox', url: '#', icon: InboxIcon, isActive: true },
@@ -166,7 +166,7 @@ export function appSidebar() {
166
166
  <ul data-slot="sidebar-menu" data-sidebar="menu" class=${sidebarMenuClass()}>
167
167
  ${data.navMain.map((item) => html`
168
168
  <li data-slot="sidebar-menu-item" data-sidebar="menu-item" class=${sidebarMenuItemClass()}>
169
- <a href=${item.url} title=${item.title} data-slot="sidebar-menu-button" data-sidebar="menu-button" data-size="default" data-active=${item === active ? 'true' : 'false'} aria-current=${item === active ? 'page' : null} class=${cn(sidebarMenuButtonClass(), 'px-2.5 md:px-2')}>
169
+ <a href=${item.url} title=${item.title} data-slot="sidebar-menu-button" data-sidebar="menu-button" data-size="default" data-active=${item === active ? 'true' : 'false'} aria-current=${item === active ? 'page' : nothing} class=${cn(sidebarMenuButtonClass(), 'px-2.5 md:px-2')}>
170
170
  ${item.icon()}
171
171
  <span>${item.title}</span>
172
172
  </a>
@@ -5,7 +5,7 @@
5
5
  * Mark the current page's link `data-active="true"` and `aria-current="page"`
6
6
  * (the `isActive` field below does it for the sample).
7
7
  */
8
- import { html } from '@webjsdev/core';
8
+ import { html, nothing } from '@webjsdev/core';
9
9
  import type { TemplateResult } from '@webjsdev/core';
10
10
  import {
11
11
  sidebarMenuButtonClass,
@@ -26,7 +26,7 @@ export function navMain({ items }: { items: NavMainItem[] }) {
26
26
  <ul data-slot="sidebar-menu" data-sidebar="menu" class=${sidebarMenuClass()}>
27
27
  ${items.map((item) => html`
28
28
  <li data-slot="sidebar-menu-item" data-sidebar="menu-item" class=${sidebarMenuItemClass()}>
29
- <a href=${item.url} data-slot="sidebar-menu-button" data-sidebar="menu-button" data-size="default" data-active=${item.isActive ? 'true' : 'false'} aria-current=${item.isActive ? 'page' : null} class=${sidebarMenuButtonClass()}>
29
+ <a href=${item.url} data-slot="sidebar-menu-button" data-sidebar="menu-button" data-size="default" data-active=${item.isActive ? 'true' : 'false'} aria-current=${item.isActive ? 'page' : nothing} class=${sidebarMenuButtonClass()}>
30
30
  ${item.icon()}
31
31
  <span>${item.title}</span>
32
32
  </a>
@@ -27,7 +27,7 @@ const data = {
27
27
  user: {
28
28
  name: 'vivek',
29
29
  email: 'vivek@example.com',
30
- avatar: 'https://webjs.dev/public/avatars/vivek.jpg',
30
+ avatar: 'https://webjs.dev/avatars/vivek.jpg',
31
31
  },
32
32
  calendars: [
33
33
  { name: 'My Calendars', items: ['Personal', 'Work', 'Family'] },
@@ -10,7 +10,7 @@
10
10
  * for its trigger. "Messages & media" is the current section. Point each
11
11
  * `href` at a real settings route and mark the current one from your router.
12
12
  */
13
- import { html, cspNonce } from '@webjsdev/core';
13
+ import { html, cspNonce, nothing } from '@webjsdev/core';
14
14
  import type { TemplateResult } from '@webjsdev/core';
15
15
  import { cn } from '../../../lib/utils.ts';
16
16
  import {
@@ -69,7 +69,7 @@ export function settingsDialog() {
69
69
  const active = item.name === 'Messages & media';
70
70
  return html`
71
71
  <li data-slot="sidebar-menu-item" data-sidebar="menu-item" class=${sidebarMenuItemClass()}>
72
- <a href="#" data-slot="sidebar-menu-button" data-sidebar="menu-button" data-size="default" data-active=${active ? 'true' : 'false'} aria-current=${active ? 'page' : null} class=${sidebarMenuButtonClass()}>
72
+ <a href="#" data-slot="sidebar-menu-button" data-sidebar="menu-button" data-size="default" data-active=${active ? 'true' : 'false'} aria-current=${active ? 'page' : nothing} class=${sidebarMenuButtonClass()}>
73
73
  ${item.icon()}
74
74
  <span>${item.name}</span>
75
75
  </a>
@@ -7,7 +7,7 @@
7
7
  * The data below is sample data. Replace it with your own, point each `url`
8
8
  * at a real route, and mark the current page from your router.
9
9
  */
10
- import { html } from '@webjsdev/core';
10
+ import { html, nothing } from '@webjsdev/core';
11
11
  import { cn } from '../../../lib/utils.ts';
12
12
  import {
13
13
  sidebarContentClass,
@@ -103,7 +103,7 @@ export function appSidebar(opts: { side?: 'left' | 'right' } = {}) {
103
103
  ? html`<ul data-slot="sidebar-menu-sub" data-sidebar="menu-sub" class=${sidebarMenuSubClass()}>
104
104
  ${item.items.map((sub) => html`
105
105
  <li data-slot="sidebar-menu-sub-item" data-sidebar="menu-sub-item" class=${sidebarMenuSubItemClass()}>
106
- <a href=${sub.url} data-slot="sidebar-menu-sub-button" data-sidebar="menu-sub-button" data-size="md" data-active=${sub.isActive ? 'true' : 'false'} aria-current=${sub.isActive ? 'page' : null} class=${sidebarMenuSubButtonClass()}>${sub.title}</a>
106
+ <a href=${sub.url} data-slot="sidebar-menu-sub-button" data-sidebar="menu-sub-button" data-size="md" data-active=${sub.isActive ? 'true' : 'false'} aria-current=${sub.isActive ? 'page' : nothing} class=${sidebarMenuSubButtonClass()}>${sub.title}</a>
107
107
  </li>
108
108
  `)}
109
109
  </ul>`
@@ -3,7 +3,7 @@
3
3
  * home, inbox) under the team switcher. A fragment helper. Ported from
4
4
  * shadcn/ui's sidebar-15.
5
5
  */
6
- import { html } from '@webjsdev/core';
6
+ import { html, nothing } from '@webjsdev/core';
7
7
  import type { TemplateResult } from '@webjsdev/core';
8
8
  import {
9
9
  sidebarMenuButtonClass,
@@ -24,7 +24,7 @@ export function navMain({ items }: { items: NavMainItem[] }) {
24
24
  <ul data-slot="sidebar-menu" data-sidebar="menu" class=${sidebarMenuClass()}>
25
25
  ${items.map((item) => html`
26
26
  <li data-slot="sidebar-menu-item" data-sidebar="menu-item" class=${sidebarMenuItemClass()}>
27
- <a href=${item.url} data-slot="sidebar-menu-button" data-sidebar="menu-button" data-size="default" data-active=${item.isActive ? 'true' : 'false'} aria-current=${item.isActive ? 'page' : null} class=${sidebarMenuButtonClass()}>
27
+ <a href=${item.url} data-slot="sidebar-menu-button" data-sidebar="menu-button" data-size="default" data-active=${item.isActive ? 'true' : 'false'} aria-current=${item.isActive ? 'page' : nothing} class=${sidebarMenuButtonClass()}>
28
28
  ${item.icon()}
29
29
  <span>${item.title}</span>
30
30
  </a>
@@ -27,7 +27,7 @@ const data = {
27
27
  user: {
28
28
  name: 'vivek',
29
29
  email: 'vivek@example.com',
30
- avatar: 'https://webjs.dev/public/avatars/vivek.jpg',
30
+ avatar: 'https://webjs.dev/avatars/vivek.jpg',
31
31
  },
32
32
  calendars: [
33
33
  { name: 'My Calendars', items: ['Personal', 'Work', 'Family'] },
@@ -33,7 +33,7 @@ const data: {
33
33
  user: {
34
34
  name: 'vivek',
35
35
  email: 'vivek@example.com',
36
- avatar: 'https://webjs.dev/public/avatars/vivek.jpg',
36
+ avatar: 'https://webjs.dev/avatars/vivek.jpg',
37
37
  },
38
38
  navMain: [
39
39
  {
@@ -8,7 +8,7 @@
8
8
  * file (`<form action=${search}>`). Until then a submit reloads the page with
9
9
  * `?q=` in the URL.
10
10
  */
11
- import { html } from '@webjsdev/core';
11
+ import { html, nothing } from '@webjsdev/core';
12
12
  import { cn } from '../../../lib/utils.ts';
13
13
  import { labelClass } from '../../../components/label.ts';
14
14
  import { sidebarInputClass } from '../../../components/sidebar.ts';
@@ -16,7 +16,7 @@ import { SearchIcon } from '../../../lib/icons.ts';
16
16
 
17
17
  export function searchForm(opts: { class?: string } = {}) {
18
18
  return html`
19
- <form role="search" class=${opts.class ?? null}>
19
+ <form role="search" class=${opts.class ?? nothing}>
20
20
  <div class="relative">
21
21
  <label data-slot="label" for="search" class=${cn(labelClass(), 'sr-only')}>Search</label>
22
22
  <input data-slot="sidebar-input" data-sidebar="input" id="search" name="q" placeholder="Type to search..." class=${cn(sidebarInputClass(), 'h-8 pl-7')}>
@@ -29,7 +29,7 @@ export default function SignupPage() {
29
29
  </div>
30
30
  </div>
31
31
  <div class="relative hidden bg-muted lg:block">
32
- <img src="/public/placeholder.svg" alt="" class="absolute inset-0 h-full w-full object-cover dark:brightness-[0.2] dark:grayscale">
32
+ <img src="/placeholder.svg" alt="" class="absolute inset-0 h-full w-full object-cover dark:brightness-[0.2] dark:grayscale">
33
33
  </div>
34
34
  </div>
35
35
  `;
@@ -88,7 +88,7 @@ export function signupForm(opts: { class?: string } = {}) {
88
88
  </div>
89
89
  </form>
90
90
  <div class="relative hidden bg-muted md:block">
91
- <img src="/public/placeholder.svg" alt="" class="absolute inset-0 h-full w-full object-cover dark:brightness-[0.2] dark:grayscale">
91
+ <img src="/placeholder.svg" alt="" class="absolute inset-0 h-full w-full object-cover dark:brightness-[0.2] dark:grayscale">
92
92
  </div>
93
93
  </div>
94
94
  </div>
@@ -1,8 +1,7 @@
1
1
  // Client-only DOM helpers. Kept SEPARATE from lib/utils.ts because these
2
- // reference `document`: any module that references a client global is treated
3
- // as client-effecting by the elision analyzer, so bundling this with the pure
4
- // `cn()` would pin every page that imports `cn` to the browser (#819). Import
5
- // these ONLY from components (which ship anyway), never from a page/layout.
2
+ // reference `document`, while the pure `cn()` is imported by pages and layouts
3
+ // too, which run only on the server (#819). Import these ONLY from components
4
+ // (which ship to the browser), never from a page/layout.
6
5
 
7
6
  /**
8
7
  * Run `reset` just before the webjs client router snapshots the page into its
@@ -50,10 +50,9 @@ function walk(value: ClassValue, out: string[]): void {
50
50
  // IMPORTANT: text-size (text-sm, text-xs, text-base, text-lg, …) and
51
51
  // text-color (text-primary, text-foreground, …) are DIFFERENT properties
52
52
  // and must be in different groups. Same for bg-size vs bg-color etc.
53
- // Built on FIRST USE, not at module load. A module-scope `...borderGroups()`
54
- // spread is a real top-level call, so the elision analyser reads this module as
55
- // client-effecting and every page that reaches `cn` on a component-free path
56
- // ships whole instead of being elided (#1320).
53
+ // Built on FIRST USE, not at module load, so a module that imports `cn` does no
54
+ // work at load and a page that never merges a class never builds the table
55
+ // (#1320).
57
56
  let _groups: Array<[RegExp, string]> | undefined;
58
57
  function GROUPS(): Array<[RegExp, string]> {
59
58
  return (_groups ??= [
@@ -99,8 +99,13 @@ export const add = new Command()
99
99
  // @webjsdev/core is always a runtime dep
100
100
  if (!dependencies.includes('@webjsdev/core')) dependencies.push('@webjsdev/core');
101
101
 
102
- if (dependencies.length) await installDeps(cwd, dependencies, false);
103
- if (devDependencies.length) await installDeps(cwd, devDependencies, true);
102
+ // Install only what the app does not already declare. Most components need
103
+ // nothing beyond @webjsdev/core, which every WebJs app has, and re-installing
104
+ // it ran a full install and rewrote the app's version range on every add.
105
+ const missing = undeclaredDeps(cwd, dependencies);
106
+ const missingDev = undeclaredDeps(cwd, devDependencies);
107
+ if (missing.length) await installDeps(cwd, missing, false);
108
+ if (missingDev.length) await installDeps(cwd, missingDev, true);
104
109
  }
105
110
 
106
111
  logger.success('Done.');
@@ -471,6 +476,29 @@ function ensureDir(d) {
471
476
  if (!existsSync(d)) mkdirSync(d, { recursive: true });
472
477
  }
473
478
 
479
+ /**
480
+ * The dependency specs (`name` or `name@range`) that the app's package.json does
481
+ * not already declare in any dependency field. A missing or unreadable
482
+ * package.json declares nothing, so every spec is returned.
483
+ * @param {string} cwd
484
+ * @param {string[]} specs
485
+ * @returns {string[]}
486
+ */
487
+ export function undeclaredDeps(cwd, specs) {
488
+ let pkg = {};
489
+ try { pkg = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf8')); } catch {}
490
+ const declared = new Set();
491
+ for (const field of ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']) {
492
+ for (const name of Object.keys(pkg?.[field] || {})) declared.add(name);
493
+ }
494
+ // A scoped name starts with @, so the version separator is the LAST @ past it.
495
+ const nameOf = (spec) => {
496
+ const at = spec.lastIndexOf('@');
497
+ return at > 0 ? spec.slice(0, at) : spec;
498
+ };
499
+ return specs.filter((spec) => !declared.has(nameOf(spec)));
500
+ }
501
+
474
502
  async function installDeps(cwd, deps, dev) {
475
503
  const manager = detectAddCommand(cwd);
476
504
  const flag = dev ? '-D' : '';
@@ -280,8 +280,8 @@ async function writeLibUtils(cwd, utilsAlias, registryUrl, overwrite) {
280
280
  }
281
281
 
282
282
  // The onBeforeCache() DOM helper lives in a SEPARATE module (#819) because
283
- // it references `document`, so keeping it out of cn()'s file prevents the
284
- // elision analyzer pinning every page that imports cn to the browser.
283
+ // it references `document`, so keeping it out of cn()'s file keeps cn()
284
+ // free of browser globals for the pages and layouts that import it.
285
285
  // Overlay components import it from `../lib/dom.ts`, so write it as a
286
286
  // sibling of the utils file, which lands it at lib/utils/dom.ts next to
287
287
  // cn.ts. `add` reads that same adjacency when it rewrites the import.
@@ -11,7 +11,7 @@
11
11
  * invented: a helper matching neither authored shape yields no value list.
12
12
  *
13
13
  * `allow` takes category names and class-group ids. The documented starting
14
- * configuration is `["layout", "rounded"]`, which is what lets the skill's
14
+ * configuration is `["layout", "rounded"]`, which is what lets the kit's
15
15
  * sanctioned one-off (`cn(buttonClass({ size: 'none' }), 'w-9 h-9
16
16
  * rounded-full')`) pass: `w-9 h-9` is layout, and `rounded-full` is the plain
17
17
  * radius group granted by name without opening the whole `shape` category.
@@ -291,9 +291,8 @@ export function uiComponentFiles(name, opts = {}) {
291
291
 
292
292
  /**
293
293
  * The kit inventory: one compact entry per `registry:ui` component (name, tier,
294
- * helper signatures, npm deps). The no-args payload for the MCP `ui` tool and
295
- * the `references/ui-kit.md` skill surface, so an agent reaches for a helper
296
- * instead of expanding Tailwind by hand.
294
+ * helper signatures, npm deps). The no-args payload for the MCP `ui` tool, so
295
+ * an agent reaches for a helper instead of expanding Tailwind by hand.
297
296
  *
298
297
  * @returns {Array<{ name: string, tier: 1|2, helpers: string[], dependencies: string[] }>}
299
298
  */