Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
74 commits
Select commit Hold shift + click to select a range
b5b65fa
feat: ship @webjsdev/ui class-helper primitives into the gallery scaf…
vivek7405 Jul 23, 2026
7bc33aa
feat: theme the gallery button helper and convert every demo button
vivek7405 Jul 23, 2026
419e2e6
feat: theme card + input helpers, convert panels/fields, drop unused …
vivek7405 Jul 23, 2026
7ed18ed
docs: teach the class-helper design-system pattern in the styling ref…
vivek7405 Jul 23, 2026
ecd0ef3
chore: keep components/ui design system across gallery:clear (make ex…
vivek7405 Jul 23, 2026
e842805
fix: copy the gallery components/ (design system) into scaffolded apps
vivek7405 Jul 23, 2026
36a36bf
chore: remove the example design system on gallery:clear (blank slate)
vivek7405 Jul 23, 2026
dc21c54
chore: make gallery:clear a truly barebones blank slate
vivek7405 Jul 23, 2026
f384216
chore: make scaffold-sync enforce the skill as a durable teacher
vivek7405 Jul 23, 2026
c52b1dd
test: cover the truly-minimal gallery:clear blank slate
vivek7405 Jul 23, 2026
be343b7
docs(skill): teach effect/batch, Task, context, and the directive set
vivek7405 Jul 23, 2026
4624f24
docs(skill): teach enableClientRouter, connectWS handle, and gotchas
vivek7405 Jul 23, 2026
e8f275d
docs(skill): teach actionSignal cancellation and the middleware ctx s…
vivek7405 Jul 23, 2026
b0762d5
docs(skill): teach the route-handler toolkit, richFetch, and image ro…
vivek7405 Jul 23, 2026
08e6f6c
docs(skill): fix the session API and teach the full auth flow
vivek7405 Jul 23, 2026
efc7d49
docs(skill): teach env.ts boot validation and signedUrl base
vivek7405 Jul 23, 2026
907164e
docs(skill): teach degrade-first optimistic forms and control a11y
vivek7405 Jul 23, 2026
f23a310
docs(skill): route the newly-documented exports from SKILL.md
vivek7405 Jul 23, 2026
0189da9
test: the agent skill satisfies the scaffold gate
vivek7405 Jul 23, 2026
67fe0d7
refactor(gallery): build the last hand-rolled buttons on the design s…
vivek7405 Jul 23, 2026
4a17111
docs(gallery): explain the like-button border override
vivek7405 Jul 23, 2026
736bc9e
feat(gallery): add badgeClass, a destructive button variant, ui.ts he…
vivek7405 Jul 23, 2026
59eea1f
refactor(gallery): build the base theme-toggle + home on the design s…
vivek7405 Jul 23, 2026
36cb3d8
refactor(gallery): route every demo through the design system
vivek7405 Jul 23, 2026
69901a1
chore(gallery): clear removes lib/utils/ui.ts; update tests for helpers
vivek7405 Jul 23, 2026
14ff1c8
test: the full-stack scaffold ships the gallery design system
vivek7405 Jul 23, 2026
f809db0
fix(ui): cn resolves shorthand-vs-axis conflicts (p-0 over px-4 py-2)
vivek7405 Jul 23, 2026
16bf72d
fix(blog): sync the dogfood cn to the shorthand-conflict fix
vivek7405 Jul 23, 2026
71d4d2a
feat(gallery): add a grouped left sidebar + centered docs-style layout
vivek7405 Jul 23, 2026
586f3fd
chore(scaffold): enforce gallery:clear parity + center the example app
vivek7405 Jul 23, 2026
ad06f41
fix(gallery): sidebar back link reads Gallery, drop the horizontal sc…
vivek7405 Jul 23, 2026
bac5f03
fix(gallery): highlight parent demo on subroutes + stop tab reflow
vivek7405 Jul 23, 2026
21bcd92
fix(gallery): rename stream-demo append/prepend (native-method shadow…
vivek7405 Jul 23, 2026
50d1b2d
feat(gallery): redirect a signed-in visitor away from login/signup
vivek7405 Jul 23, 2026
210e560
fix(gallery): underline prose text links so they read as links
vivek7405 Jul 23, 2026
aca2ba0
fix(gallery): pin the sidebar back link above the scrolling demo list
vivek7405 Jul 23, 2026
3c41cce
style(gallery): subtle auto-hiding sidebar scrollbar
vivek7405 Jul 23, 2026
d7e7180
feat(check): flag a component method shadowing a native DOM mutation …
vivek7405 Jul 23, 2026
62e65c8
fix(gallery): crisp light theme + theme-toggle on every page
vivek7405 Jul 23, 2026
1795672
refactor(gallery): add bareInputClass for the borderless in-card input
vivek7405 Jul 23, 2026
4f2d1d6
feat(gallery): floating WebJs Gallery navbar + DRY light-dark tokens
vivek7405 Jul 23, 2026
ee9704e
feat(gallery): gallery:clear resets to a token-free blank slate
vivek7405 Jul 23, 2026
55e792c
docs(skill): teach the design-token + light-dark() theming setup
vivek7405 Jul 23, 2026
93f9dd1
style(gallery): widen the gap between explanatory prose paragraphs
vivek7405 Jul 23, 2026
3159d31
style(gallery): widen prose paragraph gap further to ~32px
vivek7405 Jul 23, 2026
5a6f3a7
style(gallery): make caching's inter-paragraph gap consistent (32px)
vivek7405 Jul 23, 2026
edd5c97
feat(gallery): uniform section rhythm via a single stack container
vivek7405 Jul 23, 2026
917c02d
style(gallery): tighten section gap to 1.5rem (24px)
vivek7405 Jul 23, 2026
2a8f3e5
fix(gallery): streaming demo reserves its output box only while strea…
vivek7405 Jul 23, 2026
e05fc2a
fix(gallery): streaming output box fits its content, not a fixed min-…
vivek7405 Jul 23, 2026
dd716fe
fix(gallery): section rhythm survives streaming + display:contents
vivek7405 Jul 23, 2026
932c890
style(gallery): more bottom spacing under the pinned '← Gallery' link
vivek7405 Jul 23, 2026
37e8aa7
fix(gallery): section rhythm via flex column, not forced display:block
vivek7405 Jul 23, 2026
3e40f28
fix(gallery): make the dark-mode border visible against the card fill
vivek7405 Jul 23, 2026
7563036
fix(gallery): restore a clear hover step for the dark border
vivek7405 Jul 23, 2026
64cf1ea
fix(gallery): themed focus ring for the shadow-DOM component's buttons
vivek7405 Jul 23, 2026
75f2055
fix(gallery): revert dark --border-strong to #454b51
vivek7405 Jul 23, 2026
64b3d77
fix(gallery): buttons use the design-system global focus ring
vivek7405 Jul 23, 2026
5ff4a70
docs(skill): document the focus-ring convention (incl. shadow-DOM)
vivek7405 Jul 23, 2026
d5a7e7a
fix(gallery): shadow focus ring matches the design-system --ring/50
vivek7405 Jul 23, 2026
0066c71
fix(gallery): force a SOLID focus outline so the themed colour renders
vivek7405 Jul 23, 2026
963e1c0
fix(scaffold): favicon actually shows (emit the link into <head>)
vivek7405 Jul 23, 2026
f36cbfc
fix(gallery): favicon uses the dark-theme brand mark
vivek7405 Jul 23, 2026
e401f37
fix(server): hoist <meta> so it doesn't strand head tags in <body>
vivek7405 Jul 23, 2026
bd6b49e
docs(skill): sync styling.md with the gallery (border hex, section rh…
vivek7405 Jul 23, 2026
874a9b6
fix(gallery): metadata routes use the neutral palette, drop dead comm…
vivek7405 Jul 23, 2026
7f60ccb
docs(scaffold): rule files match the token-free gallery:clear blank s…
vivek7405 Jul 23, 2026
244e0f3
fix(gallery): stack zeroes only block margins, so mx-auto centering l…
vivek7405 Jul 23, 2026
fc766a3
fix(check): scope no-shadowed-native-member to real instance shadowing
vivek7405 Jul 23, 2026
3776556
fix(server): head hoist is quote-aware, a > in an attribute cannot tr…
vivek7405 Jul 23, 2026
8953ea1
fix(gallery): clear keeps a hand-customised layout; polish emitted text
vivek7405 Jul 23, 2026
87643af
test(scaffold): cover the gallery:clear brand guard; document it
vivek7405 Jul 23, 2026
bb0d3bc
docs: explain no-shadowed-native-member in the gotcha reference
vivek7405 Jul 23, 2026
1d10aae
Merge branch 'main' into feat/gallery-ui-design-system
vivek7405 Jul 23, 2026
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
6 changes: 4 additions & 2 deletions .agents/skills/webjs/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,17 +112,19 @@ Find the right export fast. Load the linked reference for full examples.
### `@webjsdev/core` (browser + isomorphic)

- `html` / `css` tagged templates. `WebComponent({ ... })` base-class factory; `prop(type?, opts?)` declares one reactive property. `register(tag, C)` / `Class.register('tag')`.
- `signal` / `computed` reactive state; `render(v, el)` client render.
- `signal` / `computed` reactive state, `effect(fn)` client-only reaction (returns a disposer), `batch(fn)` coalesced writes; `render(v, el)` client render.
- `notFound()` / `redirect(url[, status])` control-flow throws (page/layout/action only, NOT `route.ts`). `forbidden()` / `unauthorized()` render the nearest boundary.
- `Suspense({fallback, children})` page-level streaming; `<webjs-suspense>` component-level streaming.
- `optimistic()` optimistic UI; `navigate(url)` / `revalidate(url?)` client-router control; `connectWS` / `richFetch`.
- Types: `Metadata`, `PageProps<R>`, `LayoutProps<R>`, `RouteHandlerContext<R>`, `WebjsConfig`.
- `@webjsdev/core/server`: `renderToString` / `renderToStream` (Node side).
- `@webjsdev/core/directives`: `repeat`, `unsafeHTML` (trusted only), `live`, `keyed`, `guard`, `cache`, `until`, `watch(signal)`, `ref` / `createRef`. `Task` lives at `@webjsdev/core/task`, context at `/context`.
- `@webjsdev/core/directives`: `repeat`, `unsafeHTML` (trusted only), `live`, `keyed`, `guard`, `cache`, `until`, `watch(signal)`, `ref` / `createRef`, `asyncAppend` / `asyncReplace`, `templateContent`. `Task` / `TaskStatus` live at `@webjsdev/core/task`, context (`createContext` / `ContextProvider` / `ContextConsumer`) at `/context`. See `references/components.md` for the directive table + Task + context.

### `@webjsdev/server` (server side)

- `createRequestHandler`, `cors()`, `route(action, opts?)` REST adapter, `sitemap()` / `sitemapIndex()`, `actionContext()`, `actionSignal()`, `requestId()`, `cache()` / `revalidateTag`.
- Route-handler toolkit: `json(v)` rich responder, `readBody(req)`, `clientIp(req)`, no-arg `headers()` / `cookies()` / `cspNonce()` (client counterpart `richFetch` is in `@webjsdev/core`). See `references/routing-and-pages.md`.
- Auth + sessions: `createAuth` (+ `Credentials` / `Google` / `GitHub`), `auth()` / `auth(req)`, `session()` + `cookieSession` / `storeSession`, `getSession(req)` (`.get` / `.set` / `.flash` / `.destroy`). File storage: `getFileStore` / `diskStore` / `signedUrl`. See `references/auth-and-sessions.md` + `references/built-ins.md`.
- Data layer is Drizzle in `db/*.server.ts`. Auth, sessions, caching, rate limit, file storage are built in and pluggable (`references/built-ins.md`).

### File conventions
Expand Down
94 changes: 78 additions & 16 deletions .agents/skills/webjs/references/auth-and-sessions.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@

## What This Covers

- Sessions: cookie by default, Redis-backed when configured, the `SESSION_SECRET` requirement
- Authentication: `createAuth` (NextAuth-style), Credentials plus OAuth providers, `auth()` in a page or action
- Login and logout flows (`signIn` / `signOut` / `handlers`)
- Protecting a route: gate at the top of a page or action, redirect when unauthenticated
- Sessions: the `session()` middleware + storage factories (`cookieSession` / `storeSession`), the `getSession(req)` method API (`.get` / `.set` / `.flash` / `.destroy`), the `SESSION_SECRET` requirement
- Authentication: `createAuth` (NextAuth-style), Credentials plus OAuth providers, `auth()` in a page or action, scrypt password hashing
- Login and logout flows: mounting `handlers` at `app/api/auth/[...path]`, the no-JS credentials form (`/api/auth/signin/credentials` + `redirectTo` + `?error`), `signIn` / `signOut`
- Protecting a route: a page-top `auth()` gate OR a per-segment `middleware.ts` calling `auth(req)`
- `forbidden()` (403) vs `unauthorized()` (401) and their nearest-wins boundary files
- Returning an `ActionResult` for an auth failure inside a `'use server'` action (do NOT throw there)
- The Origin / `Sec-Fetch-Site` CSRF model (not a token cookie)
Expand All @@ -21,23 +21,30 @@ scaling, the full caching surface).

## Sessions

Enable sessions in middleware, read and write them in a page or action.
Enable sessions with `session()` MIDDLEWARE, then read and write them with `getSession(req)` in any route or middleware the session wraps.

```ts
// middleware.ts: enable on all routes
import { session } from '@webjsdev/server';
export default session(); // auto: REDIS_URL present -> server-side, else -> cookie
// middleware.ts: enable on all routes. Storage is pluggable.
import { session, cookieSession, storeSession } from '@webjsdev/server';
export default session({ secret: process.env.SESSION_SECRET, storage: cookieSession() });
// cookieSession() -> whole session in a signed cookie (stateless, the default)
// storeSession() -> session in the active store (memoryStore in dev, Redis in prod), id in the cookie
```

// in a page or action
`getSession(req)` returns a small key/value `Session` with a METHOD API, not property assignment. Mutating it makes the middleware re-sign and set the cookie on the way out:

```ts
import { getSession } from '@webjsdev/server';
const s = getSession(req);
s.userId = user.id; // auto-saved after the response
export async function GET(req: Request) {
const s = getSession(req);
s.set('userId', user.id); // write
const id = s.get('userId'); // read
s.flash('notice', 'Saved'); // one-read-only value (cleared after the next read)
s.destroy(); // clear the whole session (logout)
}
```

Cookie sessions (the default) are signed and encrypted with no server
state. Store sessions (with Redis) keep the session id in the cookie and
the data in Redis. Both require `SESSION_SECRET`, read from the
environment (never a literal in source) so boot fails if it is missing.
Cookie sessions (the default) are signed with no server state; store sessions keep only the id in the cookie and the data in the store. `cookieSession` / `storeSession` are aliases for `cookieSessionStorage` / `storeSessionStorage`. Both strategies require `SESSION_SECRET`, read from the environment (never a literal in source) so boot fails if it is missing.

## Authentication (`createAuth`)

Expand All @@ -55,17 +62,58 @@ export const { auth, signIn, signOut, handlers } = createAuth({
Credentials({
async authorize(credentials) {
const user = await db.query.users.findFirst({ where: { email: credentials.email } });
if (!user || !verifyPassword(credentials.password, user.passwordHash)) return null;
if (!user || !(await compare(credentials.password, user.passwordHash))) return null;
return { id: user.id, name: user.name, email: user.email, role: user.role };
},
}),
Google(), // reads AUTH_GOOGLE_ID, AUTH_GOOGLE_SECRET
GitHub(), // reads AUTH_GITHUB_ID, AUTH_GITHUB_SECRET
],
secret: process.env.AUTH_SECRET, // required, 32+ random chars, from the env
pages: { error: '/login' }, // a failed sign-in 302s here with ?error=<code>
});
```

**Password hashing is the app's job** (WebJs ships no `verifyPassword`). Use `scrypt` from `node:crypto` (built into Node AND Bun, no dependency) in a server-only utility, and call it from `authorize`:

```ts
// modules/auth/password.server.ts (a server-only utility, never reaches the browser)
import { scrypt, randomBytes, timingSafeEqual } from 'node:crypto';
import { promisify } from 'node:util';
const scryptAsync = promisify(scrypt);
export async function hash(pw: string) {
const salt = randomBytes(16).toString('hex');
return salt + ':' + ((await scryptAsync(pw, salt, 64)) as Buffer).toString('hex');
}
export async function compare(pw: string, stored: string) {
const [salt, key] = stored.split(':');
return timingSafeEqual((await scryptAsync(pw, salt, 64)) as Buffer, Buffer.from(key, 'hex'));
}
```

**Mount `handlers` at an `app/api/auth/[...path]/route.ts` catch-all** (at the app root, NOT under a feature folder): `createAuth` hardcodes `/api/auth/signin/*` and `/api/auth/callback/*` for its form posts and OAuth callback URIs.

```ts
// app/api/auth/[...path]/route.ts
import { handlers } from '#modules/auth/auth.server.ts';
export const GET = handlers.GET;
export const POST = handlers.POST;
```

**The no-JS sign-in / sign-out flow is plain forms** (progressive-enhancement-safe). Sign in by POSTing to `/api/auth/signin/credentials` with a hidden `redirectTo`, and read `?error` (mapped from `pages.error`) for feedback; sign out by POSTing to `/api/auth/signout`:

```html
<form method="POST" action="/api/auth/signin/credentials">
<input type="hidden" name="redirectTo" value="/dashboard">
<input name="email" type="email" required><input name="password" type="password" required>
<button>Sign in</button>
</form>
<!-- log out -->
<form method="POST" action="/api/auth/signout"><button>Log out</button></form>
```

For a programmatic sign-in (the auto-login-after-signup pattern), `signIn('credentials', creds, { redirectTo })` returns a `302` `Response` that a page `action` can return directly.

Sessions are JWT by default (stateless, scales horizontally). OAuth
providers handle the full redirect flow. Read the session anywhere on the
server with `auth()`.
Expand Down Expand Up @@ -119,6 +167,20 @@ Reading the session through `auth()` also auto-excludes the page from the
server HTML response cache, so a per-user page is never cached and served
to another visitor (see `built-ins.md`).

To gate a WHOLE subtree in one place, use a per-segment `middleware.ts` that reads `auth(req)` (the explicit-request form) and returns a `302` BEFORE the page renders. It runs for every request under its segment and needs only a cookie read (no DB query), so the gate is real the moment the app boots:

```ts
// app/dashboard/middleware.ts (protects /dashboard/*)
import { auth } from '#modules/auth/auth.server.ts';
export default async function requireAuth(req: Request, next: () => Promise<Response>) {
const session = await auth(req);
if (!session?.user) return new Response(null, { status: 302, headers: { location: '/login' } });
return next();
}
```

`auth(req)` takes the in-flight request explicitly (for a middleware / route); the ambient `auth()` (no argument) reads from context inside a page or action.

## `forbidden()` (403) vs `unauthorized()` (401)

Two control-flow throws from `@webjsdev/core`, mirroring the `notFound()`
Expand Down
16 changes: 14 additions & 2 deletions .agents/skills/webjs/references/built-ins.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Env vars, caching, rate limiting, broadcast, file storage, and the `package.json

## What This Covers

- **Environment variables** and the `WEBJS_PUBLIC_` browser-exposed prefix.
- **Environment variables**, the `WEBJS_PUBLIC_` browser-exposed prefix, and `env.ts` boot validation.
- **Caching primitives.** `cache()` with tag invalidation, HTTP `Cache-Control`, the server HTML response cache (`export const revalidate`), content-hash asset URLs, conditional GET (ETag).
- **Rate limiting** (`rateLimit()` middleware) and **broadcast** (`broadcast()` over WebSockets).
- **File storage.** `FileStore` / `diskStore`, safe keys, signed URLs.
Expand All @@ -25,6 +25,18 @@ Read this when wiring caching or rate limiting, storing uploads, hardening heade

Defaults are single-instance memory stores. To scale horizontally, switch the store once at startup: `setStore(redisStore({ url: process.env.REDIS_URL }))`.

**Validate required vars at boot with an app-root `env.ts`** (optional). It default-exports either a SCHEMA object (each var mapped to a type `string` / `number` / `boolean` / `url` / `enum`, or an options object with `optional` / `default` / `minLength` / `pattern` / `values`) OR a validator function `(env) => void` that throws. It runs at boot after `.env` loads, coerces values and writes defaults back to `process.env`, and fails fast naming EVERY bad var:

```ts
// env.ts
export default {
DATABASE_URL: 'url',
SESSION_SECRET: { type: 'string', minLength: 16 },
PORT: { type: 'number', default: 8080 },
LOG_LEVEL: { type: 'enum', values: ['debug', 'info', 'warn'], default: 'info' },
};
```

## Caching

### `cache()` for query and computation results
Expand Down Expand Up @@ -112,7 +124,7 @@ setFileStore(diskStore({ dir: '/var/data/uploads', baseUrl: '/files' }));

**Never trust a user filename as a key.** `generateKey(file.name)` returns an opaque `<uuid>.<ext>` with a sanitized extension; a traversal attempt yields a bare safe key. Keys are containment-checked before any filesystem op.

**Signed URLs** gate serving without a session lookup. `signedUrl(key, { secret, expiresIn })` mints an expiring HMAC signature; `verifySignedUrl(searchParams, secret)` returns `{ valid }`. An `expiresIn` of `0` or negative fails closed.
**Signed URLs** gate serving without a session lookup. `signedUrl(key, { secret, expiresIn })` mints an expiring HMAC signature; `verifySignedUrl(searchParams, secret)` returns `{ valid }`. An `expiresIn` of `0` or negative fails closed. Pass `base` to point the signed link at your own serve route instead of the default upload URL: `signedUrl(key, { secret, base: '/files/' + key, expiresIn: 3600 })`.

**Serving-XSS warning.** The recorded content-type is attacker-controlled (the browser sent it at upload). A serving route MUST send `X-Content-Type-Options: nosniff` and SHOULD send `Content-Disposition: attachment` for user uploads. Only serve inline after validating bytes against a strict inert allowlist, never `text/html` / `image/svg+xml`. Add the uploads directory to `.gitignore`.

Expand Down
30 changes: 26 additions & 4 deletions .agents/skills/webjs/references/client-router-and-streaming.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,13 @@ Note for anyone testing this: **the Chromium web-test-runner currently resolves
```

```js
import { disableClientRouter } from '@webjsdev/core';
disableClientRouter();
import { disableClientRouter, enableClientRouter } from '@webjsdev/core';
disableClientRouter(); // stop intercepting document <a> / <form> (plain links resume full loads)
enableClientRouter(); // turn soft navigation back on
```

`disableClientRouter()` / `enableClientRouter()` are a runtime pair that toggle only the document-level `<a>` / `<form>` interception. An explicit `navigate(url)` call still does a soft navigation either way (it is not gated by the toggle).

Per link, opt out with `data-no-router` (auth flows like `/logout`, OAuth redirects, print views, an experimental route with a different runtime). Cross-origin hrefs, `download`, a non-`_self` target, pure same-page hash jumps, and non-HTML extensions are auto-skipped.

**Programmatic navigation and cache eviction.**
Expand Down Expand Up @@ -113,6 +116,13 @@ The router can wrap a navigation's DOM mutation in the native View Transitions A
<meta name="view-transition" content="same-origin">
```

A page (or layout) does not write raw `<head>` markup, so emit that meta through the `other` metadata field, which scopes it to the page that declares it:

```ts
// app/gallery/page.ts
export const metadata = { other: { 'view-transition': 'same-origin' } };
```

The accepted value is `same-origin`. When enabled it wraps every swap path (the two-tier boundary swap, the `<webjs-frame>` swap, and the background-revalidation full-body path). When `startViewTransition` is unavailable the swap runs synchronously with no flash and no throw. To persist a live element (a playing `<audio>`, an open menu) across a swap by node identity, mark it `data-webjs-permanent` and give it an `id`.

The opt-in is **per page**, so it is a page-scoped meta: put it on a page's metadata to animate that page, or on the root layout to animate the whole app. Navigating to a page that does NOT declare it turns transitions back off, because the soft-nav head merge reconciles page-scoped `<meta>` tags (a stale one the previous page declared is removed, not left to leak, #1046). View transitions **compose with Suspense streaming**: a streamed boundary (a `loading.{js,ts}` skeleton or a `<webjs-suspense>` region) navigated to under an active transition still resolves its content progressively, because the streamed resolve waits for the transition's DOM swap to commit before it applies (#1048).
Expand Down Expand Up @@ -188,13 +198,25 @@ export function WS(ws, req, { params }) {
}
```

**Client.** `connectWS(url, handlers)` from `@webjsdev/core` auto-reconnects with exponential backoff, handles JSON parse/stringify, and queues sends while disconnected.
**Client.** `connectWS(url, handlers)` from `@webjsdev/core` auto-reconnects with exponential backoff, handles JSON parse/stringify, and queues sends while disconnected. The handler set is `{ onOpen, onMessage, onClose }`, and it RETURNS a connection handle with `.send(data)` and `.close()`. Open it in `connectedCallback` and close it in `disconnectedCallback`, driving a connection-status signal from `onOpen` / `onClose`:

```js
import { connectWS, renderStream } from '@webjsdev/core';
connectWS('/feed', { onMessage: (m) => renderStream(m) });

connectedCallback() {
super.connectedCallback();
this.conn = connectWS('/feed', {
onOpen: () => (this.online = true),
onClose: () => (this.online = false),
onMessage: (m) => renderStream(m), // apply a server-pushed <webjs-stream> payload
});
}
disconnectedCallback() { super.disconnectedCallback(); this.conn?.close(); }
send(text) { this.conn.send(text); }
```

**Gotcha: a component re-render clobbers surgical `renderStream()` updates.** `renderStream()` (and `<webjs-stream>` in general) mutates the DOM out of band, appending rows the component's own `render()` does not know about. If the component then re-renders, `render()` re-runs and wipes those out-of-band rows. So render the target container ONCE and drive any mutation counter with a PLAIN instance field, never a signal or reactive prop that `render()` reads (a read would re-render and blow away the streamed-in DOM).

**Broadcast.** `broadcast(path, data)` from `@webjsdev/server` fans a message to every connected client on that path (single-instance). For multi-instance, add Redis pub/sub yourself, there is no framework magic.

## Navigation-Loading Indicator (opt-in)
Expand Down
Loading
Loading