@webjsdev/mcp 0.1.9 → 0.1.11
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.
- package/README.md +4 -0
- package/package.json +4 -3
- package/resources/AGENTS.md +115 -44
- package/resources/SKILL.md +34 -16
- package/resources/references/auth-and-sessions.md +86 -21
- package/resources/references/built-ins.md +55 -9
- package/resources/references/client-router-and-streaming.md +59 -9
- package/resources/references/components.md +117 -8
- package/resources/references/data-and-actions.md +63 -12
- package/resources/references/muscle-memory-gotchas.md +104 -3
- package/resources/references/optimistic-ui.md +49 -10
- package/resources/references/routing-and-pages.md +62 -18
- package/resources/references/runtime.md +10 -1
- package/resources/references/service-worker.md +5 -3
- package/resources/references/styling.md +116 -2
- package/resources/references/testing.md +67 -9
- package/resources/references/typescript.md +75 -6
- package/resources/references/ui-kit.md +76 -0
- package/src/mcp.js +75 -2
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
- Design tokens: `:root` / `@theme` in the root layout
|
|
9
9
|
- Light-DOM host `display: block` behaviour (and shadow hosts via `:host`)
|
|
10
10
|
- When to use `static styles` (shadow DOM)
|
|
11
|
+
- Accessible native controls (label association, `aria-pressed`, `aria-label`)
|
|
11
12
|
- `position: fixed`, not `sticky`, for a pinned header (the iOS WebKit flicker)
|
|
12
13
|
- Even-grid / no-reflow layout tips
|
|
13
14
|
|
|
@@ -67,9 +68,82 @@ export default function Post({ params }) {
|
|
|
67
68
|
|
|
68
69
|
Avoid `@apply`: it hides which utilities a class uses and creates a second source of truth. A JS helper keeps the bundle visible at the definition site, composes with conditional classes and active states, and runs at SSR time.
|
|
69
70
|
|
|
70
|
-
|
|
71
|
+
### A design system for repeated PRIMITIVES: class helpers built on `@webjsdev/ui`
|
|
71
72
|
|
|
72
|
-
|
|
73
|
+
An `html`-fragment helper is right for a repeated CHUNK of markup (the rubric above). For a repeated UI PRIMITIVE (button, input, card, badge) that needs variants and sizes, use a class helper instead: a function that returns a Tailwind class STRING you spread onto a native element. That is exactly what `@webjsdev/ui` ships (`buttonClass({ variant, size })`, `cardClass()`, `inputClass()`, `badgeClass({ variant })`), and it is what the scaffold gallery uses in `components/ui/`. To style a ONE-OFF that a variant does not cover (a circular icon button, a pill), compose the helper and override the bespoke bits with `cn()`: `cn(buttonClass({ variant: 'secondary', size: 'none' }), 'w-9 h-9 rounded-full')`. `cn` resolves Tailwind conflicts so a later class wins, including a shorthand over the axis it subsumes (`p-0` beats an earlier `px-4 py-2`), so an override just works. Conflicts are keyed on the CSS PROPERTY wherever `cn` can tell the properties apart, rather than on the shared class prefix, so the common prefix collisions do NOT evict: `cn('border-2', 'border-primary')` keeps both (a width and a colour), `cn('flex', 'flex-1')` keeps both (a `display` and a `flex-grow`, the shape an element that is both a flex container and a flex child needs), and an arbitrary value carrying a type hint is read as the property the hint names (`cn('shadow-lg', 'shadow-[color:red]')` keeps both). It is a small hand-rolled merger, not `tailwind-merge`, so some prefixes are still grouped coarsely and a less common pair can collide (`bg-clip-text` against `bg-primary`, `shadow-lg` against `shadow-red-500`). When an override has to win and you are unsure, pass the one class rather than layering, or install `clsx` + `tailwind-merge` and replace the helper (its header comment shows the swap). For an icon button prefer `size: 'none'` (it states "I supply my own box" by dropping the helper's padding + radius) over layering a `p-0` on top of the default size.
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
// components/ui/button.ts (npx webjsdev ui add button, themed to your app)
|
|
77
|
+
import { cn } from '#lib/utils/cn.ts';
|
|
78
|
+
const BASE = 'inline-flex cursor-pointer items-center justify-center ...';
|
|
79
|
+
const VARIANTS = { default: 'bg-primary text-primary-foreground ...', secondary: '...' } as const;
|
|
80
|
+
const SIZES = { default: 'px-4 py-2 rounded-xl', sm: '...' } as const;
|
|
81
|
+
export function buttonClass(o: { variant?: keyof typeof VARIANTS; size?: keyof typeof SIZES } = {}) {
|
|
82
|
+
return cn(BASE, VARIANTS[o.variant ?? 'default'], SIZES[o.size ?? 'default']);
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
// a page or component
|
|
88
|
+
import { buttonClass } from '#components/ui/button.ts';
|
|
89
|
+
html`<button class=${buttonClass({ variant: 'secondary', size: 'sm' })} @click=${...}>Reset</button>`;
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Why a class helper (not a `<ui-button>` wrapper): it adds NO indirection, so the element stays native (`@click`, `?disabled`, form submission, focus, a11y all just work) and the markup stays readable, while every button shares one source of truth (so no button can forget `cursor-pointer` or drift). Put the affordance every variant needs (like `cursor-pointer`) on the shared BASE.
|
|
93
|
+
|
|
94
|
+
**Default: `npx webjsdev ui add`, then modify. Do not hand-write a primitive from scratch.** For a repeated primitive with variants, run `npx webjsdev ui add <name>` then adapt the copied source (add, remove, restructure, or theme it as your app needs). The scaffold already ships the `cn` prerequisite at `lib/utils/cn.ts`, so `add` works out of the box (a non-scaffold app runs `npx webjsdev ui init` once first to write `components.json`, the `cn` util, and the design tokens). The kit is shadcn-style, so `add` COPIES the helper's source INTO your `components/ui/` and you own and edit it exactly as freely as code you typed yourself. That is the key point: `add`-then-modify and hand-writing end at the SAME place (owned, editable class-helper source), so the difference is only the STARTING POINT. `add` starts you from vetted, variant-complete source you then adapt (and the copied header spells out the primitive's accessibility obligations), where hand-writing starts from a blank file and re-derives all of it for no benefit. You own the copied source and can add, remove, restructure, or theme it however your app needs: change the class values so the helper produces YOUR look (rather than bending your app to the kit's defaults), keep only the parts you use (the gallery's `cardClass` is surface-only, since its panels vary their own padding and layout), and add variants the kit does not ship. Hand-author a primitive yourself ONLY for a one-off the kit does not cover, or a deliberate opt-out of the kit. Reserve `lib/utils/ui.ts` `html`-fragment helpers for repeated markup chunks; reserve `components/ui/*` class helpers for themed primitives with variants.
|
|
95
|
+
|
|
96
|
+
## Accessible native controls
|
|
97
|
+
|
|
98
|
+
Even with the kit, an app hand-authors SOME markup (a one-off primitive the kit does not cover, or the native element you wrap a class helper around), and there accessibility is your job (the `@webjsdev/ui` primitives carry their own, but a raw `<button>` / `<input>` does not). Three habits keep hand-authored interactive markup accessible on BOTH the JS and no-JS paths:
|
|
99
|
+
|
|
100
|
+
- **Associate a label with its control.** `<label for="email">` paired with `<input id="email">` (or wrap the control in the `<label>`), so a click on the label focuses the field and a screen reader announces it.
|
|
101
|
+
- **State a toggle's pressed state.** A button that toggles carries `aria-pressed=${on}` so assistive tech announces on/off, not just "button".
|
|
102
|
+
- **Name an icon-only button.** A button whose only content is an icon has no accessible name, so give it `aria-label="Delete task"`.
|
|
103
|
+
|
|
104
|
+
Native `<button>` / `<a>` / `<input>` already have correct focus + keyboard behaviour, which is the main reason to prefer them (and the `buttonClass()` / `inputClass()` class helpers) over a custom `<div role>` element.
|
|
105
|
+
|
|
106
|
+
## Design tokens and theming
|
|
107
|
+
|
|
108
|
+
The default stack is a static compiled Tailwind stylesheet (`css:build` compiles `public/input.css` to the linked `public/tailwind.css`, so it works with JS off) plus `@theme` design tokens declared once in the root layout. You consume them as utility classes (`bg-background`, `text-foreground`, `bg-card`, `border-border`, `font-serif`).
|
|
109
|
+
|
|
110
|
+
**Two halves.** (1) `public/input.css` MAPS token names into Tailwind with `@theme inline` (`--color-background: var(--background)`), so `bg-background` resolves to `var(--background)`. That is infrastructure; leave it. (2) The root layout (`app/layout.ts`) DEFINES the values as plain CSS custom properties in a `<style>` block. That is your palette; make it your own. A freshly cleared app (after `npm run gallery:clear`) ships only the OS system-colour base (`Canvas` / `CanvasText`) with NO tokens, so building this palette is your first styling step.
|
|
111
|
+
|
|
112
|
+
**Light and dark, defined once (DRY).** Write each colour token ONE time with the native CSS `light-dark(LIGHT, DARK)` function and let `color-scheme` pick the side. The default `color-scheme: light dark` follows the OS; a `[data-theme]` attribute forces one. No duplicated light/dark blocks:
|
|
113
|
+
|
|
114
|
+
```html
|
|
115
|
+
<style>
|
|
116
|
+
:root {
|
|
117
|
+
--font-sans: ui-sans-serif, system-ui, sans-serif;
|
|
118
|
+
color-scheme: light dark; /* follow the OS by default */
|
|
119
|
+
--background: light-dark(#ffffff, #1e2226);
|
|
120
|
+
--foreground: light-dark(#191c20, #dee2e6);
|
|
121
|
+
--card: light-dark(#f7f8fa, #313539);
|
|
122
|
+
--primary: light-dark(#1e2226, #dee2e6);
|
|
123
|
+
--secondary: light-dark(#eef0f3, #3a3f45);
|
|
124
|
+
--muted: light-dark(#f1f3f5, #2a2e33);
|
|
125
|
+
--muted-foreground: light-dark(#565c64, #94989c);
|
|
126
|
+
--accent: light-dark(#e9ecef, #383d43);
|
|
127
|
+
--border: light-dark(#e2e5e9, #3d434b);
|
|
128
|
+
--ring: light-dark(#9aa1a9, #6c737b);
|
|
129
|
+
--destructive: light-dark(#b3261e, #f2b8b5);
|
|
130
|
+
/* a derived token tracks BOTH themes for free via var(--primary) */
|
|
131
|
+
--primary-tint: color-mix(in srgb, var(--primary) 22%, transparent);
|
|
132
|
+
}
|
|
133
|
+
:root[data-theme='light'] { color-scheme: light; } /* the toggle forces a scheme */
|
|
134
|
+
:root[data-theme='dark'] { color-scheme: dark; }
|
|
135
|
+
</style>
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
`light-dark()` is a native CSS function (CSS Color 5, Baseline 2024), not a library, so nothing to import. A single-theme app drops the `[data-theme]` rules and gives each token one colour.
|
|
139
|
+
|
|
140
|
+
**A manual theme toggle** writes `data-theme` on `<html>` (`light` / `dark`, or removes it for "follow the OS"). If you use `@webjsdev/ui` components, ALSO keep the `.dark` class in sync (the ui kit keys its own tokens off `.dark`), and apply the saved choice in a tiny inline `<script>` in the layout head so there is no first-paint flash. Verify dark mode in a real browser. Light mode passing proves nothing about dark.
|
|
141
|
+
|
|
142
|
+
**Edge cases.** `light-dark()` is COLOUR-only. A colour needed in just one theme sets the unused side to a no-op (`light-dark(#fff, transparent)`). A derived token that references a `light-dark()` one (like `--primary-tint` above) tracks both themes automatically. A NON-colour token that must differ per theme (a shadow's geometry, a gradient, a size, an image) cannot use `light-dark()`; give it a `:root[data-theme='dark']` override plus an `@media (prefers-color-scheme: dark) { :root:not([data-theme]) { ... } }` rule for the OS default.
|
|
143
|
+
|
|
144
|
+
**The ui class helpers build on these tokens.** `buttonClass()` / `cardClass()` / `inputClass()` / `badgeClass()` emit Tailwind utilities that reference the same tokens (`bg-primary`, `border-border`), so theming the tokens re-skins every helper at once.
|
|
145
|
+
|
|
146
|
+
**Focus rings.** The design system applies ONE themed, keyboard-only focus ring globally in the theme CSS: `@layer base { * { @apply border-border outline-ring/50 } }` themes the outline COLOUR to `--ring/50`, and a `:focus-visible { outline: 2px solid color-mix(in oklab, var(--color-ring) 50%, transparent); outline-offset: 2px }` forces a SOLID outline. That second rule matters: `outline-ring/50` alone leaves `outline-style: auto`, so the browser draws its OWN focus ring (which can look thick and white and ignore the colour). So every focusable element (button, link, input) shares one `--ring`-coloured ring with no per-element styling (a native `<button>` renders it a touch wider than a link, a Chromium form-control quirk, but the colour is the same). Do NOT re-add a focus style on a light-DOM element (`buttonClass` deliberately carries none), and NEVER remove it (`outline: none` with no replacement fails WCAG 2.4.7). `:focus-visible` already limits the ring to keyboard / programmatic focus, not a mouse click. A SHADOW-DOM component is the ONE exception: a document rule cannot cross the shadow boundary, so it styles its own focus in `static styles`, matching the global ring EXACTLY (`--ring` at 50%, the same as `outline-ring/50`): `button:focus-visible { outline: 2px solid color-mix(in oklab, var(--color-ring) 50%, transparent); outline-offset: 2px }`. Without it, its controls fall back to the raw browser outline (thick, light on a dark theme, and shown on window-refocus).
|
|
73
147
|
|
|
74
148
|
## Light-DOM host display, and shadow hosts
|
|
75
149
|
|
|
@@ -83,6 +157,17 @@ static styles = css`:host { display: block }`; // a shadow host with no :host
|
|
|
83
157
|
|
|
84
158
|
**Size the HOST, not just an inner wrapper.** The host custom element is the box the parent lays out. A host that is a flex/grid item in a centering parent (`flex justify-center`, `grid place-items-center`) is sized to its content unless it carries width itself. Put the sizing classes on the host (`w-full max-w-[400px]`), not only on an inner `<div>`. Symptom: a board or card renders tiny even though its inner grid says `w-full max-w-[400px]`. Fix: move the sizing onto the host.
|
|
85
159
|
|
|
160
|
+
## Section rhythm: one gap, defined once
|
|
161
|
+
|
|
162
|
+
Per-element margins can never give consistent vertical spacing: each element controls only one side, so a block's gap-above (the previous element's margin) drifts from its gap-below (its own). For a content column (a docs page, a demo page, an article), make the COLUMN own the spacing with a flex stack, and zero the children's own margins so only the one gap applies:
|
|
163
|
+
|
|
164
|
+
```css
|
|
165
|
+
.stack { --section-gap: 1.5rem; display: flex; flex-direction: column; gap: var(--section-gap); }
|
|
166
|
+
.stack > * { margin: 0; }
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Every top-level child (heading, paragraph, component, list) is then equally spaced from ONE variable, and a spacing change is a one-line edit. Flex `gap` beats forcing `display: block` + margins on children: a `grid`/`flex` child keeps its own layout (a blanket `display: block` clobbers it), an inline shadow-DOM host is blockified as a flex item so it honours the gap, a `display: contents` element (a streaming `<webjs-suspense>`) is replaced by its children which become the flex items, and a `display: none` node (a streaming `<script>`/`<template>`) is not an item at all, so it gets no phantom gap. A group that must stay tight (a caption directly above its code block) wraps in one child `<div>` and keeps its own inner spacing. The gallery's `/features` layout (`demo-stack`) is the worked example.
|
|
170
|
+
|
|
86
171
|
## Even grids, no reflow
|
|
87
172
|
|
|
88
173
|
The reflow bug (a cell grows when it gets content while the others shrink) comes from `auto`-sized grid rows. Size the tracks explicitly so every cell is an equal fraction regardless of content:
|
|
@@ -121,3 +206,32 @@ new ResizeObserver(apply).observe(hdr);
|
|
|
121
206
|
```
|
|
122
207
|
|
|
123
208
|
For a dashboard, an alternative is an app-shell scroll container (a non-scrolling `100dvh` flex column with `<main>` as the internal scroller), which needs no offset but changes the scroll model.
|
|
209
|
+
|
|
210
|
+
### A fixed header and a modal that locks scroll
|
|
211
|
+
|
|
212
|
+
Anything that locks page scroll (a modal, a drawer, an off-canvas menu) hides the page scrollbar, and a classic scrollbar takes real layout width, so hiding it widens the viewport. The usual compensation is padding the body, which holds in-flow content still. **It does nothing for a fixed header**, because a fixed box lays out against the initial containing block, never against the body's padding box, so the header widens with the viewport and its centred content slides right by half the scrollbar width.
|
|
213
|
+
|
|
214
|
+
`@webjsdev/ui`'s `<ui-dialog>` / `<ui-alert-dialog>` handle this for you (#1144). Their scroll lock reserves the scrollbar gutter for its duration, so the viewport width never changes and nothing moves. It leaves the gutter alone if your page already declared its own `scrollbar-gutter`, on the assumption that a page which made that choice meant it.
|
|
215
|
+
|
|
216
|
+
Engines differ, and the lock does not need to know which one it is on. Where the gutter is honoured (measured on Chromium) nothing moves and the lock does nothing else. Where it is ignored (measured on WebKit) the viewport does widen, so the lock measures how much, pads `<html>` by it to hold in-flow content still (added to any padding you already had, and restored on close), and publishes the amount as `--wj-scrollbar-compensation` so a fixed element can opt in with one line:
|
|
217
|
+
|
|
218
|
+
```css
|
|
219
|
+
header { position: fixed; inset-inline: 0; top: 0; border-right: var(--wj-scrollbar-compensation, 0px) solid transparent; }
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
A transparent border rather than `padding-right`, for two reasons. It composes with whatever padding the element already has, where a padding form has to restate that base value and restate it again per responsive variant. And a background still paints across a border, so a header that carries its own background stays full bleed instead of ending short of the edge. Declare it after your Tailwind link if you use the `border-*` utilities, since those set `border-right-color` too.
|
|
223
|
+
|
|
224
|
+
The property is only set while a lock is active AND the viewport actually widened, so the `0px` fallback covers every other moment and the two mechanisms never double-compensate. If you find inline `padding-right` on your `<html>` while a modal is open, that is this, and it comes off on close.
|
|
225
|
+
|
|
226
|
+
**Put it on the element that is both viewport-width and painting.** Both halves are load-bearing, and getting either wrong fails quietly.
|
|
227
|
+
|
|
228
|
+
- **Viewport-width**, or a left-aligned child still moves. Insetting a `max-width` container holds its CENTRED children still but not its leading ones, because the container's own box is not what widened. This is easy to miss: measure a left-aligned child, not just a centred one.
|
|
229
|
+
- **Painting**, or the background stops short of the widened edge. Insetting a wrapper that paints nothing insets the child that does, which leaves an unpainted strip.
|
|
230
|
+
|
|
231
|
+
In this repo `examples/blog` has one element that is both (the fixed header paints its own chrome, so the border goes straight on it), and `website` has a non-painting fixed wrapper around a painting header around a centring bar, where the header is the one that qualifies.
|
|
232
|
+
|
|
233
|
+
Rolling your own scroll lock? Three things the kit learned the hard way, and the first is that almost every implementation of this (including Next's own dev overlay and Radix) gets the fixed case wrong by design.
|
|
234
|
+
|
|
235
|
+
1. Reserve the gutter rather than relying on padding alone, or a fixed element will jump however carefully you compensate the body.
|
|
236
|
+
2. When you do pad, pad `<html>`, not `<body>`. A `max-width` body does not widen when the viewport does, so padding it misses the shift entirely, while padding the root holds in-flow content whatever the body's width is.
|
|
237
|
+
3. Measure the root's own border box to decide the amount. `documentElement.clientWidth` can grow while nothing actually moved (Chromium reports exactly that under a reserved gutter), and a `position: fixed` probe reads its pre-lock box on WebKit until the next rendering update, so both mislead.
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
- The four test layers (unit, browser, e2e, smoke) and where each file lives.
|
|
6
6
|
- The `handle()` harness from `@webjsdev/server/testing` for driving the real request pipeline against a native `Response`.
|
|
7
|
-
- `
|
|
7
|
+
- `npm run test` and `npm run test:browser`, plus when a browser or e2e test is REQUIRED (hydration, client router, slots, custom-element upgrade).
|
|
8
8
|
- Bun cross-runtime parity for runtime-sensitive code.
|
|
9
9
|
- Rendering the app and LOOKING for visual defects a static check cannot catch (a collapsed or reflowing layout).
|
|
10
10
|
- Convention validation with `webjs check`.
|
|
@@ -24,15 +24,44 @@ Feature folders are primary, and the test kind is a subfolder inside the feature
|
|
|
24
24
|
|
|
25
25
|
Assert only on what the layer needs. A block that inspects only the HTTP response, the SSR HTML string, headers, or the importmap does NOT need a browser. Keep in the browser suite only blocks that genuinely need a DOM (live state via `page.evaluate`, hydration, client-router nav, slots, view transitions, streaming into the DOM, custom-element upgrade).
|
|
26
26
|
|
|
27
|
+
### A browser test that clicks a real link or submits a real form MUST cancel the default
|
|
28
|
+
|
|
29
|
+
web-test-runner aborts the whole SESSION, not one file, when the page navigates. So a test that clicks a real `<a href>` or submits a real `<form>` is a single point of failure for every browser test file you have: whenever the client router loses the race to intercept, the browser performs the real navigation and the run dies reporting `0 failed` and then exiting non-zero, which reads as an infrastructure blip rather than a test problem. Cancel the default so an interception gap fails ONE test on its own assertion.
|
|
30
|
+
|
|
31
|
+
Register the canceling listener on `window` in the BUBBLE phase. That is the last step of the propagation path, so it runs after the router's own document-level listeners, and `preventDefault()` still cancels the default action because that action runs only once dispatch completes.
|
|
32
|
+
|
|
33
|
+
**Never use the capture phase.** Capture sets `defaultPrevented` before the router ever sees the event, and the router returns immediately on that flag (the same guard that lets a component's `@click` opt out). Every guarded router test then passes while testing nothing. Capture is correct only when suppressing the router is the actual goal, for a test that exercises a menu or a drawer rather than navigation; say so in a comment when you do it, because it looks identical to the mistake. Do not reach for `stopPropagation` either, which hides the click from the router and turns the assertion into a tautology.
|
|
34
|
+
|
|
35
|
+
Cancel a form on its `submit` event, not on the submit control's `click`. The form's default action fires on submit, so canceling the click stops the form from ever submitting and the router never sees it.
|
|
36
|
+
|
|
37
|
+
Resolve the anchor from `e.composedPath()`, not `e.target.closest('a[href]')`. The listener is on `window`, so a click inside a shadow root (a `static shadow = true` component) arrives retargeted to the host, and `closest()` walks only light-tree ancestors and never finds the link, which fails open exactly where the router itself handles the case. A pure-fragment `href="#x"` link needs no guard at all, since it never navigates the page away.
|
|
38
|
+
|
|
39
|
+
There is a SECOND channel a click guard cannot reach: the router assigns `location.href` when it degrades a soft navigation, and `preventDefault` does not cancel a script assignment. Do not try to intercept `location.href` itself, which is non-configurable on Chromium, Firefox, and WebKit alike, so its setter cannot be redefined on any of them. Listen for `webjs:navigation-fallback` on `document` and assert none fired; its `cause` is the diagnosis.
|
|
40
|
+
|
|
41
|
+
It is a few lines, so write it in your suite's setup and detach it in teardown:
|
|
42
|
+
|
|
43
|
+
```js
|
|
44
|
+
const onClick = (e) => {
|
|
45
|
+
for (const el of e.composedPath()) {
|
|
46
|
+
if (el instanceof HTMLAnchorElement && el.hasAttribute('href')) { e.preventDefault(); return; }
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
const onSubmit = (e) => e.preventDefault();
|
|
50
|
+
window.addEventListener('click', onClick); // bubble, never capture
|
|
51
|
+
window.addEventListener('submit', onSubmit);
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
(The WebJs framework repo keeps its own copy of exactly this in one shared module, `test/browser-nav-guard.js`, whose `installNavGuard()` returns `{ fallbacks, hardNavigations, remove }`. That module is framework-repo infrastructure and is not part of a scaffolded app.)
|
|
55
|
+
|
|
27
56
|
## App runners (`webjs test`)
|
|
28
57
|
|
|
29
58
|
```sh
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
WEBJS_E2E=1
|
|
59
|
+
npm run test # unit + browser tests (both layers; e2e only with WEBJS_E2E=1)
|
|
60
|
+
npm run test:browser # web-test-runner against test/**/browser/**
|
|
61
|
+
WEBJS_E2E=1 npm run test # adds the e2e layer
|
|
33
62
|
```
|
|
34
63
|
|
|
35
|
-
`
|
|
64
|
+
`npm run test` dispatches on the runtime (`node --test` on Node, `bun test` on Bun). The scaffold's `web-test-runner.config.js` globs `test/**/browser/**/*.test.js` and is already wired, so you do not set it up.
|
|
36
65
|
|
|
37
66
|
A scaffolded app has one root `test/` directory shaped the same way (feature first, kind second):
|
|
38
67
|
|
|
@@ -48,11 +77,11 @@ test/
|
|
|
48
77
|
|
|
49
78
|
## The `handle()` harness (`@webjsdev/server/testing`)
|
|
50
79
|
|
|
51
|
-
`createRequestHandler({ appDir }).handle(request)` drives the FULL request pipeline (middleware, routing, SSR,
|
|
80
|
+
`createRequestHandler({ appDir }).handle(request)` drives the FULL request pipeline (middleware, routing, SSR, form-dispatched actions, server-action RPC, auth, CSRF) and returns a native `Response`. It is the same entry the framework's own suite uses, so the most realistic way to test an app is to fire a `Request` through it and assert on the `Response`, with no spawned process and no network. `@webjsdev/server/testing` ships thin builders over that `handle()`, each a few lines over native `Request` / `Response` that reuse the REAL cookie names, header names, and wire serializer. For a browser test that needs to drive the app in a real DOM, `createBrowserTestHandler()` from `@webjsdev/server/testing` exposes the same `handle()` pipeline to the WTR Chromium session.
|
|
52
81
|
|
|
53
82
|
```js
|
|
54
83
|
import { createRequestHandler } from '@webjsdev/server';
|
|
55
|
-
import { testRequest, invokeActionForTest, rawActionRequest, loginAndGetCookies, withSessionCookie }
|
|
84
|
+
import { testRequest, submitForm, invokeActionForTest, rawActionRequest, loginAndGetCookies, withSessionCookie }
|
|
56
85
|
from '@webjsdev/server/testing';
|
|
57
86
|
|
|
58
87
|
const app = await createRequestHandler({ appDir: process.cwd(), dev: true });
|
|
@@ -77,6 +106,28 @@ const dash = await testRequest(app.handle, '/dashboard', withSessionCookie({}, c
|
|
|
77
106
|
assert.equal(dash.status, 200);
|
|
78
107
|
```
|
|
79
108
|
|
|
109
|
+
### `submitForm`: the no-JS write path
|
|
110
|
+
|
|
111
|
+
Use it for any page whose form binds an action (`<form action=${myAction}>`). A bound form carries a hidden `__webjs_action` field holding the action's identity, and that field is what tells the dispatcher which action to run, so a hand-written `POST` that omits it is not a form submission at all and is answered **405**. `submitForm` renders the page, reuses the identity the server put there, and posts it back, which is exactly what a browser with JS off does.
|
|
112
|
+
|
|
113
|
+
```js
|
|
114
|
+
import { submitForm } from '@webjsdev/server/testing';
|
|
115
|
+
|
|
116
|
+
// success is a 303 PRG
|
|
117
|
+
const ok = await submitForm(app.handle, '/signup', { name: 'Ada', email: 'ada@example.com', password: 'hunter2' });
|
|
118
|
+
assert.equal(ok.status, 303);
|
|
119
|
+
assert.equal(ok.headers.get('location'), '/dashboard');
|
|
120
|
+
|
|
121
|
+
// a failure result re-renders the SAME page at 422 with the result on actionData
|
|
122
|
+
const bad = await submitForm(app.handle, '/signup', { email: 'not-an-email' });
|
|
123
|
+
assert.equal(bad.status, 422);
|
|
124
|
+
assert.match(await bad.text(), /Enter a valid email/);
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Options: `cookies` (submit as a logged-in user, paired with `loginAndGetCookies`), `match` (a string or RegExp the form's markup must contain, for a page with several forms), `index` (pick by position instead), `submitPath` (render one page, submit to another), and `headers`.
|
|
128
|
+
|
|
129
|
+
Do not hand-roll the identity scrape. When it is wrong the symptom is a 405, an assertion fails, and a surrounding `catch` reports it as a database that was never migrated, so the test goes quiet rather than red.
|
|
130
|
+
|
|
80
131
|
### `invokeActionForTest`: round-trip an action through the REAL endpoint
|
|
81
132
|
|
|
82
133
|
```js
|
|
@@ -110,11 +161,18 @@ A layout bug (a board that collapses, cells of unequal size, a grid that resizes
|
|
|
110
161
|
|
|
111
162
|
WebJs runs on Node 24+ or Bun. The Node suite is the source of truth; an additive Bun matrix re-runs the runtime-sensitive suite under Bun to catch the long tail of cross-runtime incompatibilities (a `node:*` API Bun implements differently, a crypto or stream edge case, an error-message-format quirk).
|
|
112
163
|
|
|
113
|
-
Bun parity is part of the definition of done. A change to a runtime-sensitive surface (the serializer, the `node:http` vs `Bun.serve` listener and request path, SSR / action / CSRF dispatch, streams, `node:crypto`, the TS stripper, auth / session / cors) is NOT done until you run the Bun
|
|
164
|
+
If your app targets Bun, Bun parity is part of the definition of done. A change to a runtime-sensitive surface (the serializer, the `node:http` vs `Bun.serve` listener and request path, SSR / action / CSRF dispatch, streams, `node:crypto`, the TS stripper, auth / session / cors) is NOT done until you also run your suite under the Bun runtime (needs `bun` installed) and add a cross-runtime assertion for the touched surface.
|
|
165
|
+
|
|
166
|
+
### Writing a plain proof script that boots a server
|
|
167
|
+
|
|
168
|
+
A cross-runtime proof is often a plain assert script rather than a test file, so the SAME file runs under `node script.mjs` and `bun script.mjs`. If it boots a real server through `startServer`, know what carries its verdict: the process EXIT CODE, since nothing is collecting results for you. WebJs makes that code trustworthy: a fatal shutdown, which is where a failed top-level assertion lands on both runtimes, exits 1, and so does a drain that rejects or is still hanging at the 10s deadline. The process exits 0 in one case only, an operator-requested stop that drained cleanly with no crash on the way out, so a proof script that finishes green really did finish green. Two habits keep it that way:
|
|
169
|
+
|
|
170
|
+
- **Assert the exit code, never the logs.** These scripts conventionally pass a `quiet` logger, so anything that only logs is invisible. If your script catches its own failure, report it with an explicit `process.exit(1)` rather than a `console.error` alone, guarded as `if (import.meta.main) process.exit(1); else throw failure;`. The guard matters when a `.test.mjs` wrapper imports the script under `node --test`: an unguarded exit kills the whole single-process run and hides every other file's results, while the throw lets the harness report one failed test.
|
|
171
|
+
- **Prove the script can FAIL before you trust it passing.** Break one assertion on purpose and confirm the run exits non-zero. A proof that cannot go red is worse than no proof: it reports success forever.
|
|
114
172
|
|
|
115
173
|
## Convention validation (`webjs check`)
|
|
116
174
|
|
|
117
|
-
`
|
|
175
|
+
`npm run check` is the correctness validator. Every rule catches code that is wrong to ship, a crash, a security leak, a reactive prop that silently stops re-rendering, or a type-strip failure. Run it and fix every violation before considering the change done (`npm run check -- --json` for an agent loop, `npm run check -- --rules` to list the rules). It is separate from `CONVENTIONS.md`, which carries the customizable project conventions you follow by judgment.
|
|
118
176
|
|
|
119
177
|
## What NOT to do
|
|
120
178
|
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
- TypeScript at runtime with **no build step**: `.ts` / `.mts` is stripped in place, not compiled.
|
|
6
6
|
- **Erasable syntax only** (`erasableSyntaxOnly: true`) and the exact list of banned constructs, with their allowed rewrites.
|
|
7
7
|
- The **pluggable stripper** (Node 24+ built-in vs `amaro` on Bun) and how the browser gets stripped source.
|
|
8
|
-
- **Full-stack type safety**: server-action types
|
|
9
|
-
- **Typing pages, layouts, and route handlers** with `PageProps` / `LayoutProps` / `RouteHandlerContext` and the generated route union (`
|
|
8
|
+
- **Full-stack type safety**: the rule (derive the type at every boundary, never `unknown` / `any`), server-action types flowing to the call site, and the `import type` carrier rule across the `.server` boundary.
|
|
9
|
+
- **Typing pages, layouts, and route handlers** with `PageProps` / `LayoutProps` / `RouteHandlerContext` and the generated route union (`npx webjsdev types`).
|
|
10
10
|
|
|
11
11
|
Read this when you are writing `.ts` in a WebJs app, hit a strip-time 500, or want typed params and hrefs. For action signatures and the serializer wire see `data-and-actions.md`. For typing reactive props see `components.md`.
|
|
12
12
|
|
|
@@ -77,6 +77,75 @@ Prefer explicit `.ts` extensions in imports. A `.js` specifier pointing at a `.t
|
|
|
77
77
|
|
|
78
78
|
## Full-stack type safety
|
|
79
79
|
|
|
80
|
+
### The rule: derive the type, never `unknown` or `any`
|
|
81
|
+
|
|
82
|
+
Every value crossing an app boundary in WebJs already has a type you can reach, so reach for it. Writing `unknown` or `any` at a boundary throws away the framework's central guarantee, and it does so silently: the app still runs, the checker just stops helping. Nothing catches this for you. Both are valid TypeScript, so `webjs check` will not flag them (it is a correctness tool, and this is a convention), and `tsc --noEmit` passes happily.
|
|
83
|
+
|
|
84
|
+
`unknown` is the more dangerous of the two, because it reads as the safe choice. It is safe only in the sense that it forces a narrow at the read site. As a boundary type it is exactly as uninformative as `any`, and it pushes a cast into every consumer.
|
|
85
|
+
|
|
86
|
+
The ladder, in the order to climb it:
|
|
87
|
+
|
|
88
|
+
| Boundary | Write this | Not this |
|
|
89
|
+
|---|---|---|
|
|
90
|
+
| A database row | `export type Todo = typeof todos.$inferSelect` in `db/schema.server.ts` (`$inferInsert` for a write) | a hand-written interface that drifts from the schema |
|
|
91
|
+
| That row inside a shipping component | `import type { Todo } from '#db/schema.server.ts'` | `any[]`, or re-declaring the shape by hand |
|
|
92
|
+
| A server action's input | a named `interface CreatePostInput` | `input: unknown` / `input: any` |
|
|
93
|
+
| A server action's result | `Promise<ActionResult<Post>>` | `Promise<any>` |
|
|
94
|
+
| `export const validate` | `(input: unknown)`, narrowed in the body, returning `data` that `satisfies` the action's input type | `(input: any)`, which un-types the returned `data` too |
|
|
95
|
+
| A page | `PageProps<'/blog/[slug]'>` | `{ params: Record<string, any> }` |
|
|
96
|
+
| A layout | `LayoutProps` (whose `children` is a `TemplateResult`) | `{ children: unknown }` |
|
|
97
|
+
| A route handler's 2nd argument | `RouteHandlerContext<'/api/users/[id]'>` | `{ params: any }` |
|
|
98
|
+
| A client-router href | the generated `Route` union (`npx webjsdev types`; `npm run dev` also emits it) | a bare `string` |
|
|
99
|
+
| A reactive property | `prop<Student>(Object)`, `prop<Tag[]>(Array)` | `prop(Object)` plus a cast at every read |
|
|
100
|
+
| An optimistic temp row | the pending shape on the row type (`pending?: boolean`) | `as any` on the temp id |
|
|
101
|
+
|
|
102
|
+
One flow, end to end, with no escape hatch anywhere in it:
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
// db/schema.server.ts
|
|
106
|
+
export const posts = table('posts', { id: uuidPk(), title: text().notNull(), body: text().notNull() });
|
|
107
|
+
export type Post = typeof posts.$inferSelect; // derived, never hand-written
|
|
108
|
+
|
|
109
|
+
// modules/posts/actions/create-post.server.ts
|
|
110
|
+
'use server';
|
|
111
|
+
import type { Post } from '#db/schema.server.ts'; // type-only: erased before the browser sees it
|
|
112
|
+
export interface CreatePostInput { title: string; body: string }
|
|
113
|
+
export const validate = (input: unknown) => { /* narrows, returns data satisfying CreatePostInput */ };
|
|
114
|
+
export async function createPost(input: CreatePostInput): Promise<ActionResult<Post>> { /* ... */ }
|
|
115
|
+
|
|
116
|
+
// modules/posts/components/new-post.ts
|
|
117
|
+
import { createPost } from '#modules/posts/actions/create-post.server.ts';
|
|
118
|
+
const r = await createPost({ title, body });
|
|
119
|
+
if (r.success && r.data) r.data.title; // Post.title: string, checked at the call site
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
The payoff is not stylistic. A typo in `r.data.titel`, a renamed column, a changed action signature, and a page reading `params.slugg` are all compile errors in that version and all silent runtime `undefined` in the `unknown` version.
|
|
123
|
+
|
|
124
|
+
### Where `unknown` is still the right type
|
|
125
|
+
|
|
126
|
+
There are two cases, and only the first is about narrowing.
|
|
127
|
+
|
|
128
|
+
**Case one: a value that genuinely has no type yet, at the moment before it is narrowed.**
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
// app/api/webhook/route.ts
|
|
132
|
+
export async function POST(req: Request) {
|
|
133
|
+
const body: unknown = await req.json(); // correct: nothing has vouched for this yet
|
|
134
|
+
const parsed = parseWebhook(body); // narrowed on the very next line
|
|
135
|
+
return Response.json({ ok: parsed.kind });
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
The test is what the next line does. Correct `unknown` here is narrowed immediately by a parse, a validator, or a type guard, and the narrowed type is what the rest of the function sees. Anything standing at that boundary qualifies: a `route.ts` handler's `await req.json()` (above), a `catch (e)` binding (already `unknown` under `strict`), an action's `export const validate`, and any validator function those delegate to. In a validator, returning `data` that `satisfies` the action's input type is what carries a real type into the action body. See `data-and-actions.md`.
|
|
140
|
+
|
|
141
|
+
**Case two: a parameter of YOUR OWN helper that forwards its argument into an `html` template hole, which is NOT narrowed.** A hole renders a string, a number, a `TemplateResult`, an array of those, a directive result, or nothing, so a helper like `lede(content: unknown)` is correctly typed and `TemplateResult` alone would be too narrow. Narrow it only when the helper genuinely accepts one shape (`backLink(href: string, ...)`).
|
|
142
|
+
|
|
143
|
+
This case is about a value YOU accept, never one the framework hands you. A layout's `children` also ends up in a hole, but the framework already types it (`LayoutProps.children` is a `TemplateResult`), so `{ children: unknown }` is a discarded type, not this carve-out.
|
|
144
|
+
|
|
145
|
+
Everywhere else `unknown` is a missing type, not a safe one: surviving into a return type, a component prop, a layout's `children`, or an action signature is the shape to fix.
|
|
146
|
+
|
|
147
|
+
`any` gets no carve-out at all in app code. It does not defer checking, it disables it, so a validator typed `(input: any)` un-types everything downstream of the call.
|
|
148
|
+
|
|
80
149
|
### Server actions type-check automatically
|
|
81
150
|
|
|
82
151
|
Calling a server action from a client component resolves at type-check time to the action's real source file. The runtime stub swap is invisible to the checker, and the RPC serializer makes runtime match the types (`Date` stays `Date`, `Map` stays `Map`, `BigInt` stays `BigInt`; see `data-and-actions.md` for the full supported set).
|
|
@@ -128,18 +197,18 @@ With no route literal (or before you generate route types), `params` is `Record<
|
|
|
128
197
|
|
|
129
198
|
Type page metadata with the exported `Metadata` type (and `MetadataContext` for the `generateMetadata` argument), the same ergonomics as Next.js's `import type { Metadata } from 'next'`.
|
|
130
199
|
|
|
131
|
-
### The generated route union (`
|
|
200
|
+
### The generated route union (`npx webjsdev types`)
|
|
132
201
|
|
|
133
|
-
Run `
|
|
202
|
+
Run `npx webjsdev types` to write `.webjs/routes.d.ts`, an opt-in overlay augmenting `@webjsdev/core` with one key per route in `app/`. It narrows two things at tsserver time:
|
|
134
203
|
|
|
135
204
|
- The `Route` href type: `navigate('/blog/anything')` passes, `navigate('/nonexistent')` is an error. Until you generate the types, `Route` is `string` (unconstrained, non-breaking for JSDoc and un-generated apps).
|
|
136
205
|
- Per-route `params`: `PageProps<'/blog/[slug]'>['params']` becomes `{ slug: string }`.
|
|
137
206
|
|
|
138
207
|
```sh
|
|
139
|
-
|
|
208
|
+
npx webjsdev types # writes .webjs/routes.d.ts (route count printed)
|
|
140
209
|
```
|
|
141
210
|
|
|
142
|
-
`
|
|
211
|
+
`npm run dev` emits it at startup and re-emits after each route rebuild, so the editor always has fresh types. The file is gitignored (regenerated per machine, like Next's `.next/types`); the scaffold `tsconfig.json` already lists it in `include`. To opt in for an existing app, run `npx webjsdev types` once and add `.webjs/routes.d.ts` to `include`. This is the WebJs no-build equivalent of Next 15's `typedRoutes`, achieved via interface declaration-merging rather than a bundler.
|
|
143
212
|
|
|
144
213
|
### The `webjs` config block and auth user
|
|
145
214
|
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# The `@webjsdev/ui` component kit
|
|
2
|
+
|
|
3
|
+
Load this when the app uses `@webjsdev/ui` (a `components.json` is present), OR
|
|
4
|
+
when you are about to add a UI primitive (button, card, input, badge) to a fresh
|
|
5
|
+
app that has not initialised the kit yet: running `npx webjsdev ui init` then
|
|
6
|
+
`npx webjsdev ui add <name>` is HOW the kit comes to exist, and it is the default for a
|
|
7
|
+
repeated primitive over hand-writing one from scratch. `@webjsdev/ui` is the shadcn-style
|
|
8
|
+
kit for WebJs. The source is copied into your repo (`components/ui/`), so you own
|
|
9
|
+
and edit it exactly as freely as code you wrote yourself, and can add, remove,
|
|
10
|
+
restructure, or theme it however your app needs. Two tiers:
|
|
11
|
+
|
|
12
|
+
- **Tier 1, class helpers (23 components).** Pure functions returning Tailwind
|
|
13
|
+
class strings (`buttonClass({ variant })`, `cardClass()`), composed with
|
|
14
|
+
whatever native element you write. Reach for these instead of expanding
|
|
15
|
+
Tailwind by hand: the call site is a fraction of the tokens and the class list
|
|
16
|
+
cannot drift.
|
|
17
|
+
- **Tier 2, stateful custom elements (9 components).** `<ui-dialog>`, `<ui-tabs>`,
|
|
18
|
+
`<ui-dropdown-menu>`, and friends own their ARIA (focus trap, roving tabindex,
|
|
19
|
+
`aria-controls` / `inert`, live regions). Write the tag and the accessible
|
|
20
|
+
behaviour comes with it. Do NOT hand-roll these; the wiring is easy to get
|
|
21
|
+
subtly wrong.
|
|
22
|
+
|
|
23
|
+
## The workflow: query for the structure, do not guess it
|
|
24
|
+
|
|
25
|
+
`add` copies a Tier-1 component's class helpers plus a lean header (what each
|
|
26
|
+
helper is, the accessibility obligations) and a one-line pointer. It does NOT
|
|
27
|
+
copy the worked structural example, because that example is guidance you consume
|
|
28
|
+
once while composing, not code that should sit in your repo. Get the full
|
|
29
|
+
paste-ready structure on demand:
|
|
30
|
+
|
|
31
|
+
- **MCP `ui` tool** (preferred when available): call `ui` with no args for the
|
|
32
|
+
kit inventory (each component's tier, helper signatures, npm deps); pass
|
|
33
|
+
`{ name: "accordion" }` for one component's helper signatures, the paste-ready
|
|
34
|
+
structural example, the accessibility header, and deps.
|
|
35
|
+
- **CLI**: `npx webjsdev ui list` (inventory), `npx webjsdev ui view <name>` (the projected
|
|
36
|
+
view plus the full source). Same data as the MCP tool (one shared projector).
|
|
37
|
+
|
|
38
|
+
So the loop is: `add` the component, then query `ui <name>` (MCP) or
|
|
39
|
+
`npx webjsdev ui view <name>` for the accessible structure, paste it, and fill it in.
|
|
40
|
+
|
|
41
|
+
## Setup and resolution
|
|
42
|
+
|
|
43
|
+
- `npx webjsdev ui init` writes `components.json`, `lib/utils/cn.ts`, and the CSS design
|
|
44
|
+
tokens the helpers render against (`--background`, `--foreground`,
|
|
45
|
+
`--destructive`, ...). It HARD-FAILS if the tokens cannot be written, so a
|
|
46
|
+
clean exit means the kit is styled. Re-running it is safe on an existing
|
|
47
|
+
project: it keeps the aliases, stylesheet path, and base color already in
|
|
48
|
+
`components.json` and leaves an edited `cn.ts` / `dom.ts` alone, so it only
|
|
49
|
+
fills in what is missing (`--overwrite` resets them instead). `add` self-heals the tokens if they go
|
|
50
|
+
missing.
|
|
51
|
+
- Resolution is LOCAL-FIRST: `init` / `add` / `list` / `view` read the registry
|
|
52
|
+
that ships inside the installed `@webjsdev/ui`, with no network. This pins you
|
|
53
|
+
to the installed version; run `npx webjsdev ui diff` to see where your local copies
|
|
54
|
+
drift from the upstream (that command alone compares against the live registry).
|
|
55
|
+
|
|
56
|
+
## Inventory (run `npx webjsdev ui list` or the MCP `ui` tool for the authoritative, current set)
|
|
57
|
+
|
|
58
|
+
**Tier 1 (class helpers):** accordion, alert, aspect-ratio, avatar, badge,
|
|
59
|
+
breadcrumb, button, card, checkbox, collapsible, input, kbd, label,
|
|
60
|
+
native-select, pagination, popover, progress, radio-group, separator, skeleton,
|
|
61
|
+
switch, table, textarea.
|
|
62
|
+
|
|
63
|
+
**Tier 2 (custom elements, own their ARIA):** alert-dialog, dialog,
|
|
64
|
+
dropdown-menu, hover-card, sonner, tabs, tooltip, plus toggle and toggle-group
|
|
65
|
+
(these two register an element AND export a `*Class` helper).
|
|
66
|
+
|
|
67
|
+
## Idioms
|
|
68
|
+
|
|
69
|
+
- A helper is a function, so compose it: `class=${buttonClass({ variant: 'outline' })}`.
|
|
70
|
+
The unquoted `${...}` is a normal `html` attribute hole.
|
|
71
|
+
- Tier-1 helpers assume the design tokens exist; if a component paints unstyled,
|
|
72
|
+
the tokens are missing (re-run `npx webjsdev ui init` or let `add` self-heal them).
|
|
73
|
+
- Custom elements are display-only-safe at SSR and hydrate in the browser, the
|
|
74
|
+
standard WebJs component model (`references/components.md`).
|
|
75
|
+
|
|
76
|
+
Full per-package reference lives in the installed `@webjsdev/ui/AGENTS.md`.
|
package/src/mcp.js
CHANGED
|
@@ -81,6 +81,18 @@ const DOCS_SCHEMA = {
|
|
|
81
81
|
required: [],
|
|
82
82
|
};
|
|
83
83
|
|
|
84
|
+
/** `ui` lists the @webjsdev/ui kit, or details one component by `name`. */
|
|
85
|
+
const UI_SCHEMA = {
|
|
86
|
+
type: 'object',
|
|
87
|
+
properties: {
|
|
88
|
+
name: {
|
|
89
|
+
type: 'string',
|
|
90
|
+
description: 'A component name (e.g. button, accordion, dialog). Omit for the full kit inventory.',
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
required: [],
|
|
94
|
+
};
|
|
95
|
+
|
|
84
96
|
/** `source` reads the framework source: a `path` to read, a `query` to grep, or a `package` to list. */
|
|
85
97
|
const SOURCE_SCHEMA = {
|
|
86
98
|
type: 'object',
|
|
@@ -127,6 +139,12 @@ const TOOL_DEFS = [
|
|
|
127
139
|
'Read the FRAMEWORK authored source (webjs is buildless: node_modules/@webjsdev/*/src is the JSDoc source, run directly server-side; only the core browser bundle is built into dist/, which this skips). Pass `path` to read a file (e.g. server/src/ssr.js), `query` to grep the @webjsdev/* src trees, or no args to list the packages + entry points. Use when the docs do not answer something. Read-only.',
|
|
128
140
|
inputSchema: SOURCE_SCHEMA,
|
|
129
141
|
},
|
|
142
|
+
{
|
|
143
|
+
name: 'ui',
|
|
144
|
+
description:
|
|
145
|
+
'The @webjsdev/ui component kit (shadcn-style, copied into your repo). No args returns the inventory (each component: tier, helper signatures, npm deps); pass `name` for one component: helper signatures, the paste-ready structural example, the a11y/description header, and deps. Use before hand-writing UI so you reach for a helper (e.g. buttonClass()) and the accessible structure instead of expanding Tailwind blind. Reads the kit, not your app. Read-only.',
|
|
146
|
+
inputSchema: UI_SCHEMA,
|
|
147
|
+
},
|
|
130
148
|
{
|
|
131
149
|
name: 'list_routes',
|
|
132
150
|
description:
|
|
@@ -358,6 +376,50 @@ export function makeToolRunners(deps) {
|
|
|
358
376
|
};
|
|
359
377
|
}
|
|
360
378
|
|
|
379
|
+
/**
|
|
380
|
+
* Run the `ui` tool: the @webjsdev/ui kit inventory (no args) or one component's
|
|
381
|
+
* projection (helper signatures + paste-ready example + a11y/description header
|
|
382
|
+
* + deps). Projects the SHARED `@webjsdev/ui/registry/extract` leaf, the same
|
|
383
|
+
* one `webjsui view` renders, so the CLI and MCP cannot drift (#983, the #979
|
|
384
|
+
* shared-projector pattern). `uiDeps` is injected so tests need no real package.
|
|
385
|
+
*
|
|
386
|
+
* @param {{ uiInventory: Function, uiComponent: Function }} uiDeps
|
|
387
|
+
* @param {{ name?: string }} args
|
|
388
|
+
*/
|
|
389
|
+
/**
|
|
390
|
+
* Resolve the `ui` tool's deps from the shared `@webjsdev/ui/registry/extract`
|
|
391
|
+
* leaf, GUARDED (#983): if `@webjsdev/ui` is absent or too old to carry the
|
|
392
|
+
* `./registry/extract` subpath (a cross-package version skew), only the `ui`
|
|
393
|
+
* tool degrades (its deps throw a clear "unavailable" error, surfaced as an
|
|
394
|
+
* isError tool result) while the rest of the server keeps working. An unguarded
|
|
395
|
+
* top-level import would instead reject during bootstrap and sink the whole
|
|
396
|
+
* server. `importer` is injected so the failure path is unit-testable.
|
|
397
|
+
*
|
|
398
|
+
* @param {() => Promise<{ uiInventory: Function, uiComponent: Function }>} importer
|
|
399
|
+
* @returns {Promise<{ uiInventory: Function, uiComponent: Function }>}
|
|
400
|
+
*/
|
|
401
|
+
export async function loadUiDeps(importer) {
|
|
402
|
+
try {
|
|
403
|
+
const ui = await importer();
|
|
404
|
+
return { uiInventory: ui.uiInventory, uiComponent: ui.uiComponent };
|
|
405
|
+
} catch {
|
|
406
|
+
const unavailable = () => {
|
|
407
|
+
throw new Error('@webjsdev/ui is not available (install/upgrade @webjsdev/ui to use the ui tool)');
|
|
408
|
+
};
|
|
409
|
+
return { uiInventory: unavailable, uiComponent: unavailable };
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
export function runUiTool(uiDeps, args) {
|
|
414
|
+
const name = typeof args.name === 'string' && args.name ? args.name : null;
|
|
415
|
+
if (!name) return { inventory: uiDeps.uiInventory() };
|
|
416
|
+
const component = uiDeps.uiComponent(name);
|
|
417
|
+
if (!component) {
|
|
418
|
+
throw new Error(`Unknown component "${String(name)}". Call ui with no args for the inventory.`);
|
|
419
|
+
}
|
|
420
|
+
return component;
|
|
421
|
+
}
|
|
422
|
+
|
|
361
423
|
/**
|
|
362
424
|
* A JSON-RPC 2.0 result frame.
|
|
363
425
|
* @param {string|number|null} id
|
|
@@ -454,6 +516,13 @@ export async function runMcpServer(opts) {
|
|
|
454
516
|
};
|
|
455
517
|
}
|
|
456
518
|
|
|
519
|
+
// The `ui` tool (#983): the @webjsdev/ui kit inventory / per-component
|
|
520
|
+
// projection, read from the shared `@webjsdev/ui/registry/extract` leaf.
|
|
521
|
+
// Injectable for tests; otherwise resolved (guarded) from the installed
|
|
522
|
+
// package. See {@link loadUiDeps} for why the import is guarded.
|
|
523
|
+
let uiDeps = opts.uiDeps;
|
|
524
|
+
if (!uiDeps) uiDeps = await loadUiDeps(() => import('@webjsdev/ui/registry/extract'));
|
|
525
|
+
|
|
457
526
|
/** Write one JSON-RPC frame as a single line to stdout. */
|
|
458
527
|
const send = (frame) => {
|
|
459
528
|
stdout.write(JSON.stringify(frame) + '\n');
|
|
@@ -526,7 +595,9 @@ export async function runMcpServer(opts) {
|
|
|
526
595
|
const args = params.arguments || {};
|
|
527
596
|
// The knowledge tools route to the docs / source layer; they return text.
|
|
528
597
|
const isKnowledgeTool = name === 'init' || name === 'docs' || name === 'source';
|
|
529
|
-
|
|
598
|
+
// `ui` is kit-scoped (not app-scoped), so it is dispatched separately.
|
|
599
|
+
const isUiTool = name === 'ui';
|
|
600
|
+
if (!isKnowledgeTool && !isUiTool && !runners[name]) {
|
|
530
601
|
return rpcError(id, -32602, `Unknown tool: ${String(name)}`);
|
|
531
602
|
}
|
|
532
603
|
const appDir = typeof args.appDir === 'string' && args.appDir ? args.appDir : cwd;
|
|
@@ -537,7 +608,9 @@ export async function runMcpServer(opts) {
|
|
|
537
608
|
: name === 'docs'
|
|
538
609
|
? await searchDocs(docsDeps, args)
|
|
539
610
|
: await runSourceTool(sourceDeps, args)
|
|
540
|
-
:
|
|
611
|
+
: isUiTool
|
|
612
|
+
? runUiTool(uiDeps, args)
|
|
613
|
+
: await runners[name](appDir);
|
|
541
614
|
// Knowledge tools return a markdown string; introspection tools return
|
|
542
615
|
// a JSON-serialisable object.
|
|
543
616
|
const text = typeof result === 'string' ? result : JSON.stringify(result, null, 2);
|