From 831a55e022a940bb7e7c45d90ee5dc995cb57bb9 Mon Sep 17 00:00:00 2001 From: Vivek Date: Fri, 31 Jul 2026 14:43:05 +0530 Subject: [PATCH 1/9] docs: teach app agents to derive types, never unknown or any WebJs sells full-stack type safety, but the agent-facing guide never stated it as a rule and said nothing about unknown, the escape hatch an agent reaches for because it looks safe. State the ladder (schema row types, a named action input, ActionResult, PageProps / LayoutProps / RouteHandlerContext, the generated Route union, prop) and the one place unknown is correct, a payload narrowed on the next line. --- .agents/skills/webjs/SKILL.md | 5 +- .../webjs/references/data-and-actions.md | 18 +++-- .../skills/webjs/references/optimistic-ui.md | 9 +-- .../webjs/references/routing-and-pages.md | 3 +- .agents/skills/webjs/references/typescript.md | 65 ++++++++++++++++++- AGENTS.md | 15 ++++- packages/cli/templates/AGENTS.md | 31 +++++++-- 7 files changed, 126 insertions(+), 20 deletions(-) diff --git a/.agents/skills/webjs/SKILL.md b/.agents/skills/webjs/SKILL.md index a912c6560..27c1b1dbf 100644 --- a/.agents/skills/webjs/SKILL.md +++ b/.agents/skills/webjs/SKILL.md @@ -45,7 +45,7 @@ Classify the task first, then load the smallest useful reference set. Each refer | 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` | -| TypeScript at runtime, erasable syntax, full-stack types | `references/typescript.md` | +| TypeScript at runtime, erasable syntax, full-stack types, the derive-the-type rule (never `unknown` / `any`) | `references/typescript.md` | | Unit, browser, e2e tests, the `handle()` harness, Bun parity | `references/testing.md` | | Auth, caching, env vars, rate limit, file storage, the `webjs` config block | `references/built-ins.md` | | Node vs Bun, running the app, deploying, runtime-specific differences | `references/runtime.md` | @@ -68,7 +68,8 @@ Common bundles: 5. **Add interactivity per behaviour.** Reach for a component (and a signal or `@event`) only where the UI is genuinely interactive. A display-only component is elided from the browser. 6. **Validate input at the boundary.** Declare `export const validate` on an action; the RPC and `route()` boundaries run it. 7. **Default mutations to optimistic UI** where the client can predict the result (`optimistic()` from `@webjsdev/core`). -8. **Test the narrowest meaningful layer**, and render the app in a real browser for any UI change (static checks do not catch a collapsed layout). +8. **Type every boundary from its source, never `unknown` or `any`.** The row type comes from the schema (`typeof todos.$inferSelect`), the action's input from a named `interface` and its result from `ActionResult`, the routing files from `PageProps` / `LayoutProps` / `RouteHandlerContext`. `unknown` belongs only on a payload nothing has vouched for yet, narrowed on the next line. See `references/typescript.md`. +9. **Test the narrowest meaningful layer**, and render the app in a real browser for any UI change (static checks do not catch a collapsed layout). ## Project Layout diff --git a/.agents/skills/webjs/references/data-and-actions.md b/.agents/skills/webjs/references/data-and-actions.md index 81cf1d6c7..fa3390396 100644 --- a/.agents/skills/webjs/references/data-and-actions.md +++ b/.agents/skills/webjs/references/data-and-actions.md @@ -105,15 +105,23 @@ Declare `export const validate` beside the action. It runs SERVER-SIDE before th ```ts // modules/posts/actions/create-post.server.ts 'use server'; -export const validate = (input: any) => { +export interface CreatePostInput { title: string; body: string } + +// `unknown` is CORRECT here and nowhere else in this file: the validator IS +// the narrowing site for an untrusted wire payload. Never `any`, which would +// un-type the returned `data` and with it the action's own input. +export const validate = (input: unknown) => { + const raw = (input ?? {}) as Record; const fieldErrors: Record = {}; - const title = String(input?.title || '').trim(); + const title = String(raw.title ?? '').trim(); if (!title) fieldErrors.title = 'Title is required'; - if (String(input?.body || '').length < 10) fieldErrors.body = 'Too short'; + if (String(raw.body ?? '').length < 10) fieldErrors.body = 'Too short'; if (Object.keys(fieldErrors).length) return { success: false, fieldErrors }; - return { success: true, data: { title, body: String(input.body) } }; + // `satisfies` ties the validator's output to the action's input, so the two + // cannot drift apart. + return { success: true, data: { title, body: String(raw.body) } satisfies CreatePostInput }; }; -export async function createPost(input: { title: string; body: string }) { /* runs only when valid */ } +export async function createPost(input: CreatePostInput) { /* runs only when valid */ } ``` A client call resolves with the failure envelope (it does NOT throw), so the component reads `result.fieldErrors`. A zod adapter wraps `safeParse` so its result becomes the envelope; the framework stays zod-free. diff --git a/.agents/skills/webjs/references/optimistic-ui.md b/.agents/skills/webjs/references/optimistic-ui.md index 9177fba70..85e14bb61 100644 --- a/.agents/skills/webjs/references/optimistic-ui.md +++ b/.agents/skills/webjs/references/optimistic-ui.md @@ -28,10 +28,11 @@ class TodoList extends WebComponent({ source: () => this.todos, update: (state, title: string) => [ ...state, - // A client-only placeholder id for the pending row; the real id arrives - // from the server on reconcile, so the `as any` cast on this temp row is - // fine (the row is dropped when the promise settles). - { id: crypto.randomUUID() as any, title, completed: false, pending: true }, + // A client-only placeholder id for the pending row. The real id arrives + // from the server on reconcile, when this row is dropped. No cast: the + // row type carries the client-only flag (`pending?: boolean`), so the + // temp row type-checks as a plain `Todo`. + { id: crypto.randomUUID(), title, completed: false, pending: true }, ], }); diff --git a/.agents/skills/webjs/references/routing-and-pages.md b/.agents/skills/webjs/references/routing-and-pages.md index 31da64858..6781bc82c 100644 --- a/.agents/skills/webjs/references/routing-and-pages.md +++ b/.agents/skills/webjs/references/routing-and-pages.md @@ -41,7 +41,8 @@ The default export receives `{ children, params, searchParams, url }` and must e ```ts // app/layout.ts (root) import { html } from '@webjsdev/core'; -export default function RootLayout({ children }: { children: unknown }) { +import type { LayoutProps } from '@webjsdev/core'; +export default function RootLayout({ children }: LayoutProps) { return html`${children}`; } ``` diff --git a/.agents/skills/webjs/references/typescript.md b/.agents/skills/webjs/references/typescript.md index a5a110827..f1613d20f 100644 --- a/.agents/skills/webjs/references/typescript.md +++ b/.agents/skills/webjs/references/typescript.md @@ -5,7 +5,7 @@ - TypeScript at runtime with **no build step**: `.ts` / `.mts` is stripped in place, not compiled. - **Erasable syntax only** (`erasableSyntaxOnly: true`) and the exact list of banned constructs, with their allowed rewrites. - The **pluggable stripper** (Node 24+ built-in vs `amaro` on Bun) and how the browser gets stripped source. -- **Full-stack type safety**: server-action types flow to the call site, `import type` crosses the `.server` boundary, plus the carrier rule. +- **Full-stack type safety**: the rule (derive the type at every boundary, never `unknown` / `any`), server-action types flowing to the call site, and the `import type` carrier rule across the `.server` boundary. - **Typing pages, layouts, and route handlers** with `PageProps` / `LayoutProps` / `RouteHandlerContext` and the generated route union (`webjs types`). Read this when you are writing `.ts` in a WebJs app, hit a strip-time 500, or want typed params and hrefs. For action signatures and the serializer wire see `data-and-actions.md`. For typing reactive props see `components.md`. @@ -77,6 +77,69 @@ Prefer explicit `.ts` extensions in imports. A `.js` specifier pointing at a `.t ## Full-stack type safety +### The rule: derive the type, never `unknown` or `any` + +Every value crossing an app boundary in WebJs already has a type you can reach, so reach for it. Writing `unknown` or `any` at a boundary throws away the framework's central guarantee, and it does so silently: the app still runs, the checker just stops helping. Nothing catches this for you. Both are valid TypeScript, so `webjs check` will not flag them (it is a correctness tool, and this is a convention), and `tsc --noEmit` passes happily. + +`unknown` is the more dangerous of the two, because it reads as the safe choice. It is safe only in the sense that it forces a narrow at the read site. As a boundary type it is exactly as uninformative as `any`, and it pushes a cast into every consumer. + +The ladder, in the order to climb it: + +| Boundary | Write this | Not this | +|---|---|---| +| A database row | `export type Todo = typeof todos.$inferSelect` in `db/schema.server.ts` (`$inferInsert` for a write) | a hand-written interface that drifts from the schema | +| That row inside a shipping component | `import type { Todo } from '#db/schema.server.ts'` | `any[]`, or re-declaring the shape by hand | +| A server action's input | a named `interface CreatePostInput` | `input: unknown` / `input: any` | +| A server action's result | `Promise>` | `Promise` | +| `export const validate` | `(input: unknown)`, narrowed in the body, returning `data` that `satisfies` the action's input type | `(input: any)`, which un-types the returned `data` too | +| A page | `PageProps<'/blog/[slug]'>` | `{ params: Record }` | +| A layout | `LayoutProps` (whose `children` is a `TemplateResult`) | `{ children: unknown }` | +| A route handler's 2nd argument | `RouteHandlerContext<'/api/users/[id]'>` | `{ params: any }` | +| A client-router href | the generated `Route` union (`npx webjsdev types`) | a bare `string` | +| A reactive property | `prop(Object)`, `prop(Array)` | `prop(Object)` plus a cast at every read | +| An optimistic temp row | the pending shape on the row type (`pending?: boolean`) | `as any` on the temp id | + +One flow, end to end, with no escape hatch anywhere in it: + +```ts +// db/schema.server.ts +export const posts = table('posts', { id: uuidPk(), title: text().notNull(), body: text().notNull() }); +export type Post = typeof posts.$inferSelect; // derived, never hand-written + +// modules/posts/actions/create-post.server.ts +'use server'; +import type { Post } from '#db/schema.server.ts'; // type-only: erased before the browser sees it +export interface CreatePostInput { title: string; body: string } +export const validate = (input: unknown) => { /* narrows, returns data satisfying CreatePostInput */ }; +export async function createPost(input: CreatePostInput): Promise> { /* ... */ } + +// modules/posts/components/new-post.ts +import { createPost } from '#modules/posts/actions/create-post.server.ts'; +const r = await createPost({ title, body }); +if (r.success && r.data) r.data.title; // Post.title: string, checked at the call site +``` + +The payoff is not stylistic. A typo in `r.data.titel`, a renamed column, a changed action signature, and a page reading `params.slugg` are all compile errors in that version and all silent runtime `undefined` in the `unknown` version. + +### Where `unknown` is still the right type + +`unknown` is correct for a value that genuinely has no type yet, at the moment before it is narrowed: + +```ts +// app/api/webhook/route.ts +export async function POST(req: Request) { + const body: unknown = await req.json(); // correct: nothing has vouched for this yet + const parsed = parseWebhook(body); // narrowed on the very next line + return Response.json({ ok: parsed.kind }); +} +``` + +The test is what the next line does. Correct `unknown` is narrowed immediately by a parse, a validator, or a type guard, and the narrowed type is what the rest of the function sees. `unknown` that survives into a return type, a component prop, or an action signature is a missing type, not a safe one. A `catch (e)` binding is already `unknown` under `strict`, which is the same pattern. + +An action's `export const validate` is the other canonical spot: it runs on the RPC boundary against a payload nothing has vouched for yet, so `(input: unknown)` is right there, and returning `data` that `satisfies` the action's input type is what carries a real type into the action body. See `data-and-actions.md`. + +`any` gets no such carve-out in app code. It does not defer checking, it disables it, so a validator typed `(input: any)` un-types everything downstream of the call. + ### Server actions type-check automatically Calling a server action from a client component resolves at type-check time to the action's real source file. The runtime stub swap is invisible to the checker, and the RPC serializer makes runtime match the types (`Date` stays `Date`, `Map` stays `Map`, `BigInt` stays `BigInt`; see `data-and-actions.md` for the full supported set). diff --git a/AGENTS.md b/AGENTS.md index ba72e77e0..a74b0a33c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,7 +19,7 @@ to these `references/`: | `references/styling.md` | Tailwind, light-DOM tag-prefix rule, tokens, fixed headers, no-reflow layout | | `references/client-router-and-streaming.md` | Client router, prefetch, frames, view transitions, Suspense streaming | | `references/optimistic-ui.md` | Optimistic UI for a user-facing mutation | -| `references/typescript.md` | TypeScript at runtime, erasable syntax, full-stack types | +| `references/typescript.md` | TypeScript at runtime, erasable syntax, full-stack types, the derive-the-type rule (never `unknown` / `any`) | | `references/testing.md` | Unit, browser, e2e tests, the `handle()` harness, Bun parity | | `references/built-ins.md` | Auth, caching, env vars, rate limit, file storage, the `webjs` config block | | `references/runtime.md` | Node vs Bun, running the app, deploying, runtime-specific differences | @@ -338,6 +338,16 @@ type ActionResult = --- +## Types: derive them, never `unknown` or `any` + +**Full-stack type safety is the point of the `.server.{js,ts}` boundary, so an app that types its boundaries `unknown` or `any` gives up WebJs's central guarantee for nothing.** There is no build step and no code generation between the two sides: a client component importing a server action resolves to the action's real signature at type-check time, so the type is already there to be used. Nothing enforces this (both are valid TypeScript, so `tsc` and `webjs check` pass either way), which is exactly why it is written down. + +Derive the type at every boundary: a DB row from the schema (`typeof todos.$inferSelect`, carried into a shipping component with `import type`), an action's input from a named `interface` and its result from `ActionResult`, a page from `PageProps<'/blog/[slug]'>`, a layout from `LayoutProps`, a route handler's second argument from `RouteHandlerContext`, an href from the generated `Route` union (`npx webjsdev types`), a reactive property from `prop(Object)`. + +`unknown` is right in exactly one place: a payload nothing has vouched for yet, narrowed on the next line (a `route.ts` `await req.json()`, an action's `export const validate`, a `catch` binding). `unknown` that survives into a return type, a component prop, or an action signature is a missing type, not a safe one, and `any` has no carve-out at all. Full ladder, with the end-to-end example, in `references/typescript.md`. + +--- + ## 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`. @@ -369,7 +379,8 @@ class TodoList extends WebComponent({ source: () => this.todos, update: (state, title: string) => [ ...state, - { id: crypto.randomUUID() as any, title, completed: false, pending: true }, + // No cast: `Todo` carries the client-only `pending?: boolean` flag. + { id: crypto.randomUUID(), title, completed: false, pending: true }, ], }); diff --git a/packages/cli/templates/AGENTS.md b/packages/cli/templates/AGENTS.md index 8c932523a..e59f16b87 100644 --- a/packages/cli/templates/AGENTS.md +++ b/packages/cli/templates/AGENTS.md @@ -31,11 +31,32 @@ This is what separates a working app from a broken one. ## Type everything (all templates) -Define explicit TypeScript interfaces and discriminated unions for your data -payloads and action inputs and outputs (and, in a UI app, component props and -optimistic updates). Narrow an `ActionResult` with -`if (result.success && result.data)`. Never reach for `any` or a loose -`as any` cast. +Full-stack type safety is what the `.server.ts` boundary buys you: a client +component importing a server action resolves to that action's real signature at +type-check time, with no build step and no code generation in between. So +DERIVE the type at every boundary instead of widening it: + +- A database row: `export type Todo = typeof todos.$inferSelect` in + `db/schema.server.ts` (`$inferInsert` for a write), carried into a + browser-shipped component with `import type` (erased before it reaches the + browser, so it does not trip the server-import boundary). +- An action's input: a named `interface`. Its result: `ActionResult`. + Narrow with `if (result.success && result.data)`. +- Routing files: `PageProps<'/blog/[slug]'>`, `LayoutProps`, + `RouteHandlerContext`, all from `@webjsdev/core`. Run `npx webjsdev types` + for the typed `Route` union and per-route `params`. +- A reactive property: `prop(Object)`, `prop(Array)`. + +Never reach for `any` or a loose `as any` cast, and do not reach for `unknown` +either just because it looks safer. `unknown` is right in exactly one place: a +payload nothing has vouched for yet, narrowed on the very next line (a +`route.ts` `await req.json()`, an action's `export const validate`, a `catch` +binding). `unknown` that survives into a return type, a component prop, a +layout's `children`, or an action signature is a missing type, not a safe one. +Nothing enforces this (both are valid TypeScript, so `webjs check` and `tsc` +pass either way), which is exactly why it is written down. The full ladder, +with an end-to-end example, is in +`.agents/skills/webjs/references/typescript.md`. Keep server-only code (database drivers, secrets, `node:*` builtins) in `.server.ts` modules. There are exactly two kinds: From b717583dee6845999bd11cc0efe5d294f59a7907 Mon Sep 17 00:00:00 2001 From: Vivek Date: Fri, 31 Jul 2026 14:49:46 +0530 Subject: [PATCH 2/9] docs: stop modelling unknown and any in the examples agents copy An agent copies the example far more reliably than it follows the prose, so every place we taught the anti-pattern is fixed: the layout argument becomes LayoutProps (it was untyped in the generated root layout, which is the first file a new app opens), action signatures get a named input type, the two validate examples narrow a real unknown and return data that satisfies the action's input, and the optimistic temp row drops its as any cast. --- .agents/skills/webjs/references/typescript.md | 2 ++ blog/csrf-protection-without-tokens.md | 4 +-- packages/cli/lib/create.js | 7 +++++- .../templates/gallery/app/examples/layout.ts | 3 ++- .../app/features/auth/dashboard/layout.ts | 3 ++- .../templates/gallery/app/features/layout.ts | 8 ++++-- .../cli/templates/scripts/clear-gallery.mjs | 3 ++- .../templates/test/hello/e2e/hello.test.ts | 16 +++++++++++- website/app/docs/backend-only/page.ts | 11 +++++--- website/app/docs/getting-started/page.ts | 3 ++- website/app/docs/layout.ts | 3 ++- website/app/docs/routing/page.ts | 6 +++-- website/app/docs/server-actions/page.ts | 13 +++++----- website/app/docs/ssr/page.ts | 3 ++- website/app/docs/styling/page.ts | 6 +++-- website/app/docs/typescript/page.ts | 25 +++++++++++++++++-- website/app/layout.ts | 3 ++- website/app/ui/layout.ts | 3 ++- 18 files changed, 92 insertions(+), 30 deletions(-) diff --git a/.agents/skills/webjs/references/typescript.md b/.agents/skills/webjs/references/typescript.md index f1613d20f..d5a619be0 100644 --- a/.agents/skills/webjs/references/typescript.md +++ b/.agents/skills/webjs/references/typescript.md @@ -136,6 +136,8 @@ export async function POST(req: Request) { The test is what the next line does. Correct `unknown` is narrowed immediately by a parse, a validator, or a type guard, and the narrowed type is what the rest of the function sees. `unknown` that survives into a return type, a component prop, or an action signature is a missing type, not a safe one. A `catch (e)` binding is already `unknown` under `strict`, which is the same pattern. +A value destined for an `html` template hole is the third: a hole renders a string, a number, a `TemplateResult`, an array of those, a directive result, or nothing, so a helper like `lede(content: unknown)` is correctly typed and `TemplateResult` alone would be too narrow. Narrow it only when the helper genuinely accepts one shape (`backLink(href: string, ...)`). + An action's `export const validate` is the other canonical spot: it runs on the RPC boundary against a payload nothing has vouched for yet, so `(input: unknown)` is right there, and returning `data` that `satisfies` the action's input type is what carries a real type into the action body. See `data-and-actions.md`. `any` gets no such carve-out in app code. It does not defer checking, it disables it, so a validator typed `(input: any)` un-types everything downstream of the call. diff --git a/blog/csrf-protection-without-tokens.md b/blog/csrf-protection-without-tokens.md index 055de4ec8..50af332de 100644 --- a/blog/csrf-protection-without-tokens.md +++ b/blog/csrf-protection-without-tokens.md @@ -75,13 +75,13 @@ WebJs sends no `Set-Cookie` riding the SSR HTML for CSRF, because there is no to ```ts // app/layout.ts (root layout for a visitor-identical app) import { html } from '@webjsdev/core'; -import type { Metadata } from '@webjsdev/core'; +import type { Metadata, LayoutProps } from '@webjsdev/core'; export const metadata: Metadata = { cacheControl: 'public, max-age=300', }; -export default function RootLayout({ children }: { children: unknown }) { +export default function RootLayout({ children }: LayoutProps) { return html`
${children}
`; } ``` diff --git a/packages/cli/lib/create.js b/packages/cli/lib/create.js index 9ea28741d..98d0c6eb1 100644 --- a/packages/cli/lib/create.js +++ b/packages/cli/lib/create.js @@ -1149,6 +1149,7 @@ ${uiThemeRaw} await copyGallery(appDir); await writeFile(join(appDir, 'app', 'layout.ts'), `import { html, cspNonce, asset } from '@webjsdev/core'; +import type { LayoutProps } from '@webjsdev/core'; import '#components/theme-toggle.ts'; /** @@ -1167,7 +1168,11 @@ import '#components/theme-toggle.ts'; // lives at public/favicon.svg and serves at /public/favicon.svg. export const metadata = { icons: '/public/favicon.svg' }; -export default function RootLayout({ children }: { children: unknown }) { +// LayoutProps types every layout argument (children, params, searchParams, +// url) from the framework, so children is a TemplateResult rather than an +// untyped value. Derive types like this everywhere instead of widening to +// unknown; see .agents/skills/webjs/references/typescript.md. +export default function RootLayout({ children }: LayoutProps) { // Read the in-flight request's CSP nonce so the theme-detection inline script // passes strict CSP. Returns '' when no CSP nonce is set. const nonce = cspNonce(); diff --git a/packages/cli/templates/gallery/app/examples/layout.ts b/packages/cli/templates/gallery/app/examples/layout.ts index b14f294a3..39a4bfb78 100644 --- a/packages/cli/templates/gallery/app/examples/layout.ts +++ b/packages/cli/templates/gallery/app/examples/layout.ts @@ -1,10 +1,11 @@ import { html } from '@webjsdev/core'; +import type { LayoutProps } from '@webjsdev/core'; import { backLink } from '#lib/utils/ui.ts'; // Shared layout for every gallery example app under /examples/*. It adds the same // slim "back to the gallery" link the feature demos get, so an example is never a // dead end. A non-root layout, so it never writes the document shell. -export default function ExamplesLayout({ children }: { children: unknown }) { +export default function ExamplesLayout({ children }: LayoutProps) { // An example app has no sidebar, so center it in a focused reading column // (the root centers the whole page; this narrows the example within it). return html` diff --git a/packages/cli/templates/gallery/app/features/auth/dashboard/layout.ts b/packages/cli/templates/gallery/app/features/auth/dashboard/layout.ts index 47da8bbb9..132c9624d 100644 --- a/packages/cli/templates/gallery/app/features/auth/dashboard/layout.ts +++ b/packages/cli/templates/gallery/app/features/auth/dashboard/layout.ts @@ -1,4 +1,5 @@ import { html } from '@webjsdev/core'; +import type { LayoutProps } from '@webjsdev/core'; import { buttonClass } from '#components/ui/button.ts'; // Nested layout for the protected dashboard subtree. Logout is a plain @@ -7,7 +8,7 @@ import { buttonClass } from '#components/ui/button.ts'; // default). signOut is server-only (modules/auth/auth.server.ts), so we POST to // its route rather than import it into a browser-shipping page. After signout the // dashboard middleware bounces any later visit to login. -export default function DashboardLayout({ children }: { children: unknown }) { +export default function DashboardLayout({ children }: LayoutProps) { return html`