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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions .agents/skills/webjs/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ The table above routes by the job; this one routes by the topic, for when you al
| Server actions, mutations, queries, validation, the `ActionResult` envelope | `references/data-and-actions.md` |
| Sessions, login flows, route protection, `forbidden()` / `unauthorized()` | `references/auth-and-sessions.md` |
| Tailwind, light-DOM tag-prefix rule, tokens, fixed headers, no-reflow layout | `references/styling.md` |
| Where a repeated markup helper lives (`utils/ui/` vs `lib/`), and fragment vs display-only component | `references/styling.md` |
| Client router, prefetch, frames, view transitions, Suspense streaming | `references/client-router-and-streaming.md` |
| Optimistic UI for a user-facing mutation | `references/optimistic-ui.md` |
| The `@webjsdev/ui` component kit (a `components.json` is present): class helpers, tokens, `add` / `view`, the MCP `ui` tool | `references/ui-kit.md` |
Expand Down Expand Up @@ -122,8 +123,10 @@ app/ ROUTING ONLY (thin adapters importing from modules/)
error.ts loading.ts not-found.ts forbidden.ts unauthorized.ts boundaries (nearest wins)
middleware.ts root middleware
modules/<feature>/ actions/ (mutations, *.server.ts), queries/ (reads, *.server.ts),
components/, utils/ (pure), types.ts
lib/ lib/*.server.ts server-only infra, lib/utils/ browser-safe helpers
components/ (custom elements), types.ts,
utils/ (pure; returns data, or an html fragment under utils/ui/)
lib/ lib/*.server.ts server-only infra, lib/utils/ browser-safe helpers,
lib/utils/ui.ts app-wide html fragments (lib/ui/ once they grow)
components/*.ts shared presentational custom elements (one per file)
db/*.server.ts Drizzle: schema, connection
public/* static assets, served at /public/<name>
Expand Down
49 changes: 47 additions & 2 deletions .agents/skills/webjs/references/styling.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,20 @@ When custom CSS IS unavoidable inside a light-DOM component, the tag-prefix inva

## DRY via a JS helper, not `@apply`

When the same Tailwind bundle repeats across 2+ places, extract it into a helper in `lib/utils/ui.ts` that returns an `html` fragment (SSR-time, no client runtime, output identical to inline classes):
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:

| Consumers | Home |
|---|---|
| routes across the app (a heading, a lede, a back link) | `lib/utils/ui.ts` |
| one feature (a todo row, a comment card, a board) | `modules/<feature>/utils/ui/<name>.ts` |

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.

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/`.

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.

The example below is the app-wide tier:

```ts
import { html } from '@webjsdev/core';
Expand All @@ -62,12 +75,44 @@ export default function Post({ params }) {
| Repeats | Action |
|---|---|
| Once | Inline the classes. |
| 2 to 3 times, identical | Extract to `lib/utils/ui.ts`. |
| 2 to 3 times, identical, inside ONE feature | Extract to `modules/<feature>/utils/ui/<name>.ts`. |
| 2 to 3 times, identical, across features or routes | Extract to `lib/utils/ui.ts`. |
| Varies by 1 to 2 props | Extract with a small parameter (`mb: 'sm' \| 'md'`). |
| Radically different per call site | Keep inline, do not force-fit. |

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.

### Fragment or display-only component?

Two questions, in order.

**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.

**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:

| Case | What happens |
|---|---|
| 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 |
| 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 |

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:

| Case | What survives, what breaks |
|---|---|
| a `<ul>` / `<ol>` / `<dl>` child | the list renders, but `ul > li`, `:nth-child`, and list markers now see the wrapper instead of the row |
| 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 |
| 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 |

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.

**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.

**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.

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.

**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.

### A design system for repeated PRIMITIVES: class helpers built on `@webjsdev/ui`

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.
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -411,7 +411,7 @@ Derive the type at every boundary: a DB row from the schema (`typeof todos.$infe

## Styling: Tailwind-first

**Tailwind is the strong default for pages AND light-DOM components.** The lit reflex to scope CSS in a shadow root with `static styles` is the habit to resist in light DOM. When a class bundle repeats, extract it into a `lib/utils/ui.ts` helper returning an `` html`...` `` fragment (SSR-time), NOT a CSS class (no `@apply`). Reserve raw CSS for what utilities cannot express (design tokens / `@theme`, `@property` + `@keyframes`, scrollbar, `prefers-reduced-motion`, complex `color-mix()` / gradients); in light DOM the tag-prefix invariant (#7) still holds, and shadow-DOM components legitimately use `static styles = css\`\``. **Pin a header with `position: fixed`, never `position: sticky`** (a sticky header flickers on iOS WebKit during a client-router nav, #610, because the preserved header plus the scroll-to-top trips a WebKit sticky-repaint bug that the GPU-promotion hacks do NOT fix, so reserve the header height on the content with a `--header-height` offset). See `references/styling.md`.
**Tailwind is the strong default for pages AND light-DOM components.** The lit reflex to scope CSS in a shadow root with `static styles` is the habit to resist in light DOM. When a class bundle repeats, extract it into a helper returning an `` html`...` `` fragment (SSR-time), NOT a CSS class (no `@apply`); it lives in `modules/<feature>/utils/ui/` when one feature consumes it and `lib/utils/ui.ts` when the whole app does. Choose between a fragment and a display-only component on STRUCTURE, not bytes: a component is a tag in the DOM, so reach for the fragment where that extra wrapper cannot exist at all (a `<table>` child, which the parser foster-parents out per the HTML spec, and string output such as a `<webjs-stream>` payload) or where it lands wrong (a `<ul>` / `<dl>` / `<select>` child, which breaks `ul > li` and `select > option`, and a grid or flex child, which makes the wrapper the item), and prefer the component everywhere else. On cost they tie under a page (inert page, elided component) and both ship under an island, where the component additionally carries an element class, its registration, a per-instance upgrade, and the display-only components it renders. Reserve raw CSS for what utilities cannot express (design tokens / `@theme`, `@property` + `@keyframes`, scrollbar, `prefers-reduced-motion`, complex `color-mix()` / gradients); in light DOM the tag-prefix invariant (#7) still holds, and shadow-DOM components legitimately use `static styles = css\`\``. **Pin a header with `position: fixed`, never `position: sticky`** (a sticky header flickers on iOS WebKit during a client-router nav, #610, because the preserved header plus the scroll-to-top trips a WebKit sticky-repaint bug that the GPU-promotion hacks do NOT fix, so reserve the header height on the content with a `--header-height` offset). See `references/styling.md`.

---

Expand Down
5 changes: 4 additions & 1 deletion examples/blog/CONVENTIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -501,7 +501,10 @@ and available everywhere via utility classes (`text-foreground`, `bg-card`,

**Dedup repeated Tailwind class bundles with JS helpers, not `@apply`.**
When the same string of classes appears in 2+ places, extract it into a
small function in `lib/utils/ui.ts`:
small function returning an `html` fragment. App-wide chunks live in
`lib/utils/ui.ts` (this file's examples); a chunk only one feature draws
lives in `modules/<feature>/utils/ui/<name>.ts` instead, so a view helper
never sits unlabelled beside the pure data helpers in `utils/`:

```ts
// lib/utils/ui.ts
Expand Down
64 changes: 64 additions & 0 deletions gallery/modules/stream/components/browser/stream-demo.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// Co-located browser test for the stream demo, in real Chromium with real SSR
// and hydration. The runner UI is `tdd` (suite/test) and there is no assertion
// library, so a tiny inline assert does the job.
//
// This pins the one thing the row fragment has to get right: the SEEDED rows
// (rendered from the html`` shape) and the STREAMED rows (rendered from the
// string shape) come off one class list, so a mutation cannot leave the list
// styled two ways. Both shapes are exercised through the real component.
import { html } from '@webjsdev/core';
import { ssrFixture } from '@webjsdev/core/testing';
import '../stream-demo.ts';

const assert = (cond, msg) => { if (!cond) throw new Error(msg || 'assertion failed'); };
const rows = (el) => [...el.querySelectorAll('#stream-list > li')];
const button = (el, label) => [...el.querySelectorAll('button')].find((b) => b.textContent.trim() === label);
const tick = () => new Promise((r) => setTimeout(r, 0));

suite('<stream-demo>', () => {
test('SSRs the two seeded rows as direct list children', async () => {
const el = await ssrFixture(html`<stream-demo></stream-demo>`);
const ids = rows(el).map((li) => li.id);
assert(ids.join(',') === 'row-1,row-2', `seeded ids, got ${ids}`);
// A direct child, no wrapper element between the list and the row. That is
// the structural reason the row is a fragment and not a display-only element.
assert(rows(el).every((li) => li.parentElement.id === 'stream-list'), 'rows are direct children of the list');
});

test('a streamed row carries the same classes as a seeded row', async () => {
const el = await ssrFixture(html`<stream-demo></stream-demo>`);
const seeded = rows(el)[0].className;
button(el, 'Append').click();
await tick();
const all = rows(el);
assert(all.length === 3, `three rows after append, got ${all.length}`);
const streamed = all[2];
assert(streamed.id === 'row-3', `appended row is row-3, got ${streamed.id}`);
assert(streamed.className === seeded, `streamed row classes match seeded:\n ${streamed.className}\n ${seeded}`);
});

test('the string shape escapes its holes', async () => {
// The html`` shape escapes its own holes; this plain-string one has to do
// it by hand, and it is the shape a reader copies. A raw value here would
// become markup as soon as renderStream() inserted it.
const { streamRowHTML } = await import('../../utils/ui/row.ts');
const out = streamRowHTML('x" onload="boom', '<img src=x onerror=boom>');
assert(!out.includes('<img'), `text hole is escaped, got ${out}`);
assert(!out.includes('" onload'), `attribute hole is escaped, got ${out}`);
});

test('replace keeps the id and reset restores the seed list', async () => {
const el = await ssrFixture(html`<stream-demo></stream-demo>`);
button(el, 'Replace Row 1').click();
await tick();
assert(rows(el)[0].id === 'row-1', 'replace keeps row-1');
assert(rows(el)[0].textContent.includes('replaced'), 'replace swaps the content');
button(el, 'Prepend').click();
await tick();
button(el, 'Reset').click();
await tick();
const ids = rows(el).map((li) => li.id);
assert(ids.join(',') === 'row-1,row-2', `reset restores the seed list, got ${ids}`);
assert(rows(el)[0].textContent.trim() === 'Row 1', 'reset restores the seed content');
});
});
Loading
Loading