@webjsdev/mcp 0.1.18 → 0.1.19

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.
@@ -1,312 +0,0 @@
1
- # Styling
2
-
3
- ## What This Covers
4
-
5
- - Tailwind-first: the strong default for pages AND light-DOM components, and the Lit reflex it counters
6
- - The light-DOM tag-prefix invariant when raw CSS is unavoidable
7
- - Extracting a repeated Tailwind bundle into a `lib/utils/ui.ts` `html` fragment (not `@apply`)
8
- - Design tokens: `:root` / `@theme` in the root layout
9
- - Light-DOM host `display: block` behaviour (and shadow hosts via `:host`)
10
- - When to use `static styles` (shadow DOM)
11
- - Accessible native controls (label association, `aria-pressed`, `aria-label`)
12
- - `position: fixed`, not `sticky`, for a pinned header (the iOS WebKit flicker)
13
- - Even-grid / no-reflow layout tips
14
-
15
- Read this when a task touches a class list, a `<style>`, a design token, a pinned header, or a grid/board/card layout. Sibling ref: `components.md` (light vs shadow DOM, `static styles`, host behaviour in depth).
16
-
17
- ## Tailwind-first is the strong default
18
-
19
- Use Tailwind utilities for pages AND light-DOM components (the default DOM mode). Layout, spacing, color (via `@theme` tokens), typography, borders, radius, shadows, and interaction states (hover/focus/active/disabled, dark mode) are all utility-expressible. Light DOM does not scope styles, so a utility class on a light-DOM element resolves against the global stylesheet exactly as it does on a page. That is why utilities are the right tool there, not an exception.
20
-
21
- ### The Lit muscle-memory trap
22
-
23
- In Lit a component owns a shadow root and scopes its CSS with `static styles = css\`\``, so the reflex is to author scoped CSS or an inline `<style>` with semantic class names (`.hero`, `.card`, `.btn`) per component. In WebJs the default is light DOM, which does NOT scope. A `css` block does nothing without `static shadow = true` (the framework warns at runtime), and an inline `<style>` with bare semantic class names leaks those names into the global namespace. Reach for Tailwind utilities instead. When the same bundle repeats, extract a `lib/utils/ui.ts` helper returning an `html` fragment (below), NOT a CSS class.
24
-
25
- ### The custom-CSS allowlist
26
-
27
- Reserve raw CSS for what utilities genuinely cannot express. This is the exhaustive list, anything outside it should be a utility or a `lib/utils/ui.ts` helper:
28
-
29
- - design-token `:root` + `@theme` definitions (palette, fonts, fluid type scale, motion durations, declared once in the root layout),
30
- - `@property` animated custom properties paired with `@keyframes`,
31
- - `::-webkit-scrollbar` and `scrollbar-color` (no utility surface),
32
- - `prefers-reduced-motion` blocks,
33
- - complex `color-mix()` or gradient effects a utility cannot spell.
34
-
35
- When custom CSS IS unavoidable inside a light-DOM component, the tag-prefix invariant holds (every class selector is prefixed with the component tag). Shadow-DOM components (`static shadow = true`) legitimately author `static styles = css\`\``, the right home for scoped CSS. The Tailwind-first steer is about the LIGHT-DOM default, not shadow DOM.
36
-
37
- ## DRY via a JS helper, not `@apply`
38
-
39
- When the same Tailwind bundle repeats across 2+ places, extract it into a helper that returns an `html` fragment (SSR-time, no client runtime, output identical to inline classes). Where the helper LIVES follows the narrowest-owner rule, so pick the tier by who consumes it:
40
-
41
- | Consumers | Home |
42
- |---|---|
43
- | routes across the app (a heading, a lede, a back link) | `lib/utils/ui.ts` |
44
- | one feature (a todo row, a comment card, a board) | `modules/<feature>/utils/ui/<name>.ts` |
45
-
46
- One file per fragment under `utils/ui/`, because a feature accumulates several and one-per-file keeps them greppable. A fragment promotes from the feature tier to `lib/` only when a second feature genuinely consumes it.
47
-
48
- The app-wide tier grows the same way, on the same judgment `references/module-structure.md` applies to any module. `lib/utils/ui.ts` is where it starts and where it usually stays: small, independent, one-element helpers belong together in one file, however many of them there are (the blog example keeps nine there quite happily). Split to `lib/ui/<name>.ts`, one file per fragment, when a fragment stops being a one-liner, when one composes others, or when the single file is no longer scannable. The framework's own website crossed that line and its four composed page fragments live in `lib/ui/`.
49
-
50
- So `modules/<feature>/utils/ui/`, `lib/utils/ui.ts`, and `lib/ui/` are one convention at three sizes rather than three conventions, and the `ui` segment is the part carrying the meaning at every one of them: inside `modules/<feature>/`, `components/` holds custom elements, `utils/ui/` holds functions returning a `TemplateResult`, and the rest of `utils/` holds functions returning data. Drop the segment and a view fragment ends up beside a pure data helper with nothing in the path to tell them apart.
51
-
52
- The example below is the app-wide tier:
53
-
54
- ```ts
55
- import { html } from '@webjsdev/core';
56
-
57
- /** `● label` kicker: small caps, accent colour, above headings. */
58
- export function rubric(label: string, mb: 'sm' | 'md' = 'md') {
59
- const mbCls = mb === 'sm' ? 'mb-3' : 'mb-4';
60
- return html`
61
- <span class="block font-mono text-[11px] leading-none font-semibold tracking-[0.2em] uppercase text-primary ${mbCls}">● ${label}</span>
62
- `;
63
- }
64
- ```
65
-
66
- ```ts
67
- // app/blog/[slug]/page.ts
68
- import { rubric } from '#lib/utils/ui.ts';
69
-
70
- export default function Post({ params }) {
71
- return html`${rubric('post')}<h1 class="font-serif ...">${title}</h1>`;
72
- }
73
- ```
74
-
75
- | Repeats | Action |
76
- |---|---|
77
- | Once | Inline the classes. |
78
- | 2 to 3 times, identical, inside ONE feature | Extract to `modules/<feature>/utils/ui/<name>.ts`. |
79
- | 2 to 3 times, identical, across features or routes | Extract to `lib/utils/ui.ts`. |
80
- | Varies by 1 to 2 props | Extract with a small parameter (`mb: 'sm' \| 'md'`). |
81
- | Radically different per call site | Keep inline, do not force-fit. |
82
-
83
- 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.
84
-
85
- ### Fragment or display-only component?
86
-
87
- Two questions, in order.
88
-
89
- **First, is this a UNIT or a repeated CLASS BUNDLE?** The helpers this section began with (a heading, a lede, a back link) are the second kind: one element with a class list you did not want to type twice. That is a fragment by definition and never a component; nobody wants `<page-lede>` as a tag in their DOM, and promoting a class bundle to an element is the same over-reach as absorbing a page section into an island. The question below only arises for a genuine unit of markup (a row, a card, a board) that could reasonably be either.
90
-
91
- **Second, for a unit: can the markup carry an extra wrapper element at all?** A component is a tag in the DOM, so choosing one adds a node between the parent and the markup. Usually that is fine and the component is the better choice, since it gets a tag name to target and can grow behaviour later. Two cases make it impossible outright:
92
-
93
- | Case | What happens |
94
- |---|---|
95
- | a `<table>` / `<tbody>` child | the parser FOSTER-PARENTS the element out of the table (an HTML spec rule, not a WebJs one), so it lands BEFORE the table and its cells are adopted by a `<tr>` it no longer owns. The component never renders where you put it |
96
- | output that is not DOM | a `<webjs-stream>` payload, or HTML a `route.ts` returns, is a STRING, and a component has no way to produce one |
97
-
98
- Three more render fine and are wrong in ways that surface later, so treat them as strong reasons rather than hard blocks. Measured in Chromium, the element survives in all three:
99
-
100
- | Case | What survives, what breaks |
101
- |---|---|
102
- | a `<ul>` / `<ol>` / `<dl>` child | the list renders, but `ul > li`, `:nth-child`, and list markers now see the wrapper instead of the row |
103
- | a `<select>` child | the control still offers a wrapped `<option>` (it is in `select.options`), but it is no longer `select > option`, so selector-based CSS and DOM code miss it, and the content model is invalid |
104
- | a `grid` / `flex` child | the wrapper becomes THE ITEM, so the children you meant to lay out sit one level too deep and the track sizing applies to the wrong box |
105
-
106
- Everywhere else the two are interchangeable, and the remaining difference is cost. Be precise about that too, because the obvious summary ("fragments are free, components ship") is wrong in both directions.
107
-
108
- **Rendered only by pages, they cost the same: nothing.** A page is inert or import-only, so a fragment it calls runs at SSR and is never fetched. A component that does no client work is elided, so it is never fetched either. Byte arguments do not decide this case; pick whichever reads better.
109
-
110
- **Rendered by a shipping island, BOTH ship.** A module a shipping component imports is fetched, fragment or not, so the fragment's function is downloaded too (verify it yourself: watch the network panel for the helper's path). What differs is what else comes with it. The component ships a custom element class plus its registration and upgrades once per instance, and, because elision propagates downward, it un-elides every display-only component IT renders. The fragment ships a function and stops there.
111
-
112
- So near an island the fragment is the smaller and more predictable choice, not a free one, and the gap is a class and its blast radius rather than everything. That is a tiebreaker, not a rule: it is worth acting on where a shipping island is or might become the renderer, and not worth reorganising page-level markup over. When it matters, measure with `webjs elision` rather than reasoning from either rule of thumb.
113
-
114
- **The summary.** A repeated class bundle is always a fragment. For a real unit, take the fragment where a wrapper element cannot exist (a table child, or string output) and where it would land wrong (a list, select, grid, or flex child); take the component whenever the markup needs behaviour, wants a tag to target, or might grow either; and treat the byte difference as the last consideration rather than the first.
115
-
116
- ### A design system for repeated PRIMITIVES: class helpers built on `@webjsdev/ui`
117
-
118
- 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), `cn('shadow-lg', 'shadow-red-500')` keeps both (a box-shadow and its colour), `cn('bg-clip-text', 'bg-primary')` keeps both (a clip and a colour, so the gradient-text idiom survives a later background), 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 it is still coarse in two ways. A prefix outside the families it knows is not grouped at all, so both classes are emitted and the winner is left to compiled stylesheet order (`inset-shadow-sm` against `inset-shadow-red-500`, `ring-2` against `ring-red-500`). And where one prefix carries two properties it reads the value against Tailwind's DEFAULT scales, so a `@theme`-extended name it cannot know about can still be misread and evict the wrong class: a custom `--shadow-card` makes `shadow-card` a box-shadow, but `cn` sees an unfamiliar name under a prefix whose bare names are usually colours and treats it as one. 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. Under the opt-in `webjsui lint` (configured by a `lint` block in `components.json`, see `references/ui-kit.md`), `w-9 h-9` is layout and `rounded-full` is shape in the shadcn category taxonomy, so an app running it sets `no-restyle` to `allow: ["layout", "rounded"]` for this idiom to pass: the plain radius group is granted by name without opening the whole `shape` category, and `border-2` beside a helper still fires.
119
-
120
- ```ts
121
- // components/ui/button.ts (npx webjsdev ui add button, themed to your app)
122
- import { cn } from '#lib/utils/cn.ts';
123
- const BASE = 'inline-flex cursor-pointer items-center justify-center ...';
124
- const VARIANTS = { default: 'bg-primary text-primary-foreground ...', secondary: '...' } as const;
125
- const SIZES = { default: 'px-4 py-2 rounded-xl', sm: '...' } as const;
126
- export function buttonClass(o: { variant?: keyof typeof VARIANTS; size?: keyof typeof SIZES } = {}) {
127
- return cn(BASE, VARIANTS[o.variant ?? 'default'], SIZES[o.size ?? 'default']);
128
- }
129
- ```
130
-
131
- ```ts
132
- // a page or component
133
- import { buttonClass } from '#components/ui/button.ts';
134
- html`<button class=${buttonClass({ variant: 'secondary', size: 'sm' })} @click=${...}>Reset</button>`;
135
- ```
136
-
137
- 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.
138
-
139
- **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.
140
-
141
- ## Accessible native controls
142
-
143
- 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:
144
-
145
- - **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.
146
- - **State a toggle's pressed state.** A button that toggles carries `aria-pressed=${on}` so assistive tech announces on/off, not just "button".
147
- - **Name an icon-only button.** A button whose only content is an icon has no accessible name, so give it `aria-label="Delete task"`.
148
-
149
- 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.
150
-
151
- ## Design tokens and theming
152
-
153
- The default stack is a static compiled Tailwind stylesheet (the `webjs.dev.regenerate` rule compiles `public/input.css` to the linked `public/tailwind.css`, so it works with JS off; `webjs regenerate` in `dev.before` / `start.before` builds it only when stale and records the build, and `npm run css:build` is the same compile by hand, unrecorded) 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`).
154
-
155
- **Two halves.** (1) The `@theme inline` maps (`--color-background: var(--background)`) turn token names into utilities, so `bg-background` resolves to `var(--background)`. That is infrastructure; leave it. (2) The VALUES live in `styles/globals.css`, inside the theme block `webjs ui init` owns (between `/* @webjsdev/ui theme */` and `/* @webjsdev/ui theme end */`): the light ones on `:root`, the dark ones on `.dark`, the fonts in the block's `@theme inline`. That is your palette; make it your own, by editing the block or with `npx webjsdev ui init --preset <code>` (a code from webjs.dev/ui/create), which replaces it. The scaffold writes its gallery palette there, and a cleared app keeps it. **Never redefine a theme token in the root layout**: a `<style>` there comes after the stylesheet, so it wins, and every later preset or block edit silently stops showing. Tokens your app adds itself go AFTER the block's end marker, where a preset leaves them alone.
156
-
157
- **`@theme` and `@theme inline` differ in whether the token reaches `:root`, and the difference is silent.** Measured on `tailwindcss@4.3.0`, a token mapped in a theme block is emitted as a real `:root` custom property when:
158
-
159
- | block | token used only through a utility (`border-border`) | token written as a raw `var(--color-x)` in any SCANNED file | token unused |
160
- |---|---|---|---|
161
- | `@theme` | emitted | emitted | not emitted |
162
- | `@theme inline` | NOT emitted (the value is substituted into the utility) | emitted | not emitted |
163
-
164
- The one cell that bites is `inline` plus utility-only usage. Nothing on the page can then inherit `--color-x`, so a raw `var(--color-x)` written somewhere Tailwind never scanned resolves to nothing and the declaration falls back to its initial value (a border or outline silently becomes `currentColor`).
165
-
166
- "Scanned" is wider than it looks, and this is the part worth knowing: Tailwind scans source files as raw text, so a `var(--color-ring)` inside a component's `static styles` template DOES count and forces emission, exactly like one in the stylesheet. That is why the `@webjsdev/ui` kit theme works despite using `inline`. So the rule is not "shadow components need a plain `@theme`". It is: **if a token is only ever used through utilities, and something outside the scanned source needs to inherit it, map that token with a plain `@theme`.** Anything under a configured `@source` is scanned and needs no special handling.
167
-
168
- Whichever form you use, a token nothing references is dropped in both, so an unused mapping is dead configuration rather than a safety net.
169
-
170
- **Light and dark, defined once (DRY), for tokens you write by hand.** The kit's theme block uses `:root` plus `.dark`, the shape a preset writes. For a palette you author yourself (in place of the block's values, or for your own extra tokens after it), you can instead 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:
171
-
172
- ```html
173
- <style>
174
- :root {
175
- --font-sans: ui-sans-serif, system-ui, sans-serif;
176
- color-scheme: light dark; /* follow the OS by default */
177
- --background: light-dark(#ffffff, #1e2226);
178
- --foreground: light-dark(#191c20, #dee2e6);
179
- --card: light-dark(#f7f8fa, #313539);
180
- --primary: light-dark(#1e2226, #dee2e6);
181
- --secondary: light-dark(#eef0f3, #3a3f45);
182
- --muted: light-dark(#f1f3f5, #2a2e33);
183
- --muted-foreground: light-dark(#565c64, #94989c);
184
- --accent: light-dark(#e9ecef, #383d43);
185
- --border: light-dark(#e2e5e9, #3d434b);
186
- --ring: light-dark(#9aa1a9, #6c737b);
187
- --destructive: light-dark(#b3261e, #f2b8b5);
188
- /* a derived token tracks BOTH themes for free via var(--primary) */
189
- --primary-tint: color-mix(in srgb, var(--primary) 22%, transparent);
190
- }
191
- :root[data-theme='light'] { color-scheme: light; } /* the toggle forces a scheme */
192
- :root[data-theme='dark'] { color-scheme: dark; }
193
- </style>
194
- ```
195
-
196
- `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.
197
-
198
- **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. When the palette itself lives on `.dark` (the kit's theme block, the scaffold's shape), switch `color-scheme` on the same class (`:root { color-scheme: light } :root.dark { color-scheme: dark }`, with `<meta name="color-scheme" content="light">`): only the script sets `.dark`, so with JavaScript off the page is light throughout, where `color-scheme: light dark` would draw OS-dark native controls and scrollbars over light tokens. Verify dark mode in a real browser. Light mode passing proves nothing about dark.
199
-
200
- **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.
201
-
202
- **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.
203
-
204
- **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).
205
-
206
- ## A stylesheet for one section of the app
207
-
208
- When one part of the app has its own stylesheet (an admin area, a docs section, a component gallery), link it from THAT section's layout, as the first thing the layout renders, and leave the root layout alone:
209
-
210
- ```ts
211
- // app/admin/layout.ts
212
- export default function AdminLayout({ children }: LayoutProps) {
213
- return html`
214
- <link rel="stylesheet" href=${asset('/public/admin.css')}>
215
- <div class="admin-shell">${children}</div>
216
- `;
217
- }
218
- ```
219
-
220
- The server moves a `<link rel="stylesheet">` at the start of any layout's or page's output into `<head>`, after the root layout's own links, so it blocks first paint like a root stylesheet does (#1669). Comments and other `<link>` tags before it are fine; any other element ends the run, so a link further down stays where it is. A second link to a file the head already links is dropped, whether its href is written relative or absolute (a link with a different `media` is a different sheet, so a print copy and a screen copy are both kept). A page with a `loading.{js,ts}` boundary is the one exception: it streams in after the head is sent, so its own leading link is not hoisted; link that page's sheet from its layout instead. The hoisted link is marked with the layout that owns it, so when a soft navigation leaves the section the client router switches the sheet off, and switches it back on when the reader returns (see `references/client-router-and-streaming.md`). Do NOT also link the section's sheet from the root layout by pathname: the root layout is not re-rendered on a soft navigation, so that copy is never switched off and outlives the section.
221
-
222
- ## Light-DOM host display, and shadow hosts
223
-
224
- A custom element is `display: inline` in plain CSS, which collapses a component used as a block container to its content size. WebJs marks every LIGHT-DOM host `data-wj-host` and defaults it to `display: block` via one head rule in a low-priority cascade layer (`@layer webjs-host { :where([data-wj-host]) { display: block } }`), so a container component does not collapse. The layer keeps it overridable: any author style INCLUDING a Tailwind utility (`class="flex"`, `grid`, `hidden`) wins over it, and `[hidden]` still hides the host so `?hidden=${cond}` works. Opt into an inline light component with a tag-prefixed rule (`my-badge { display: inline }`).
225
-
226
- Shadow-DOM hosts are NOT marked (a document rule would override the shadow tree's own `:host`), so a shadow component sets its host display the idiomatic way in `static styles`:
227
-
228
- ```ts
229
- static styles = css`:host { display: block }`; // a shadow host with no :host display stays inline
230
- ```
231
-
232
- **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.
233
-
234
- ## Section rhythm: one gap, defined once
235
-
236
- 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:
237
-
238
- ```css
239
- .stack { --section-gap: 1.5rem; display: flex; flex-direction: column; gap: var(--section-gap); }
240
- .stack > * { margin: 0; }
241
- ```
242
-
243
- 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.
244
-
245
- ## Even grids, no reflow
246
-
247
- 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:
248
-
249
- ```html
250
- <!-- a 3x3 board whose cells stay equal and square as it fills -->
251
- <div class="grid gap-2 aspect-square [grid-template-columns:repeat(3,1fr)] [grid-template-rows:repeat(3,1fr)]">
252
- ${cells.map((c) => html`
253
- <button class="grid place-items-center min-h-0 overflow-hidden text-[clamp(1rem,8cqi,3rem)]">${c}</button>
254
- `)}
255
- </div>
256
- ```
257
-
258
- - `aspect-ratio` (e.g. `aspect-square`) on the CONTAINER plus `repeat(N,1fr)` columns AND rows keeps every cell an equal square that does not resize as marks are placed. Putting `aspect-square` on the CELLS is the common mistake that produces uneven rows.
259
- - `min-h-0` + `overflow-hidden` on a cell stops its content forcing the track taller (a grid/flex child has an implicit `min-height: auto`).
260
- - Size text relative to the cell (`clamp()`, container-query units `cqi`) so the glyph scales with the board rather than dictating the cell size.
261
-
262
- Verify a layout by USING it, not by glancing at the first paint. A layout bug only shows mid-interaction: play through every state (fill the board, win, reload) and confirm nothing resizes.
263
-
264
- ## Pin a header with `position: fixed`, never `sticky`
265
-
266
- A `position: sticky` header (the common `sticky top-0` pattern) flickers its background for one frame on iOS WebKit (every iOS browser uses WebKit) during a client-router forward navigation. The router's scroll-to-top after the content swap drives a sticky recompute that WebKit mis-repaints. It is iOS-only (fine on desktop and Android, invisible in DevTools emulation), and neither compositor promotion (`translateZ(0)` / `will-change`) nor changing the swap paint timing fixes it. Preserving the header across nav is correct and standard, only the `sticky` positioning is the problem.
267
-
268
- The fix is `position: fixed`. A fixed header is always pinned and never does the scroll-relative recompute, so the repaint bug never fires. Because fixed leaves normal flow, reserve the header height on the content below with a single `--header-h` custom property, and size the header BY that property:
269
-
270
- ```css
271
- :root { --header-h: 3.5rem; }
272
- header { position: fixed; inset-inline: 0; top: 0; height: var(--header-h); }
273
- body { padding-top: var(--header-h); }
274
- html { scroll-padding-top: var(--header-h); } /* #anchor jumps stop below the header */
275
- ```
276
-
277
- The last line matters as soon as a page has in-page links: without it an `#anchor` jump, native or the client router's `scrollIntoView`, lands the target under the header. Next's app router reads the same `scroll-padding-top` when it scrolls a segment into view, and shadcn's site sets it from its header-height variable.
278
-
279
- With Tailwind, that is `class="fixed inset-x-0 top-0 h-[var(--header-h)]"` on the header. The scaffold's layout already carries the token and a `body:has(> header.fixed)` rule, so pinning its navbar is adding `fixed inset-x-0 top-0 z-20` and nothing else. Pinning from a breakpoint up works the same way: `md:fixed md:inset-x-0 md:top-0` pins it on wider screens only, because `public/input.css` carries a matching rule for each default breakpoint written with Tailwind's `@variant`, so it follows the app's own `--breakpoint-*` values. A breakpoint added under a new name needs its own line beside them.
280
-
281
- **Do not measure the header with JavaScript.** The tempting version, a `<head>` script that writes `offsetHeight` into `--header-h` from a `ResizeObserver`, breaks on a client-router swap that replaces the header (a layout refresh, a form action that re-renders the layouts): the observer fires for the detached old node, reads a height of 0, and the content slides under the navbar with no way back short of a reload. The head script never re-runs, so nothing re-measures. A token the header is sized by cannot drift, because there is nothing to keep in sync.
282
-
283
- A header whose height changes by breakpoint sets the token per breakpoint (`@media (min-width: 768px) { :root { --header-h: 4rem } }`). A header that genuinely wraps to an unknown height belongs in an app-shell scroll container instead: a non-scrolling `100dvh` flex column with `<main>` as the internal scroller, which needs no offset at all but changes the scroll model.
284
-
285
- ### A fixed header and a modal that locks scroll
286
-
287
- 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.
288
-
289
- `@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.
290
-
291
- 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:
292
-
293
- ```css
294
- header { position: fixed; inset-inline: 0; top: 0; border-right: var(--wj-scrollbar-compensation, 0px) solid transparent; }
295
- ```
296
-
297
- 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.
298
-
299
- 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.
300
-
301
- **Put it on the element that is both viewport-width and painting.** Both halves are load-bearing, and getting either wrong fails quietly.
302
-
303
- - **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.
304
- - **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.
305
-
306
- 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.
307
-
308
- 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.
309
-
310
- 1. Reserve the gutter rather than relying on padding alone, or a fixed element will jump however carefully you compensate the body.
311
- 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.
312
- 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.
@@ -1,223 +0,0 @@
1
- # Testing
2
-
3
- ## What This Covers
4
-
5
- - The four test layers (unit, browser, e2e, smoke) and where each file lives.
6
- - The `handle()` harness from `@webjsdev/server/testing` for driving the real request pipeline against a native `Response`.
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
- - Bun cross-runtime parity for runtime-sensitive code.
9
- - Rendering the app and LOOKING for visual defects a static check cannot catch (a collapsed or reflowing layout).
10
- - Convention validation with `webjs check`.
11
-
12
- Read this when you are adding tests for a feature, deciding which layer a test belongs in, or verifying a UI or theming change. For component mount and hydration helpers see `components.md`. For testing actions and the `ActionResult` envelope see `data-and-actions.md`.
13
-
14
- ## Test layers
15
-
16
- Feature folders are primary, and the test kind is a subfolder inside the feature ONLY when that kind is present. Never create an empty `browser/` or `e2e/` folder.
17
-
18
- | Kind | Location | What it does |
19
- |---|---|---|
20
- | unit + integration | `test/<feature>/<name>.test.{js,ts,mjs}` | Imports modules and asserts. No spawned process, no network. |
21
- | browser | `test/<feature>/browser/<name>.test.js` | Real DOM, events, shadow / light DOM, in Chromium, Firefox, and WebKit. |
22
- | e2e | `test/<feature>/e2e/<name>.test.{ts,mjs}` | Boots a real process and drives it over HTTP / browser / stdout. Opt in with `WEBJS_E2E=1`. |
23
- | smoke | `test/<feature>/smoke/<name>.test.{js,ts}` | Fast deploy-time sanity check, a subset of e2e in spirit. |
24
-
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
-
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
-
56
- ## App runners (`webjs test`)
57
-
58
- ```sh
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
62
- ```
63
-
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.
65
-
66
- An app with no browser tests yet (for example right after `npm run gallery:clear`) is not a failure: `webjs test --browser` sees that no file matches the config's `files` globs, prints `no browser tests yet`, and exits 0, the same way the server layer passes with zero files. So `npm run ci` stays green until you write the first browser test, and from then on the browser layer runs as normal.
67
-
68
- A scaffolded app has one root `test/` directory shaped the same way (feature first, kind second):
69
-
70
- ```
71
- test/
72
- auth/
73
- auth.test.ts # signup / login / currentUser
74
- browser/login-form.test.js # only if exercising DOM
75
- posts/
76
- posts.test.ts
77
- browser/post-editor.test.js
78
- ```
79
-
80
- ## The `handle()` harness (`@webjsdev/server/testing`)
81
-
82
- `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.
83
-
84
- ```js
85
- import { createRequestHandler } from '@webjsdev/server';
86
- import { testRequest, submitForm, invokeActionForTest, rawActionRequest, loginAndGetCookies, withSessionCookie }
87
- from '@webjsdev/server/testing';
88
-
89
- const app = await createRequestHandler({ appDir: process.cwd(), dev: true });
90
-
91
- const res = await testRequest(app.handle, '/about');
92
- assert.equal(res.status, 200);
93
- assert.match(await res.text(), /About/);
94
- ```
95
-
96
- A bare path is prefixed with a dummy origin (the pipeline reads only `pathname` and `search`); a full URL string or a pre-built `Request` also works. The optional third arg is a standard `RequestInit`.
97
-
98
- ### Auth and session helpers
99
-
100
- Server-action CSRF is an Origin / `Sec-Fetch-Site` check, so a test needs no CSRF setup. `loginAndGetCookies` drives the REAL credentials login through `handle()` and captures the genuine signed session cookie, so a follow-up request can hit a protected route as the logged-in user.
101
-
102
- ```js
103
- const gated = await testRequest(app.handle, '/dashboard');
104
- assert.equal(gated.status, 302); // -> /login
105
-
106
- const { cookies } = await loginAndGetCookies(app.handle, { email, password });
107
- const dash = await testRequest(app.handle, '/dashboard', withSessionCookie({}, cookies));
108
- assert.equal(dash.status, 200);
109
- ```
110
-
111
- ### `submitForm`: the no-JS write path
112
-
113
- 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.
114
-
115
- ```js
116
- import { submitForm } from '@webjsdev/server/testing';
117
-
118
- // success is a 303 PRG
119
- const ok = await submitForm(app.handle, '/signup', { name: 'Ada', email: 'ada@example.com', password: 'hunter2' });
120
- assert.equal(ok.status, 303);
121
- assert.equal(ok.headers.get('location'), '/dashboard');
122
-
123
- // a failure result re-renders the SAME page at 422 with the result on actionData
124
- const bad = await submitForm(app.handle, '/signup', { email: 'not-an-email' });
125
- assert.equal(bad.status, 422);
126
- assert.match(await bad.text(), /Enter a valid email/);
127
- ```
128
-
129
- 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`.
130
-
131
- 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.
132
-
133
- ### `invokeActionForTest`: round-trip an action through the REAL endpoint
134
-
135
- ```js
136
- // modules/posts/actions/create.server.ts exports createPost
137
- const out = await invokeActionForTest(app, 'modules/posts/actions/create.server.ts', 'createPost', [input]);
138
- ```
139
-
140
- It serializes the args with the WebJs serializer exactly as the generated client stub does, POSTs them same-origin to `/__webjs/action/<hash>/<fn>`, and parses the response. Prefer this over a direct import of the action. A direct import bypasses three production concerns the endpoint enforces (the wire serializer, CSRF, and prod error sanitization), so `invokeActionForTest` catches a regression a direct import cannot see. For negative cases, `rawActionRequest(app, file, fn, args, { crossOrigin: true })` returns the raw `Response` and never throws on a non-2xx (pass `{ omitCsrf: true }` to drop the CSRF pair).
141
-
142
- ## When a browser or e2e test is REQUIRED
143
-
144
- A unit test is necessary but NOT sufficient for any change to hydration, the client router, slots, or custom-element upgrade. The headline behaviour of these is a browser or e2e assertion, so ship one:
145
-
146
- - Hydration and the SSR-then-hydrate agreement.
147
- - Client-router navigation, form submissions through the router, prefetch.
148
- - Slots and light / shadow DOM projection.
149
- - Custom-element upgrade of the SSR'd tag.
150
- - Progressive soft-nav streaming (assert the fallback is live at the moment the URL advances) belongs in e2e (`WEBJS_E2E=1`).
151
-
152
- Component mount helpers (`fixture`, `ssrFixture`, `waitForUpdate`) come from `@webjsdev/core/testing`; see `components.md`.
153
-
154
- ## Rendering the app and looking for UI defects
155
-
156
- A layout bug (a board that collapses, cells of unequal size, a grid that resizes as it fills) is invisible to `webjs check`, `webjs typecheck`, and a glance at the empty first paint. Static tools give no signal for a visual defect, so render the app in a real browser and look.
157
-
158
- - A browser test can measure real geometry with `getBoundingClientRect()` and FAIL on the defect. There is no framework helper (it is a few lines); write it against your component and assert its children stay the same size and do not resize as the grid fills. Ship one for any grid, board, or gallery layout.
159
- - Test both light AND dark mode. Light mode passing proves nothing about dark mode. Emulate dark (a `newContext({ colorScheme: 'dark' })` or the theme toggle) and inspect a component's COMPUTED `background-color` and `color`, not just the page chrome.
160
- - Read the screenshot. Capture `page.screenshot({ fullPage: true })` and open the PNG. White-on-white or a stray light box is obvious visually and invisible in the markup.
161
-
162
- ## Bun cross-runtime parity
163
-
164
- 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).
165
-
166
- 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.
167
-
168
- ### Writing a plain proof script that boots a server
169
-
170
- 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:
171
-
172
- - **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.
173
- - **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.
174
-
175
- ## Proving display-only elision did not break anything
176
-
177
- WebJs strips the JavaScript of every component that does no client work, so a wrong verdict costs an app real interactivity and does it silently. Two commands cover the two halves, and you need both.
178
-
179
- ```sh
180
- webjs elision --verify
181
- ```
182
-
183
- renders every static page route with elision on and off and diffs the served bytes. It is the framework's own differential guard pointed at your route table, and it exits non-zero on a divergence AND on a corpus where nothing could be compared, so it belongs in CI. It forces the ON side on rather than reading your config, and reports how many modules elision actually dropped, so a pass that compared two identical renders is visible rather than silent. Dynamic routes are skipped by name; add real paths with `--routes /,/blog/hello`.
184
-
185
- That proves the bytes you SERVE did not change. It cannot prove post-hydration behaviour, because a wrongly dropped module shows up as a dead click, not as different bytes. Run your own browser or e2e suite twice for that half:
186
-
187
- ```sh
188
- WEBJS_ELIDE=1 npm run test:e2e
189
- WEBJS_ELIDE=0 npm run test:e2e
190
- ```
191
-
192
- A test that passes under one and fails under the other is a wrong verdict, and `webjs elision` tells you which module and on what evidence. If the component's interactivity is genuinely invisible to static analysis, the fix is `static interactive = true` on it; see `components.md` for what that override does and does not rescue.
193
-
194
- ## One command for every layer (`webjs ci`)
195
-
196
- ```sh
197
- npm run ci # the webjs.ci list: check, doctor, typecheck, audit, then every test layer
198
- npm run ci -- --only Tests # one step or group by title
199
- npm run ci -- --fail-fast # stop at the first failure
200
- npm run ci -- --json # one JSON document for an agent loop
201
- ```
202
-
203
- The scaffold declares its gate once, in `package.json` under `webjs.ci`, and `npm run ci` runs it with a timed result line per step (the Rails `bin/ci` model). The generated GitHub workflow runs the same list, so a green local run predicts CI. Run it before every push; the pre-commit hook deliberately runs none of it so a commit stays fast. Browser and e2e steps need a Chromium on the machine (`npx playwright install chromium`, plus `puppeteer-core` for the e2e layer), which the workflow installs for itself. The list, the flags, the `--signoff` merge gate, and the JSON shape are in `references/built-ins.md` under "Local CI".
204
-
205
- ## Type-checking your tests (`webjs typecheck`)
206
-
207
- Your tests are inside the tsconfig `include`, so `npm run typecheck` reads them (#1299). Treat a type error in a test as a failed gate, not a review catch: the checker sees a wrong argument shape or an unannotated parameter in a test the same way it sees one in `app/`.
208
-
209
- Write them to the same bar as app code, then. No `any`, no blanket `@ts-expect-error`. When a test needs a complete props object the framework would normally build, put a small typed helper in `test/helpers/` and import it rather than reaching for a cast; a cast in a test silences the one thing that would have told you the call was wrong.
210
-
211
- `.js` test files follow whatever `checkJs` says. With it off they are parsed and not checked, which is the usual setup for browser tests a real browser runs.
212
-
213
- ## Convention validation (`webjs check`)
214
-
215
- `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.
216
-
217
- ## What NOT to do
218
-
219
- - Do not recreate a top-level `test/{unit,browser,e2e}/` shape. Kind is a child of feature, never the reverse.
220
- - Do not create empty kind folders.
221
- - Do not import from another package's `test/` directory. Test code is not a public surface.
222
- - Do not add `.unit` / `.integration` filename suffixes. The folder tells you the kind.
223
- - Do not run WTR or Playwright inside a headless sandbox that lacks the transform plugins or native browser libraries. Instead, extract the reconciliation or optimistic-update logic into a pure browser-safe utility and cover it with a Node unit test.