audit-shells
Audit pracht shells: missing `Loading()` on SPA routes, `<html>`/`<head>`/ `<body>` rendered inside a shell, shells that never render `children`, unused shells, and `ErrorBoundary` exports shadowed by every route under them. Use for "audit shells", "check shell composition", "fin
Install
npx skills add https://github.com/JoviDeCroock/pracht/tree/main/skills/audit-shells
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install jovidecroock-pracht@llmmart
git clone https://github.com/JoviDeCroock/pracht.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole jovidecroock/pracht collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Pracht Audit Shells
Shells in pracht are named layout components composed around routes. The
framework owns the document — shells must not render <html>, <head>, or
<body>. They sit between the framework's HTML scaffold and the route
component.
Step 1: Enumerate
MCP: when the pracht MCP server is registered (pracht dev-mcp), prefer its
inspect_routes/inspect_api/inspect_build/doctor/verify tools over
shelling out.
pracht inspect routes --json
Prerequisite: pracht inspect needs a vite config with the pracht plugin
wired up; run it from the app root.
For every shell used by the app, read its source file. Also list which routes
use which shell (the JSON output includes resolved shell and shellFile per
route). The JSON's top-level mode field tells you which router the app uses
— see Step 3 for how that changes shell discovery.
Step 2: Per-shell checks
For each shell file:
2a. Document-level tag misuse
Grep for <html, <head>, <body>, <meta, <title>, <link rel. Any of
these inside a shell is a bug — the framework injects them based on head()
exports. Recommend moving meta/title to the shell's head() export and
removing the JSX tags.
2b. Shell export shape
- Must be a function component named
Shell. - Must accept
{ children }: ShellProps. - Must render
{children}somewhere — flag shells that never renderchildren(hard to spot, blank page everywhere).
2c. Loading() for SPA routes
If any route assigned to this shell has render: "spa", the shell SHOULD
export a Loading() function that renders a placeholder during the
client-only data fetch. Without it, users see blank content during navigation.
2d. head() export
- Optional, but recommended if the shell sets shared meta tags.
- Verify return shape matches
{ title?, lang?, meta?, link?, script? }. - Flag shells whose
head()returnsundefinedunconditionally — delete the export.
2e. ErrorBoundary export
ErrorBoundary is a valid export on both shells and routes (see
ShellModule and RouteModule in packages/framework/src/types.ts). The
runtime resolves routeMod.ErrorBoundary ?? shellModule.ErrorBoundary — a
shell-level boundary is the fallback for every route under that shell when
the route doesn't declare its own.
- A shell exporting
ErrorBoundaryis fine — often the right place for a shared error fallback. - If both the shell and a route declare one, the route wins. Flag as
infoat most, and only if the shell boundary is thereby unreachable for every route under it (dead code).
2f. headers() export
Shells may export headers() to contribute response headers (merged with
route headers()). Optional, but if present:
- Verify the return shape is a plain
HeadersInit. - Flag shells whose
headers()returnsundefinedunconditionally — delete the export.
2g. loader export
Shells may export loader(args) for layout-level data, read with
useShellData() in the shell and its routes. If present:
- Run the audit-loaders checks on it: its return value is serialized into
every page under the shell (
shellDatain the hydration state and the route-state JSON). useShellData()returnsundefinedwhile the shell renders without its data (SPA loading state, an error boundary after the shell loader failed). Flag a shell that dereferences it unguarded.- Flag a shell loader used as an auth gate (redirecting anonymous users) with no gating middleware on its routes: navigations inside the shell ask the server to skip it. Gate in middleware.
- Flag several routes under one shell whose loaders each fetch the same layout-level value (the current user): that belongs in the shell loader.
Step 3: Coverage and waste
Shell registration is mode-aware — check the mode field from the Step 1
JSON first:
- Manifest apps (
mode: "manifest"): shells are registered indefineApp({ shells }). Diff that registry against the per-route resolvedshell/shellFilevalues from the inspect JSON — the JSON has no shell registry of its own, so "unused" means "registered indefineAppbut referenced by no route or group". - Pages apps (
mode: "pages"): there is nodefineAppshell registry. Each_appfile is auto-registered —src/pages/_app.tsxas"pages",src/pages/blog/_app.tsxas"pages:blog"— and owns the routes in its directory subtree (see https://pracht.resynapse.dev/docs/shells). Shells replace, they do not nest: the nearest_appabove a route is the only one that renders it, so a directory shell must carry its ownhead()/headers()rather than inherit the parent's. "Unused shells" analysis does not apply; instead checkpracht inspect routes --jsonand confirm each route'sshellis the nearest_appyou expect, and that no directory you meant to scope is silently falling back to"pages". An_appinside an underscore-reserved tree (src/pages/_components/_app.tsx) is a plain helper, and two_appfiles in one directory are a build,doctor, andverifyerror.
Then report:
- Unused shells (manifest apps only): registered but referenced by no route or group. Recommend removal.
- Single-use shells: shells used by exactly one route — sometimes a
signal the layout should be inlined. Flag as
info. - Routes without shells: routes resolved to no shell. Usually intentional for raw HTML responses, but worth listing.
Step 4: Report
| Shell | File | Used by | Issue | Severity |
|---|
Severities: error (document tags, missing children), warn (no Loading
on SPA routes, empty headers()), info (unused, single-use, shell
ErrorBoundary shadowed by route-level boundaries on every route).
Rules
- Source of truth is
pracht inspect routes --json— it shows resolved shell-per-route after group inheritance. - Read the shell source — do not infer from names.
Loading()is a shell export (SPA-only fallback).ErrorBoundaryis valid on both shells and routes: the shell's boundary is the fallback, the route's wins when both exist. Do not flag shell boundaries as bugs.- Recommend deletions for unused shells; do not delete automatically.
- When in doubt about render mode interaction, cross-reference with
tune-render-mode.
$ARGUMENTS
Files (pracht)
-
SKILL.md 6.7 KB
--- name: audit-shells version: 1.4.0 description: | Audit pracht shells: missing `Loading()` on SPA routes, `<html>`/`<head>`/ `<body>` rendered inside a shell, shells that never render `children`, unused shells, and `ErrorBoundary` exports shadowed by every route under them. Use for "audit shells", "check shell composition", "find unused shells", "is my layout structured correctly". allowed-tools: - Bash - Read - Grep - Glob --- # Pracht Audit Shells Shells in pracht are named layout components composed around routes. The framework owns the document — shells must not render `<html>`, `<head>`, or `<body>`. They sit between the framework's HTML scaffold and the route component. ## Step 1: Enumerate MCP: when the pracht MCP server is registered (`pracht dev-mcp`), prefer its `inspect_routes`/`inspect_api`/`inspect_build`/`doctor`/`verify` tools over shelling out. ```bash pracht inspect routes --json ``` Prerequisite: `pracht inspect` needs a vite config with the pracht plugin wired up; run it from the app root. For every shell used by the app, read its source file. Also list which routes use which shell (the JSON output includes resolved `shell` and `shellFile` per route). The JSON's top-level `mode` field tells you which router the app uses — see Step 3 for how that changes shell discovery. ## Step 2: Per-shell checks For each shell file: ### 2a. Document-level tag misuse Grep for `<html`, `<head>`, `<body>`, `<meta`, `<title>`, `<link rel`. Any of these inside a shell is a bug — the framework injects them based on `head()` exports. Recommend moving meta/title to the shell's `head()` export and removing the JSX tags. ### 2b. `Shell` export shape - Must be a function component named `Shell`. - Must accept `{ children }: ShellProps`. - Must render `{children}` somewhere — flag shells that never render `children` (hard to spot, blank page everywhere). ### 2c. `Loading()` for SPA routes If any route assigned to this shell has `render: "spa"`, the shell SHOULD export a `Loading()` function that renders a placeholder during the client-only data fetch. Without it, users see blank content during navigation. ### 2d. `head()` export - Optional, but recommended if the shell sets shared meta tags. - Verify return shape matches `{ title?, lang?, meta?, link?, script? }`. - Flag shells whose `head()` returns `undefined` unconditionally — delete the export. ### 2e. `ErrorBoundary` export `ErrorBoundary` is a valid export on **both** shells and routes (see `ShellModule` and `RouteModule` in `packages/framework/src/types.ts`). The runtime resolves `routeMod.ErrorBoundary ?? shellModule.ErrorBoundary` — a shell-level boundary is the fallback for every route under that shell when the route doesn't declare its own. - A shell exporting `ErrorBoundary` is fine — often the right place for a shared error fallback. - If **both** the shell and a route declare one, the route wins. Flag as `info` at most, and only if the shell boundary is thereby unreachable for every route under it (dead code). ### 2f. `headers()` export Shells may export `headers()` to contribute response headers (merged with route `headers()`). Optional, but if present: - Verify the return shape is a plain `HeadersInit`. - Flag shells whose `headers()` returns `undefined` unconditionally — delete the export. ### 2g. `loader` export Shells may export `loader(args)` for layout-level data, read with `useShellData()` in the shell and its routes. If present: - Run the audit-loaders checks on it: its return value is serialized into every page under the shell (`shellData` in the hydration state and the route-state JSON). - `useShellData()` returns `undefined` while the shell renders without its data (SPA loading state, an error boundary after the shell loader failed). Flag a shell that dereferences it unguarded. - Flag a shell loader used as an auth gate (redirecting anonymous users) with no gating middleware on its routes: navigations inside the shell ask the server to skip it. Gate in middleware. - Flag several routes under one shell whose loaders each fetch the same layout-level value (the current user): that belongs in the shell loader. ## Step 3: Coverage and waste Shell registration is mode-aware — check the `mode` field from the Step 1 JSON first: - **Manifest apps** (`mode: "manifest"`): shells are registered in `defineApp({ shells })`. Diff that registry against the per-route resolved `shell`/`shellFile` values from the inspect JSON — the JSON has no shell registry of its own, so "unused" means "registered in `defineApp` but referenced by no route or group". - **Pages apps** (`mode: "pages"`): there is no `defineApp` shell registry. Each `_app` file is auto-registered — `src/pages/_app.tsx` as `"pages"`, `src/pages/blog/_app.tsx` as `"pages:blog"` — and owns the routes in its directory subtree (see <https://pracht.resynapse.dev/docs/shells>). Shells **replace**, they do not nest: the nearest `_app` above a route is the only one that renders it, so a directory shell must carry its own `head()`/`headers()` rather than inherit the parent's. "Unused shells" analysis does not apply; instead check `pracht inspect routes --json` and confirm each route's `shell` is the nearest `_app` you expect, and that no directory you meant to scope is silently falling back to `"pages"`. An `_app` inside an underscore-reserved tree (`src/pages/_components/_app.tsx`) is a plain helper, and two `_app` files in one directory are a build, `doctor`, and `verify` error. Then report: - **Unused shells** (manifest apps only): registered but referenced by no route or group. Recommend removal. - **Single-use shells**: shells used by exactly one route — sometimes a signal the layout should be inlined. Flag as `info`. - **Routes without shells**: routes resolved to no shell. Usually intentional for raw HTML responses, but worth listing. ## Step 4: Report | Shell | File | Used by | Issue | Severity | | ----- | ---- | ------- | ----- | -------- | Severities: `error` (document tags, missing children), `warn` (no `Loading` on SPA routes, empty `headers()`), `info` (unused, single-use, shell `ErrorBoundary` shadowed by route-level boundaries on every route). ## Rules 1. Source of truth is `pracht inspect routes --json` — it shows resolved shell-per-route after group inheritance. 2. Read the shell source — do not infer from names. 3. `Loading()` is a shell export (SPA-only fallback). `ErrorBoundary` is valid on both shells and routes: the shell's boundary is the fallback, the route's wins when both exist. Do not flag shell boundaries as bugs. 4. Recommend deletions for unused shells; do not delete automatically. 5. When in doubt about render mode interaction, cross-reference with `tune-render-mode`. $ARGUMENTS
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.