@warlock.js/web 5.0.0

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.
Files changed (182) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/LICENSE +21 -0
  3. package/README.md +125 -0
  4. package/esm/build/contribution.d.mts +55 -0
  5. package/esm/build/contribution.mjs +132 -0
  6. package/esm/build/contribution.mjs.map +1 -0
  7. package/esm/build/discover-pages.mjs +294 -0
  8. package/esm/build/discover-pages.mjs.map +1 -0
  9. package/esm/build/generate-client-registry.mjs +123 -0
  10. package/esm/build/generate-client-registry.mjs.map +1 -0
  11. package/esm/build/generate-pages-barrel.mjs +330 -0
  12. package/esm/build/generate-pages-barrel.mjs.map +1 -0
  13. package/esm/build/read-route-exports.mjs +212 -0
  14. package/esm/build/read-route-exports.mjs.map +1 -0
  15. package/esm/client/build-hydrated-tree.mjs +113 -0
  16. package/esm/client/build-hydrated-tree.mjs.map +1 -0
  17. package/esm/client/hydrate-page.mjs +73 -0
  18. package/esm/client/hydrate-page.mjs.map +1 -0
  19. package/esm/client/navigation/current-route.d.mts +91 -0
  20. package/esm/client/navigation/current-route.mjs +66 -0
  21. package/esm/client/navigation/current-route.mjs.map +1 -0
  22. package/esm/client/navigation/fetch-page-data.mjs +97 -0
  23. package/esm/client/navigation/fetch-page-data.mjs.map +1 -0
  24. package/esm/client/navigation/navigation-root.mjs +196 -0
  25. package/esm/client/navigation/navigation-root.mjs.map +1 -0
  26. package/esm/client/navigation/prefetch.mjs +110 -0
  27. package/esm/client/navigation/prefetch.mjs.map +1 -0
  28. package/esm/client/navigation/refresh.d.mts +40 -0
  29. package/esm/client/navigation/refresh.mjs +146 -0
  30. package/esm/client/navigation/refresh.mjs.map +1 -0
  31. package/esm/client/navigation/verbs.d.mts +55 -0
  32. package/esm/client/navigation/verbs.mjs +66 -0
  33. package/esm/client/navigation/verbs.mjs.map +1 -0
  34. package/esm/client/runtime/index.d.mts +4 -0
  35. package/esm/client/runtime/index.mjs +4 -0
  36. package/esm/client/runtime/manifest.d.mts +8 -0
  37. package/esm/client/runtime/manifest.mjs +77 -0
  38. package/esm/client/runtime/manifest.mjs.map +1 -0
  39. package/esm/client/runtime/matcher.d.mts +7 -0
  40. package/esm/client/runtime/matcher.mjs +169 -0
  41. package/esm/client/runtime/matcher.mjs.map +1 -0
  42. package/esm/client/runtime/types.d.mts +22 -0
  43. package/esm/components/default-app.mjs +32 -0
  44. package/esm/components/default-app.mjs.map +1 -0
  45. package/esm/components/document-context.d.mts +1 -0
  46. package/esm/components/document-context.mjs +24 -0
  47. package/esm/components/document-context.mjs.map +1 -0
  48. package/esm/components/head.d.mts +14 -0
  49. package/esm/components/head.mjs +63 -0
  50. package/esm/components/head.mjs.map +1 -0
  51. package/esm/components/link.d.mts +157 -0
  52. package/esm/components/link.mjs +194 -0
  53. package/esm/components/link.mjs.map +1 -0
  54. package/esm/components/scripts.d.mts +14 -0
  55. package/esm/components/scripts.mjs +21 -0
  56. package/esm/components/scripts.mjs.map +1 -0
  57. package/esm/connector/index.d.mts +6 -0
  58. package/esm/connector/index.mjs +7 -0
  59. package/esm/context.d.mts +107 -0
  60. package/esm/hydration/index.d.mts +1 -0
  61. package/esm/hydration/index.mjs +33 -0
  62. package/esm/hydration/index.mjs.map +1 -0
  63. package/esm/hydration-payload.d.mts +1 -0
  64. package/esm/hydration-payload.mjs +72 -0
  65. package/esm/hydration-payload.mjs.map +1 -0
  66. package/esm/index.d.mts +35 -0
  67. package/esm/index.mjs +12 -0
  68. package/esm/loaders.d.mts +47 -0
  69. package/esm/metadata.d.mts +54 -0
  70. package/esm/props.d.mts +42 -0
  71. package/esm/route.d.mts +30 -0
  72. package/esm/routing/compose-route-path.mjs +33 -0
  73. package/esm/routing/compose-route-path.mjs.map +1 -0
  74. package/esm/routing/data-request.mjs +52 -0
  75. package/esm/routing/data-request.mjs.map +1 -0
  76. package/esm/routing/layout-policy.mjs +78 -0
  77. package/esm/routing/layout-policy.mjs.map +1 -0
  78. package/esm/routing/navigator.mjs +21 -0
  79. package/esm/routing/navigator.mjs.map +1 -0
  80. package/esm/routing/query-string.d.mts +250 -0
  81. package/esm/routing/query-string.mjs +340 -0
  82. package/esm/routing/query-string.mjs.map +1 -0
  83. package/esm/routing/route-identity.mjs +68 -0
  84. package/esm/routing/route-identity.mjs.map +1 -0
  85. package/esm/routing/route-table.d.mts +34 -0
  86. package/esm/routing/route-table.mjs +182 -0
  87. package/esm/routing/route-table.mjs.map +1 -0
  88. package/esm/routing/router-events.d.mts +159 -0
  89. package/esm/routing/router-events.mjs +83 -0
  90. package/esm/routing/router-events.mjs.map +1 -0
  91. package/esm/server/buffered-response.mjs +115 -0
  92. package/esm/server/buffered-response.mjs.map +1 -0
  93. package/esm/server/build-hydration-payload.mjs +27 -0
  94. package/esm/server/build-hydration-payload.mjs.map +1 -0
  95. package/esm/server/client-asset-url-prefix.d.mts +22 -0
  96. package/esm/server/client-asset-url-prefix.mjs +23 -0
  97. package/esm/server/client-asset-url-prefix.mjs.map +1 -0
  98. package/esm/server/create-page-module-loader.mjs +49 -0
  99. package/esm/server/create-page-module-loader.mjs.map +1 -0
  100. package/esm/server/create-page-route-handler.mjs +107 -0
  101. package/esm/server/create-page-route-handler.mjs.map +1 -0
  102. package/esm/server/dev-server.mjs +167 -0
  103. package/esm/server/dev-server.mjs.map +1 -0
  104. package/esm/server/execute-page-request.d.mts +1 -0
  105. package/esm/server/execute-page-request.mjs +166 -0
  106. package/esm/server/execute-page-request.mjs.map +1 -0
  107. package/esm/server/execute-page-request.types.d.mts +1 -0
  108. package/esm/server/hydration-client-url.d.mts +37 -0
  109. package/esm/server/hydration-client-url.mjs +101 -0
  110. package/esm/server/hydration-client-url.mjs.map +1 -0
  111. package/esm/server/index.mjs +13 -0
  112. package/esm/server/install-page-routes-from-manifest.mjs +133 -0
  113. package/esm/server/install-page-routes-from-manifest.mjs.map +1 -0
  114. package/esm/server/install-page-routes.d.mts +1 -0
  115. package/esm/server/install-page-routes.mjs +6 -0
  116. package/esm/server/install-production-page-routes.mjs +55 -0
  117. package/esm/server/install-production-page-routes.mjs.map +1 -0
  118. package/esm/server/match-page-route.mjs +48 -0
  119. package/esm/server/match-page-route.mjs.map +1 -0
  120. package/esm/server/page-context.d.mts +1 -0
  121. package/esm/server/page-context.mjs +33 -0
  122. package/esm/server/page-context.mjs.map +1 -0
  123. package/esm/server/page-manifest.d.mts +102 -0
  124. package/esm/server/page-manifest.mjs +31 -0
  125. package/esm/server/page-manifest.mjs.map +1 -0
  126. package/esm/server/render-page.mjs +214 -0
  127. package/esm/server/render-page.mjs.map +1 -0
  128. package/esm/server/resolve-page-metadata.mjs +33 -0
  129. package/esm/server/resolve-page-metadata.mjs.map +1 -0
  130. package/esm/server/resolve-validation-data.mjs +27 -0
  131. package/esm/server/resolve-validation-data.mjs.map +1 -0
  132. package/esm/server/settle-page-response.d.mts +1 -0
  133. package/esm/server/settle-page-response.mjs +90 -0
  134. package/esm/server/settle-page-response.mjs.map +1 -0
  135. package/esm/server/stylesheet-urls.mjs +65 -0
  136. package/esm/server/stylesheet-urls.mjs.map +1 -0
  137. package/esm/server/web-connector-factory.d.mts +25 -0
  138. package/esm/server/web-connector-factory.mjs +121 -0
  139. package/esm/server/web-connector-factory.mjs.map +1 -0
  140. package/esm/server/web-connector.d.mts +34 -0
  141. package/esm/server/web-connector.mjs +516 -0
  142. package/esm/server/web-connector.mjs.map +1 -0
  143. package/esm/shared.d.mts +22 -0
  144. package/esm/shared.mjs +223 -0
  145. package/esm/shared.mjs.map +1 -0
  146. package/esm/validation.d.mts +31 -0
  147. package/esm/vite/app-convention-aliases.mjs +43 -0
  148. package/esm/vite/app-convention-aliases.mjs.map +1 -0
  149. package/esm/vite/build-client.d.mts +38 -0
  150. package/esm/vite/build-client.mjs +72 -0
  151. package/esm/vite/build-client.mjs.map +1 -0
  152. package/esm/vite/gate-a-resolve.d.mts +57 -0
  153. package/esm/vite/gate-a-resolve.mjs +834 -0
  154. package/esm/vite/gate-a-resolve.mjs.map +1 -0
  155. package/esm/vite/gate-b-secrets.d.mts +57 -0
  156. package/esm/vite/gate-b-secrets.mjs +299 -0
  157. package/esm/vite/gate-b-secrets.mjs.map +1 -0
  158. package/esm/vite/gate-c-verify.d.mts +118 -0
  159. package/esm/vite/gate-c-verify.mjs +263 -0
  160. package/esm/vite/gate-c-verify.mjs.map +1 -0
  161. package/esm/vite/hydration-entries.d.mts +17 -0
  162. package/esm/vite/hydration-entries.mjs +45 -0
  163. package/esm/vite/hydration-entries.mjs.map +1 -0
  164. package/esm/vite/index.d.mts +126 -0
  165. package/esm/vite/index.mjs +146 -0
  166. package/esm/vite/index.mjs.map +1 -0
  167. package/esm/vite/page-registry-plugin.d.mts +43 -0
  168. package/esm/vite/page-registry-plugin.mjs +138 -0
  169. package/esm/vite/page-registry-plugin.mjs.map +1 -0
  170. package/esm/vite/projection.d.mts +31 -0
  171. package/esm/vite/projection.mjs +384 -0
  172. package/esm/vite/projection.mjs.map +1 -0
  173. package/llms-full.txt +1041 -0
  174. package/llms.txt +21 -0
  175. package/package.json +77 -0
  176. package/skills/add-web-to-an-app/SKILL.md +103 -0
  177. package/skills/create-a-page/SKILL.md +141 -0
  178. package/skills/load-page-data/SKILL.md +183 -0
  179. package/skills/navigate-on-the-client/SKILL.md +182 -0
  180. package/skills/serve-styles/SKILL.md +145 -0
  181. package/skills/use-layouts/SKILL.md +121 -0
  182. package/skills/write-the-root/SKILL.md +131 -0
package/llms-full.txt ADDED
@@ -0,0 +1,1041 @@
1
+ # Warlock Web — full skills
2
+
3
+ > Package: `@warlock.js/web`
4
+
5
+ > SSR React pages served by the same Warlock HTTP server that serves the API — a backend framework that renders React, not a React framework.
6
+
7
+ > First published release: 5.0.0. `@warlock.js/web` had never been published to npm before this release.
8
+
9
+ > Consumer entry points: `@warlock.js/web`, `@warlock.js/web/client/runtime`, `@warlock.js/web/connector`, and `@warlock.js/web/vite`. The published `@warlock.js/web/hydration` subpath is a framework build input; application code never imports it.
10
+
11
+ > Deliberate non-goals: server actions are a v2 design decision rather than a missing v1 feature; page routes reject regex parameters, optional parameters, and multiple parameters in one segment.
12
+
13
+ > Generated artifact. Concatenates every SKILL.md and reference file under `@warlock.js/web/skills/`. Re-run `node scripts/generate-llms.mjs` after any change.
14
+
15
+ ## add-web-to-an-app `@warlock.js/web/add-web-to-an-app/SKILL.md`
16
+
17
+ ---
18
+ name: add-web-to-an-app
19
+ description: 'Install the SSR page layer with `warlock add web`: add React/Vite peers, scaffold `src/web/root.tsx` and `src/web/home.page.tsx`, register `webConnector()`, and safely relocate the stock top-level `GET "/"` JSON route to `/welcome`. Triggers: `warlock add web`, `webConnector`, `src/web/root.tsx`, `src/web/home.page.tsx`, `GET "/welcome"`; "add web to an app", "install Warlock web", "scaffold SSR", "homepage route collision". Skip: author a page — `@warlock.js/web/create-a-page/SKILL.md`; customize the document — `@warlock.js/web/write-the-root/SKILL.md`; dev/build/start commands — `@warlock.js/core/run-app/SKILL.md`; competing installers `create-next-app`, `vite create`, `remix init`.'
20
+ ---
21
+
22
+ # Warlock — add web to an app
23
+
24
+ `warlock add web` turns an existing Warlock API application into an application that can also serve SSR React pages. It installs the web and React packages, adds the Vite development peers, scaffolds the smallest page layer, and registers the late-phase web connector.
25
+
26
+ ## The shape
27
+
28
+ ```bash
29
+ yarn warlock add web
30
+ ```
31
+
32
+ On the ordinary path it leaves this application-owned shape:
33
+
34
+ ```text
35
+ src/
36
+ web/
37
+ root.tsx
38
+ home.page.tsx
39
+ warlock.config.ts
40
+ ```
41
+
42
+ The generated home page declares `route = "/"`. The generated root owns the document, renders `<Head />`, keeps `{children}` inside `#root`, and renders `<Scripts />`.
43
+
44
+ ## Connector registration
45
+
46
+ The command adds `webConnector()` to `warlock.config.ts`. If the config cannot be patched safely, add the same wiring yourself:
47
+
48
+ ```ts title="warlock.config.ts"
49
+ import { defineConfig } from "@warlock.js/core";
50
+ import { webConnector } from "@warlock.js/web/connector";
51
+
52
+ export default defineConfig({
53
+ connectors: [webConnector()],
54
+ });
55
+ ```
56
+
57
+ Keep the connector in `warlock.config.ts`. The build reads this same array to collect web's build contribution, while dev/start use it to boot the runtime. Registering it again from `src/app/main.ts` boots it twice and duplicates page routes.
58
+
59
+ ## What happens to an existing `GET "/"`
60
+
61
+ The stock project template already owns `/` with a JSON welcome route. The page stub also owns `/`, and two `GET` handlers for one path fail at request time. The installer preserves both surfaces by changing the existing JSON route to `/welcome` before it writes the page.
62
+
63
+ The automatic move is intentionally narrow. It only inspects:
64
+
65
+ ```text
66
+ src/app/shared/routes.ts
67
+ ```
68
+
69
+ and only rewrites exactly one top-level declaration shaped like this:
70
+
71
+ ```ts
72
+ router.get("/", homePageController);
73
+ ```
74
+
75
+ After the move:
76
+
77
+ ```ts
78
+ router.get("/welcome", homePageController);
79
+ ```
80
+
81
+ Indented `router.get("/", ...)` declarations inside a prefixed group are not root collisions and are not touched.
82
+
83
+ ## Collision outcomes
84
+
85
+ - No `src/app/shared/routes.ts`, or no top-level `GET "/"`: create the root page normally.
86
+ - Exactly one recognized top-level `GET "/"` and no top-level `GET "/welcome"`: move it to `/welcome`, then create the home page.
87
+ - More than one top-level `GET "/"`, an existing top-level `GET "/welcome"`, an unreadable/unwritable routes file, or an unrecognized rewrite: create `root.tsx`, do not create `home.page.tsx`, set a failing exit code, and still register the connector.
88
+
89
+ On the refusal path, free `/` yourself and create a page with either `route = "/"` or another literal route.
90
+
91
+ ## Re-running the command
92
+
93
+ `src/web/root.tsx` is the scaffold sentinel. If it already exists, the command skips the `src/web` scaffold rather than overwriting a human-owned root. Connector registration is independently idempotent: if the config already mentions `webConnector`, it is left alone.
94
+
95
+ ## Verify the result
96
+
97
+ Read the files rather than assuming the command could patch every application shape:
98
+
99
+ ```bash
100
+ yarn warlock routes --method GET --path /
101
+ yarn warlock routes --method GET --path /welcome
102
+ ```
103
+
104
+ The desired result is one page route at `/` and, when the stock JSON route existed, one API route at `/welcome`.
105
+
106
+ ## Gotchas
107
+
108
+ - **The collision scan is not codebase-wide.** A root route declared outside `src/app/shared/routes.ts` is not moved. Check `warlock routes` after installation.
109
+ - **`/welcome` must be free before relocation.** The installer refuses to trade one duplicate route for another.
110
+ - **A partial scaffold exits non-zero.** `root.tsx` and connector registration may still have been written; complete only the missing home page after resolving the collision.
111
+ - **Do not register the connector twice.** Use `warlock.config.ts`, not an additional `connectorsManager.register(...)` call.
112
+ - **Do not import the hydration entry.** The connector and build contribution own it.
113
+
114
+ ## See also
115
+
116
+ - [`create-a-page/SKILL.md`](../create-a-page/SKILL.md) — write the page after installation.
117
+ - [`write-the-root/SKILL.md`](../write-the-root/SKILL.md) — customize the generated document shell.
118
+ - [`serve-styles/SKILL.md`](../serve-styles/SKILL.md) — add global CSS from the root.
119
+ - [`@warlock.js/core/run-app/SKILL.md`](@warlock.js/core/run-app/SKILL.md) — run dev, build, and production.
120
+
121
+ ## create-a-page `@warlock.js/web/create-a-page/SKILL.md`
122
+
123
+ ---
124
+ name: create-a-page
125
+ description: 'Create an SSR React page under `src/web/**` or `src/app/<module>/web/**` with a literal `route`, a default component, an optional typed `loader`, and page `metadata`. Triggers: `*.page.tsx`, `route`, `PageLoader`, `PageProps`, `PageMetadata`; "create a page", "add an SSR route", "make a React page", "type page loader data"; typical import `import type { PageLoader, PageProps } from "@warlock.js/web"`. Skip: root document shell — `@warlock.js/web/write-the-root/SKILL.md`; layout wrappers and prefixes — `@warlock.js/web/use-layouts/SKILL.md`; loader lifecycle and `shared` — `@warlock.js/web/load-page-data/SKILL.md`; competing frameworks `next`, `remix`, `react-router` file routes.'
126
+ ---
127
+
128
+ # Warlock — create a page
129
+
130
+ A page is any `*.page.tsx` beneath the global `src/web/` tree or a module's `src/app/<module>/web/` tree. Its URL is declared by `route`; its default export renders React.
131
+
132
+ ## The shape
133
+
134
+ ```tsx title="src/app/products/web/product-details.page.tsx"
135
+ import type { PageLoader, PageMetadata, PageProps } from "@warlock.js/web";
136
+
137
+ export const route = {
138
+ path: "/products/:id",
139
+ name: "products.details",
140
+ } as const;
141
+
142
+ export const loader = (async ({ request, response }) => {
143
+ const id = request.input("id");
144
+
145
+ response.header("cache-control", "private, max-age=60");
146
+
147
+ return {
148
+ product: {
149
+ id,
150
+ name: `Product ${id}`,
151
+ },
152
+ };
153
+ }) satisfies PageLoader<undefined, typeof route>;
154
+
155
+ export const metadata: PageMetadata<typeof loader> = ({ data }) => ({
156
+ title: data.product.name,
157
+ description: `Details for ${data.product.name}`,
158
+ });
159
+
160
+ export default function ProductDetailsPage({ data }: PageProps<typeof loader>) {
161
+ return (
162
+ <main>
163
+ <h1>{data.product.name}</h1>
164
+ <p>Product id: {data.product.id}</p>
165
+ </main>
166
+ );
167
+ }
168
+ ```
169
+
170
+ Use `satisfies PageLoader`, not `: PageLoader`. `satisfies` checks the context contract while retaining the loader's concrete return type, which is how `PageProps<typeof loader>` knows the shape of `data`.
171
+
172
+ ## The minimum page
173
+
174
+ ```tsx title="src/web/contact.page.tsx"
175
+ export const route = "/contact";
176
+
177
+ export default function ContactPage() {
178
+ return (
179
+ <main>
180
+ <h1>Contact</h1>
181
+ <a href="mailto:support@example.com">support@example.com</a>
182
+ </main>
183
+ );
184
+ }
185
+ ```
186
+
187
+ The `route` export is required. Development skips a page without it; the production discovery pass refuses to build it.
188
+
189
+ ## Route declarations
190
+
191
+ Use either a bare path or a literal object:
192
+
193
+ ```ts
194
+ export const route = "/products";
195
+ ```
196
+
197
+ ```ts
198
+ export const route = {
199
+ path: "/products/:id",
200
+ name: "products.details",
201
+ } as const;
202
+ ```
203
+
204
+ Prefer an explicit stable `name` for links. Without one, Warlock derives a name from the module and declared path: a module page gets `<module>.<path-as-dots>`, a global root page gets `index`, and another global page gets its dotted path.
205
+
206
+ The build reads `route` without executing application code. Declare it directly with `export const` and literal strings. Variables, function calls, computed object keys, spreads, and `export { route }` are refused.
207
+
208
+ ## Page-route grammar
209
+
210
+ Page routes deliberately accept less than API routes:
211
+
212
+ - Supported: `/`, static segments, whole-segment params such as `/products/:id`, the exact wildcard `*`, and a terminal wildcard such as `/docs/*`.
213
+ - Not supported: regex params, optional params, multiple params in one segment, params mixed with text, doubled or trailing slashes, and non-terminal wildcards.
214
+
215
+ Write two pages for an optional segment. Validate a constrained parameter in the page instead of putting a regex in its path.
216
+
217
+ ## Metadata
218
+
219
+ `metadata` may be a static object or a function of the resolved loader data and readonly `shared` payload:
220
+
221
+ ```tsx
222
+ import type { PageMetadata } from "@warlock.js/web";
223
+
224
+ export const metadata: PageMetadata = {
225
+ title: "Products",
226
+ description: "Browse the product catalogue",
227
+ robots: "index,follow",
228
+ openGraph: {
229
+ type: "website",
230
+ image: "/images/catalogue-card.png",
231
+ },
232
+ };
233
+ ```
234
+
235
+ Supported fields are `title`, `description`, `keywords`, `canonical`, `robots`, `openGraph`, and `twitter`. Function metadata runs after a successful loader. If a loader fails, Warlock uses error metadata instead of calling the page function with missing data.
236
+
237
+ ## The client boundary
238
+
239
+ The browser boundary is decided by the import graph, not by the file's location. A `*.page.tsx` is universal:
240
+
241
+ - `route`, `middleware`, `validation`, `loader`, and `metadata` are stripped from the client projection.
242
+ - The default component and any other surviving exports form the client graph.
243
+ - An import used only by a stripped server export is removed with it.
244
+ - An import also used by the component survives and therefore must be browser-safe.
245
+
246
+ Keep server-only repository and service reads inside `loader`. Do not read them from module-scope initializers or the default component.
247
+
248
+ ## Gotchas
249
+
250
+ - **Do not derive URLs from filenames.** The file chooses discovery; `route` chooses the public URL.
251
+ - **Keep `route` literal.** A computed route cannot be discovered without executing app code and is refused.
252
+ - **Do not annotate the loader with `: PageLoader`.** That erases the return type `PageProps` needs.
253
+ - **Components receive data, not HTTP objects.** `request` and `response` belong to loaders; the component also renders in the browser.
254
+ - **A default component is synchronous.** Fetch in the loader, then render its result.
255
+ - **There are no server actions.** Mutations remain ordinary API requests; call `refresh()` after a successful mutation.
256
+ - **Do not import the hydration entry.** `esm/hydration/index.mjs` is a framework build input, not a consumer API. The public low-level runtime subpath is `@warlock.js/web/client/runtime`.
257
+
258
+ ## See also
259
+
260
+ - [`load-page-data/SKILL.md`](../load-page-data/SKILL.md) — validation, loader context, response short-circuits, and `shared`.
261
+ - [`use-layouts/SKILL.md`](../use-layouts/SKILL.md) — positional layouts, prefixes, and persistence.
262
+ - [`navigate-on-the-client/SKILL.md`](../navigate-on-the-client/SKILL.md) — `Link`, `href`, navigation, and `refresh()`.
263
+ - [`serve-styles/SKILL.md`](../serve-styles/SKILL.md) — CSS imports in page and root modules.
264
+
265
+ ## load-page-data `@warlock.js/web/load-page-data/SKILL.md`
266
+
267
+ ---
268
+ name: load-page-data
269
+ description: 'Load App, Layout, and Page data with `AppLoader`, `LayoutLoader`, and `PageLoader`; type component `data`, validate page input, short-circuit with the buffered response, and publish request-scoped browser-safe values through `shared`. Triggers: `PageLoader`, `LayoutLoader`, `AppLoader`, `PageProps`, `shared`, `useShared`, `validation`, `request.validated`; "load page data", "pass server data to React", "share request data", "redirect from a loader". Skip: page module basics — `@warlock.js/web/create-a-page/SKILL.md`; layouts — `@warlock.js/web/use-layouts/SKILL.md`; mutation follow-up — `@warlock.js/web/navigate-on-the-client/SKILL.md`; competing loaders Next data functions, Remix loaders, React Server Components.'
270
+ ---
271
+
272
+ # Warlock — load page data
273
+
274
+ Loaders run on the server and return serializable data to their own component level. Use the level-specific loader type with `satisfies`, then pass `typeof loader` to the matching props type.
275
+
276
+ ## The shape
277
+
278
+ ```tsx title="src/app/products/web/product-details.page.tsx"
279
+ import { v } from "@warlock.js/seal";
280
+ import type { PageLoader, PageProps } from "@warlock.js/web";
281
+
282
+ export const route = {
283
+ path: "/products/:id",
284
+ name: "products.details",
285
+ } as const;
286
+
287
+ export const validation = {
288
+ schema: v.object({
289
+ id: v.string().minLength(2),
290
+ }),
291
+ validating: ["params"],
292
+ } as const;
293
+
294
+ export const loader = (async ({ request, response, shared }) => {
295
+ const { id } = request.validated();
296
+
297
+ if (id === "missing") {
298
+ return response.notFound();
299
+ }
300
+
301
+ response.header("cache-control", "private, max-age=60");
302
+
303
+ return {
304
+ product: {
305
+ id,
306
+ name: `Product ${id}`,
307
+ },
308
+ locale: (shared as { locale?: string }).locale ?? "en",
309
+ };
310
+ }) satisfies PageLoader<typeof validation, typeof route>;
311
+
312
+ export default function ProductDetailsPage({ data }: PageProps<typeof loader>) {
313
+ return (
314
+ <article lang={data.locale}>
315
+ <h1>{data.product.name}</h1>
316
+ </article>
317
+ );
318
+ }
319
+ ```
320
+
321
+ `LoaderShortCircuit` values from `notFound()` and redirects are excluded from `PageProps["data"]`, so the component sees only the successful loader return.
322
+
323
+ ## Three loader levels
324
+
325
+ | Module | Contract | Component props |
326
+ | --- | --- | --- |
327
+ | `src/web/root.tsx` | `AppLoader` | `AppProps<typeof loader>` |
328
+ | positional `layout.tsx` | `LayoutLoader` | `LayoutProps<typeof loader>` |
329
+ | `*.page.tsx` | `PageLoader` | `PageProps<typeof loader>` |
330
+
331
+ All receive one context object with `request`, `response`, and `shared`. Page loaders add generics that connect their sibling `validation` and `route` exports to `request.validated()` and `request.input()`.
332
+
333
+ ## Validation
334
+
335
+ A page's `validation` export has:
336
+
337
+ - `schema`: a Seal validator.
338
+ - `validating`: any ordered subset of `"body"`, `"query"`, `"params"`, and `"headers"`.
339
+
340
+ When `validating` is absent or empty, pages validate query + params, with params winning a duplicate key. Validation runs before loaders and a failure short-circuits with 422.
341
+
342
+ `request.validated()` uses the schema's output type, so fields with `.default(...)` are present. `request.input("id")` is narrowed from a literal route path when the loader uses `typeof route`.
343
+
344
+ ## Loader response surface
345
+
346
+ Each loader gets its own buffered response because App, Layout, and Page loaders execute in parallel. The public loader methods are:
347
+
348
+ ```ts
349
+ response.header("cache-control", "private, max-age=60");
350
+ response.setStatusCode(201);
351
+ return response.redirect("/login");
352
+ return response.permanentRedirect("/products");
353
+ return response.notFound();
354
+ ```
355
+
356
+ Do not continue after a redirect or `notFound`; return the result. Surviving buffers are committed root to leaf. For the same header key, the leafward write wins. A loader that throws or a lower level discarded by a short-circuit does not leak its buffered writes.
357
+
358
+ ## Declare the shared payload
359
+
360
+ `SharedContext` ships empty and has no index signature. Augment it once with everything the browser is allowed to receive:
361
+
362
+ ```ts title="src/web/types.ts"
363
+ declare module "@warlock.js/web" {
364
+ interface SharedContext {
365
+ locale: string;
366
+ user?: {
367
+ name: string;
368
+ };
369
+ }
370
+ }
371
+
372
+ export {};
373
+ ```
374
+
375
+ Required keys need an unconditional middleware writer. Optional keys may be written conditionally:
376
+
377
+ ```tsx title="src/web/root.tsx"
378
+ import { Head, Scripts, shared as writableShared } from "@warlock.js/web";
379
+ import type { AppProps } from "@warlock.js/web";
380
+ import "./types";
381
+
382
+ const publishLocale = async () => {
383
+ writableShared.locale = "en";
384
+ };
385
+
386
+ export const middleware = [publishLocale];
387
+
388
+ export default function App({ children, shared }: AppProps) {
389
+ return (
390
+ <html lang={shared.locale}>
391
+ <head>
392
+ <Head />
393
+ </head>
394
+ <body>
395
+ <div id="root">{children}</div>
396
+ <Scripts />
397
+ </body>
398
+ </html>
399
+ );
400
+ }
401
+ ```
402
+
403
+ ## Shared lifecycle
404
+
405
+ `shared` looks global but resolves through the current request's store on every access. Two requests never share its target.
406
+
407
+ The request pipeline is:
408
+
409
+ 1. App, layout, and page middleware run outermost first and write `shared`.
410
+ 2. `shared` is normalized, checked, and sealed.
411
+ 3. App, layout, and page loaders run in parallel and may only read it.
412
+ 4. Components receive a readonly snapshot through props or `useShared()`.
413
+
414
+ Use `useShared()` in a deep component that is not already receiving level props:
415
+
416
+ ```tsx title="src/web/components/locale-label.tsx"
417
+ import { useShared } from "@warlock.js/web";
418
+
419
+ export function LocaleLabel() {
420
+ const shared = useShared();
421
+
422
+ return <span>Locale: {shared.locale}</span>;
423
+ }
424
+ ```
425
+
426
+ Only put browser-safe data in `shared`: scalars, arrays, plain objects, or values with a valid `toJSON()` contract. Functions, `Date`, `Map`, `Set`, and arbitrary class instances are rejected. Prefer a narrow Resource output over a model.
427
+
428
+ ## Parallelism rules
429
+
430
+ - App, Layout, and Page loaders cannot read each other's return values.
431
+ - A loader may read `shared` because middleware completed and it was sealed first.
432
+ - A loader must not write `shared`; a post-seal write throws.
433
+ - Loader response mutations are buffered per level and settled deterministically.
434
+
435
+ ## Gotchas
436
+
437
+ - **Use `satisfies`, not a type annotation.** Preserve the return type for component props.
438
+ - **Return client-safe data.** Components render again in the browser; models and server handles do not survive the wire.
439
+ - **Write `shared` in middleware only.** Loaders run after the seal.
440
+ - **Required shared keys need unconditional writers.** The type is a promise for every request.
441
+ - **Do not use loader return values as cross-level communication.** The loaders run in parallel.
442
+ - **Server actions are not supported.** POST to an ordinary Warlock API route and call `refresh()` after success.
443
+
444
+ ## See also
445
+
446
+ - [`create-a-page/SKILL.md`](../create-a-page/SKILL.md) — the complete page module.
447
+ - [`write-the-root/SKILL.md`](../write-the-root/SKILL.md) — `AppLoader`, `<Head />`, and `<Scripts />`.
448
+ - [`use-layouts/SKILL.md`](../use-layouts/SKILL.md) — `LayoutLoader` and persistent wrappers.
449
+ - [`navigate-on-the-client/SKILL.md`](../navigate-on-the-client/SKILL.md) — re-fetch loaders with `refresh()`.
450
+
451
+ ## navigate-on-the-client `@warlock.js/web/navigate-on-the-client/SKILL.md`
452
+
453
+ ---
454
+ name: navigate-on-the-client
455
+ description: 'Navigate hydrated pages with `<Link>`, resolve named URLs with `href()`, use `navigateTo` / `navigateBack`, prefetch on interaction, inspect the server match with `currentRoute()`, and re-fetch loaders after a mutation with `refresh()`. Triggers: `Link`, `href`, `navigateTo`, `navigateBack`, `refresh`, `currentRoute`, `previousRoute`; "navigate without a reload", "link to a named route", "refresh page data", "revalidate loaders", "client-side back"; typical import `import { Link, refresh } from "@warlock.js/web"`. Skip: define a page route — `@warlock.js/web/create-a-page/SKILL.md`; loader mechanics — `@warlock.js/web/load-page-data/SKILL.md`; root hydration boundary — `@warlock.js/web/write-the-root/SKILL.md`; competing routers `@mongez/react-router`, `react-router-dom`, Next navigation.'
456
+ ---
457
+
458
+ # Warlock — navigate on the client
459
+
460
+ `<Link>` renders a real anchor for progressive enhancement and intercepts a plain in-app click after hydration. The server remains the only route matcher; client navigation fetches the page-data representation of the URL and swaps the Layout + Page tree.
461
+
462
+ ## The shape
463
+
464
+ ```tsx title="src/web/components/product-link.tsx"
465
+ import { Link } from "@warlock.js/web";
466
+
467
+ export function ProductLink({ id }: { id: string }) {
468
+ return (
469
+ <Link
470
+ to="products.details"
471
+ params={{ id }}
472
+ query={{ tab: "specifications" }}
473
+ prefetch
474
+ >
475
+ View product
476
+ </Link>
477
+ );
478
+ }
479
+ ```
480
+
481
+ Route-name destinations resolve through the table published from the same page graph the server installs. Missing or extra params throw rather than producing a URL that silently 404s.
482
+
483
+ ## Link destinations
484
+
485
+ Pass exactly one destination prop:
486
+
487
+ ```tsx
488
+ import { Link } from "@warlock.js/web";
489
+
490
+ export function NavigationLinks() {
491
+ return (
492
+ <nav>
493
+ <Link to="products.index">Products</Link>
494
+ <Link href="/pricing">Pricing</Link>
495
+ <Link href="https://example.com/docs" newTab>External docs</Link>
496
+ <Link email="sales@example.com">Email sales</Link>
497
+ <Link tel="+201000000000">Call sales</Link>
498
+ </nav>
499
+ );
500
+ }
501
+ ```
502
+
503
+ `to` and `href` both accept a route name or literal URL:
504
+
505
+ - A value beginning with `/` or with a URI scheme is literal and is passed through.
506
+ - Any other value is a route name and is resolved through `href()`.
507
+ - `params` and `query` apply only to route names.
508
+ - `newTab` supplies `_blank` and `noopener noreferrer` unless you provide your own `target` or `rel`.
509
+
510
+ `prefetch` fetches in-app page data on hover or keyboard focus. It is ignored for external, email, telephone, new-tab, and explicitly targeted links. Prefetch is best-effort and never delays the interaction.
511
+
512
+ Modified clicks, middle clicks, downloads, another browsing context, or an earlier `preventDefault()` remain browser-owned.
513
+
514
+ ## Build a URL without React
515
+
516
+ `href(name, params?, query?)` is the durable primitive for redirects, headers, email bodies, and other non-component callers:
517
+
518
+ ```ts
519
+ import { href } from "@warlock.js/web";
520
+
521
+ const productUrl = href(
522
+ "products.details",
523
+ { id: "42" },
524
+ { tab: "reviews", tags: ["featured", "sale"] },
525
+ );
526
+ ```
527
+
528
+ Unknown route names throw at runtime with the known names. Call `href()` after the route table has been published—during a page render, event, or request—not from an eager module initializer before boot.
529
+
530
+ ## Programmatic navigation
531
+
532
+ ```tsx title="src/web/components/checkout-button.tsx"
533
+ import { href, navigateBack, navigateTo } from "@warlock.js/web";
534
+
535
+ export function CheckoutButtons() {
536
+ const openCheckout = () => {
537
+ const url = href("checkout.index");
538
+
539
+ if (!navigateTo(url)) {
540
+ window.location.assign(url);
541
+ }
542
+ };
543
+
544
+ return (
545
+ <div>
546
+ <button type="button" onClick={navigateBack}>Back</button>
547
+ <button type="button" onClick={openCheckout}>Checkout</button>
548
+ </div>
549
+ );
550
+ }
551
+ ```
552
+
553
+ `navigateTo(path, { replace?: boolean })` accepts a path, not a route name. Resolve a name with `href()` first. It returns `false` when no client runtime accepted the navigation; use a real browser navigation when arrival is mandatory. `navigateBack()` is a no-op without a browser.
554
+
555
+ ## Re-fetch after a mutation
556
+
557
+ The public primitive is `refresh()`. There is no `revalidate()` export.
558
+
559
+ ```tsx title="src/app/products/web/delete-product-button.tsx"
560
+ import { refresh } from "@warlock.js/web";
561
+
562
+ export function DeleteProductButton({ id }: { id: string }) {
563
+ const deleteProduct = async () => {
564
+ const response = await fetch(`/api/products/${encodeURIComponent(id)}`, {
565
+ method: "DELETE",
566
+ });
567
+
568
+ if (!response.ok) {
569
+ throw new Error(`Delete failed with status ${response.status}`);
570
+ }
571
+
572
+ const refreshed = await refresh();
573
+
574
+ if (!refreshed) {
575
+ window.location.reload();
576
+ }
577
+ };
578
+
579
+ return <button type="button" onClick={deleteProduct}>Delete product</button>;
580
+ }
581
+ ```
582
+
583
+ `refresh()` re-fetches the current URL's App, Layout, and Page loaders and swaps fresh data without pushing history. It returns `true` only when fresh data reached the screen. On network/build failure it returns `false` and leaves the current page intact.
584
+
585
+ This is an ordinary API mutation followed by a re-fetch. Server actions are not supported.
586
+
587
+ ## Current and previous routes
588
+
589
+ `currentRoute()` and `previousRoute()` report route names and params sent by the server; they never re-match the browser URL.
590
+
591
+ ```tsx title="src/web/components/current-product-id.tsx"
592
+ import { currentRoute, previousRoute } from "@warlock.js/web";
593
+
594
+ export function CurrentProductId() {
595
+ const current = currentRoute();
596
+ const previous = previousRoute();
597
+
598
+ return (
599
+ <dl>
600
+ <dt>Current product</dt>
601
+ <dd>{current?.params?.id ?? "none"}</dd>
602
+ <dt>Previous page</dt>
603
+ <dd>{previous?.name ?? "first visit"}</dd>
604
+ </dl>
605
+ );
606
+ }
607
+ ```
608
+
609
+ `previousRoute()` means the previously swapped page, not the previous browser-history entry. On the first page it is `undefined`.
610
+
611
+ `routerEvents` is exported and `refresh()` emits its start/end/error lifecycle today. Ordinary Link and `navigateTo` swaps are not yet wired to that emitter, so do not use it as a complete global navigation progress signal yet.
612
+
613
+ ## Failure behavior
614
+
615
+ - A failed navigation falls back to a full browser load so the user still arrives.
616
+ - A failed `refresh()` keeps the current page and reports `false`; it never spends the page the user already has.
617
+ - Back/Forward uses the same data fetch and tree swap, replacing the existing history entry rather than pushing another.
618
+ - Navigating within one layout preserves layout state through React reconciliation.
619
+
620
+ ## Gotchas
621
+
622
+ - **Use `refresh()`, not `revalidate()`.** `refresh` is the exported loader re-fetch primitive.
623
+ - **`navigateTo` accepts a URL/path.** Resolve named routes with `href()` first.
624
+ - **Pass exactly one Link destination.** Combining `to`, `href`, `email`, or `tel` throws.
625
+ - **Do not add `params` or `query` to a literal URL.** Put them in the URL itself or use a route name.
626
+ - **Do not build a client matcher.** The server's matched name and params travel in the payload.
627
+ - **Do not import `esm/hydration/index.mjs`.** Normal consumers import navigation from `@warlock.js/web`; low-level runtime contracts live at `@warlock.js/web/client/runtime`.
628
+
629
+ ## See also
630
+
631
+ - [`create-a-page/SKILL.md`](../create-a-page/SKILL.md) — declare route names and params.
632
+ - [`load-page-data/SKILL.md`](../load-page-data/SKILL.md) — what `refresh()` re-runs.
633
+ - [`use-layouts/SKILL.md`](../use-layouts/SKILL.md) — why layout state persists.
634
+ - [`write-the-root/SKILL.md`](../write-the-root/SKILL.md) — the `#root` swap boundary.
635
+
636
+ ## serve-styles `@warlock.js/web/serve-styles/SKILL.md`
637
+
638
+ ---
639
+ name: serve-styles
640
+ description: 'Serve CSS imported by `root.tsx` or `*.page.tsx`, with render-blocking `<link rel="stylesheet">` delivery from Vite source URLs in development and Vite manifest assets in production. Triggers: `import "./app.css"`, page CSS, `?direct`, `manifest.json`, stylesheet flash, FOUC, `<head>`; "add global styles", "style a page", "CSS missing in SSR", "page flashes unstyled", "serve CSS in production". Skip: root document markup — `@warlock.js/web/write-the-root/SKILL.md`; page authoring — `@warlock.js/web/create-a-page/SKILL.md`; client navigation — `@warlock.js/web/navigate-on-the-client/SKILL.md`; competing styling systems CSS-in-JS, Next CSS, styled-components.'
641
+ ---
642
+
643
+ # Warlock — serve styles
644
+
645
+ Import styles from `root.tsx` for global, render-blocking CSS. A page may also import its own stylesheet, but development and production deliver page-local CSS differently.
646
+
647
+ ## The shape
648
+
649
+ ```tsx title="src/web/root.tsx"
650
+ import { Head, Scripts } from "@warlock.js/web";
651
+ import type { AppProps } from "@warlock.js/web";
652
+ import "./app.css";
653
+
654
+ export default function App({ children }: AppProps) {
655
+ return (
656
+ <html lang="en">
657
+ <head>
658
+ <Head />
659
+ </head>
660
+ <body>
661
+ <div id="root">{children}</div>
662
+ <Scripts />
663
+ </body>
664
+ </html>
665
+ );
666
+ }
667
+ ```
668
+
669
+ ```css title="src/web/app.css"
670
+ :root {
671
+ color-scheme: light dark;
672
+ font-family: system-ui, sans-serif;
673
+ }
674
+
675
+ body {
676
+ margin: 0;
677
+ }
678
+
679
+ main {
680
+ max-width: 72rem;
681
+ margin-inline: auto;
682
+ padding: 2rem;
683
+ }
684
+ ```
685
+
686
+ Use a bare side-effect import in `root.tsx`. In development the server parses that exact form to discover global styles before rendering the document.
687
+
688
+ ## Development delivery
689
+
690
+ Development has no client manifest, so Warlock reads `root.tsx`, finds bare stylesheet imports, resolves them relative to the root file, and emits a link before `</head>`:
691
+
692
+ ```html
693
+ <link rel="stylesheet" href="/src/web/app.css?direct">
694
+ ```
695
+
696
+ The `?direct` query is required: without it Vite serves an imported CSS URL as a JavaScript module, which a stylesheet link cannot apply. With it Vite returns real `text/css`.
697
+
698
+ Root imports ending in `.css`, `.scss`, `.sass`, `.less`, or `.styl` are recognized. The source file must resolve inside the app root; an outside path is left to the client graph rather than converted to an unsafe `/@fs/` guess.
699
+
700
+ ## Page-local styles
701
+
702
+ A page may import CSS directly:
703
+
704
+ ```tsx title="src/app/products/web/products.page.tsx"
705
+ import "./products.css";
706
+
707
+ export const route = {
708
+ path: "/products",
709
+ name: "products.index",
710
+ } as const;
711
+
712
+ export default function ProductsPage() {
713
+ return (
714
+ <main className="products-page">
715
+ <h1>Products</h1>
716
+ </main>
717
+ );
718
+ }
719
+ ```
720
+
721
+ ```css title="src/app/products/web/products.css"
722
+ .products-page {
723
+ display: grid;
724
+ gap: 1rem;
725
+ }
726
+ ```
727
+
728
+ Recognized asset imports survive the page's client projection. The client boundary is determined by the import graph, not by the file living under `web/`.
729
+
730
+ In development, only root stylesheet imports are converted into render-blocking document links. A page-local import is loaded by Vite's client module graph around hydration, so it still applies but is not guaranteed before first paint. Move above-the-fold or site-wide rules into a stylesheet imported by `root.tsx` when avoiding a flash matters.
731
+
732
+ ## Production delivery
733
+
734
+ The production client build enables Vite's manifest. Vite records emitted CSS against the chunks that imported it. At boot, Warlock reads:
735
+
736
+ ```text
737
+ <build.outdir>/client/.vite/manifest.json
738
+ ```
739
+
740
+ It collects every valid `css` asset across all manifest entries, deduplicates them in manifest order, and emits each as a render-blocking link before `</head>`.
741
+
742
+ That means both root-imported and page-imported CSS are linked in the initial production document. The current implementation collects across the whole client manifest rather than selecting only the matched route's CSS, so every page receives the emitted stylesheet set.
743
+
744
+ Only manifest URLs under the one client asset prefix are emitted; a stylesheet outside the mounted asset directory is dropped instead of producing a dead link.
745
+
746
+ ## Where links land
747
+
748
+ Stylesheet links are inserted into the rendered HTML immediately before the final `</head>`. They are not rendered by the `<Head />` component itself.
749
+
750
+ Consequences:
751
+
752
+ - A custom root must render a real `<head>...</head>` for automatic stylesheet links.
753
+ - Root-authored `<link>` and `<style>` elements appear before Warlock's injected stylesheet links, so later injected rules can win normal cascade ties.
754
+ - `<Head />` remains responsible for page metadata; the closing `</head>` is what stylesheet installation needs.
755
+ - A root with no closing `</head>` is returned unchanged and receives no automatic stylesheet links.
756
+
757
+ ## What ships to the browser
758
+
759
+ The hydration entry is built from the projected client graph. CSS imports are known-safe asset edges and survive projection even when server exports in the same page module are removed. Application code never imports the published hydration file directly; the web build uses `esm/hydration/index.mjs` as an input and the route handler installs its emitted module URL.
760
+
761
+ ## Diagnose missing or late CSS
762
+
763
+ 1. For global CSS in development, confirm `root.tsx` uses a bare import such as `import "./app.css";`.
764
+ 2. Confirm the rendered document contains a closing `</head>` and a stylesheet URL ending in `?direct`.
765
+ 3. For page-local CSS in development, expect Vite's client graph to apply it after hydration; move critical rules to the root import if first paint matters.
766
+ 4. In production, confirm `.vite/manifest.json` has `css` arrays and the referenced files live under the client asset prefix.
767
+ 5. Do not hand-author a link to a hashed production asset; its name belongs to the Vite manifest.
768
+
769
+ ## Gotchas
770
+
771
+ - **Root CSS and page CSS differ in dev.** Root imports are render-blocking; page imports are client-injected.
772
+ - **Production currently links all emitted CSS on every page.** It is safe for first paint but not route-minimal.
773
+ - **Use a bare root import.** `devStylesheetUrls` scans `import "./app.css"`, not a bound CSS-module import.
774
+ - **Keep a closing `</head>`.** Automatic link installation has nowhere safe to write without it.
775
+ - **Do not remove `?direct` from a dev stylesheet link.** Vite otherwise responds with JavaScript.
776
+ - **Do not import the hydration entry yourself.** The connector owns dev serving and production asset URLs.
777
+
778
+ ## See also
779
+
780
+ - [`write-the-root/SKILL.md`](../write-the-root/SKILL.md) — the document `<head>` these links enter.
781
+ - [`create-a-page/SKILL.md`](../create-a-page/SKILL.md) — page-local asset imports and projection.
782
+ - [`navigate-on-the-client/SKILL.md`](../navigate-on-the-client/SKILL.md) — client swaps after the initial styled document.
783
+
784
+ ## use-layouts `@warlock.js/web/use-layouts/SKILL.md`
785
+
786
+ ---
787
+ name: use-layouts
788
+ description: 'Wrap pages with positional `layout.tsx` modules, compose literal `prefix` exports, load typed layout data with `LayoutLoader` / `LayoutProps`, and preserve layout state during client navigation. Triggers: `layout.tsx`, `prefix`, `LayoutLoader`, `LayoutProps`, `children`; "add a page layout", "share navigation between pages", "prefix page routes", "keep a layout mounted". Skip: full-document root — `@warlock.js/web/write-the-root/SKILL.md`; page route export — `@warlock.js/web/create-a-page/SKILL.md`; loader and shared lifecycle — `@warlock.js/web/load-page-data/SKILL.md`; competing layout systems `next/layout`, React Router outlets, Remix nested routes.'
789
+ ---
790
+
791
+ # Warlock — use layouts
792
+
793
+ A positional `layout.tsx` applies to pages in its directory and descendant directories. Its default export wraps the page; its optional `prefix` contributes to every descendant page's effective URL.
794
+
795
+ ## The shape
796
+
797
+ ```tsx title="src/app/products/web/layout.tsx"
798
+ import type { LayoutLoader, LayoutProps } from "@warlock.js/web";
799
+
800
+ export const prefix = "/products";
801
+
802
+ export const loader = (async () => {
803
+ return {
804
+ navigation: [
805
+ { href: "/products", label: "Products" },
806
+ { href: "/products/new", label: "New product" },
807
+ ],
808
+ };
809
+ }) satisfies LayoutLoader;
810
+
811
+ export default function ProductsLayout({
812
+ data,
813
+ children,
814
+ }: LayoutProps<typeof loader>) {
815
+ return (
816
+ <section>
817
+ <nav aria-label="Products">
818
+ {data.navigation.map(item => (
819
+ <a key={item.href} href={item.href}>
820
+ {item.label}
821
+ </a>
822
+ ))}
823
+ </nav>
824
+ {children}
825
+ </section>
826
+ );
827
+ }
828
+ ```
829
+
830
+ A page beside it can declare its path relative to the prefix:
831
+
832
+ ```tsx title="src/app/products/web/index.page.tsx"
833
+ export const route = {
834
+ path: "/",
835
+ name: "products.index",
836
+ } as const;
837
+
838
+ export default function ProductsPage() {
839
+ return <h1>Products</h1>;
840
+ }
841
+ ```
842
+
843
+ The effective URL is `/products`. A page with `route.path = "/:id"` under the same layout is served at `/products/:id`.
844
+
845
+ ## Prefix composition
846
+
847
+ Every positional layout on the page's directory ancestry may export a literal `prefix`. Prefixes compose outermost first, then the page's own route path.
848
+
849
+ ```tsx title="src/app/users/web/layout.tsx"
850
+ export const prefix = "/users";
851
+ ```
852
+
853
+ ```tsx title="src/app/users/web/account/layout.tsx"
854
+ export const prefix = "/account";
855
+ ```
856
+
857
+ ```tsx title="src/app/users/web/account/settings.page.tsx"
858
+ export const route = {
859
+ path: "/settings",
860
+ name: "users.account.settings",
861
+ } as const;
862
+
863
+ export default function SettingsPage() {
864
+ return <h1>Account settings</h1>;
865
+ }
866
+ ```
867
+
868
+ This page's effective URL is `/users/account/settings`.
869
+
870
+ Like page routes, prefixes are read statically at build time. Write `export const prefix = "/account";` directly; computed prefixes are refused.
871
+
872
+ ## Rendering-layout limit
873
+
874
+ Prefix nesting does not imply wrapper nesting. Pages currently support at most one rendering layout—one `layout.tsx` with a default export—on their ancestry path.
875
+
876
+ The two prefix-only layouts above are legal because neither renders. Add a default export to at most one of them. If two layouts on the path have default exports, discovery and boot throw `NestedLayoutsNotSupportedError`, naming the page and both rendering layouts.
877
+
878
+ Non-rendering layouts may carry prefixes and middleware and may nest freely. Do not delete a middleware-only authorization boundary to satisfy the rendering limit; consolidate only the default-export wrappers.
879
+
880
+ ## Why layout state persists
881
+
882
+ Client navigation rebuilds the Layout + Page element tree at the same `#root` position. When the next page uses the same layout component type in the same position, React reconciles it instead of remounting it. Layout state such as open menus, scroll containers, and media survives.
883
+
884
+ `refresh()` has the same property: it re-fetches loaders and swaps the page data while the layout stays mounted. Navigating to a page with a different layout component changes the tree type and remounts that wrapper.
885
+
886
+ ## Loader data
887
+
888
+ `LayoutLoader` receives the full `PageContext` and its return reaches `LayoutProps<typeof loader>["data"]`. Layout, App, and Page loaders run in parallel; a layout loader cannot read the page loader's result.
889
+
890
+ Use `shared` when multiple levels need one request-derived value, and write it in middleware before loaders run. See [load-page-data](../load-page-data/SKILL.md).
891
+
892
+ ## Gotchas
893
+
894
+ - **`layout.tsx` is positional.** It applies by directory ancestry; moving a page can change its layout and URL prefix together.
895
+ - **Only one layout on a path may render.** Multiple prefix/middleware-only layouts are fine; multiple default exports are not.
896
+ - **A layout prefix changes the registered URL.** Check the composed path, not only the page's `route.path`.
897
+ - **Keep `prefix` literal.** The build parses it without executing the module.
898
+ - **Use `LayoutProps<typeof loader>`.** Bare `LayoutProps` is for a layout with no loader and gives `data` as `undefined`.
899
+ - **Layout persistence is type-and-position based.** Changing to another layout component remounts it, as normal React reconciliation requires.
900
+
901
+ ## See also
902
+
903
+ - [`create-a-page/SKILL.md`](../create-a-page/SKILL.md) — declare the page path composed after the prefix.
904
+ - [`write-the-root/SKILL.md`](../write-the-root/SKILL.md) — the document and `#root` outside the layout.
905
+ - [`load-page-data/SKILL.md`](../load-page-data/SKILL.md) — `LayoutLoader`, parallel execution, and `shared`.
906
+ - [`navigate-on-the-client/SKILL.md`](../navigate-on-the-client/SKILL.md) — client swaps and `refresh()`.
907
+
908
+ ## write-the-root `@warlock.js/web/write-the-root/SKILL.md`
909
+
910
+ ---
911
+ name: write-the-root
912
+ description: 'Author `src/web/root.tsx`, the full-document application root that owns `<html>`, `<head>`, and `<body>`, places page metadata with `<Head />`, renders the hydrated subtree inside `#root`, and emits the payload with `<Scripts />`. Triggers: `root.tsx`, `AppProps`, `AppLoader`, `Head`, `Scripts`, `id="root"`; "customize the root document", "add html lang", "add an app provider", "where do Head and Scripts go"; typical import `import { Head, Scripts, type AppProps } from "@warlock.js/web"`. Skip: page component contract — `@warlock.js/web/create-a-page/SKILL.md`; layout wrappers — `@warlock.js/web/use-layouts/SKILL.md`; CSS delivery — `@warlock.js/web/serve-styles/SKILL.md`; competing roots `next/layout`, Remix `root`, React `createRoot`.'
913
+ ---
914
+
915
+ # Warlock — write the root
916
+
917
+ `src/web/root.tsx` is the application document. Its default export renders the complete `<html>` tree and contains the one DOM node the browser hydrates: `#root`.
918
+
919
+ ## The shape
920
+
921
+ ```tsx title="src/web/root.tsx"
922
+ import { Head, Scripts } from "@warlock.js/web";
923
+ import type { AppProps } from "@warlock.js/web";
924
+
925
+ export default function App({ children }: AppProps) {
926
+ return (
927
+ <html lang="en">
928
+ <head>
929
+ <Head />
930
+ <link rel="icon" href="/favicon.svg" />
931
+ </head>
932
+ <body>
933
+ <div id="root">{children}</div>
934
+ <Scripts />
935
+ </body>
936
+ </html>
937
+ );
938
+ }
939
+ ```
940
+
941
+ The root component is synchronous. HTTP work belongs in an `AppLoader`; the component receives its result as `data`.
942
+
943
+ ## `#root` is the hydration boundary
944
+
945
+ The server renders this document shape:
946
+
947
+ ```text
948
+ App document
949
+ └── #root
950
+ └── Layout
951
+ └── Page
952
+ ```
953
+
954
+ The browser hydrates `#root`, not the whole document. The client tree deliberately contains Layout + Page and excludes App because App contains the mount point. Keep exactly one element with `id="root"` around `{children}`. It may be nested inside your own body markup, but it must not be renamed or omitted.
955
+
956
+ Because App is outside the hydrated subtree, put client state that must survive navigation in a layout or component beneath `#root`, not in the document root.
957
+
958
+ ## `<Head />`
959
+
960
+ `<Head />` renders the resolved page metadata at that position. It takes no props and emits:
961
+
962
+ - `<meta charset="utf-8">`
963
+ - title, description, keywords, canonical, and robots tags
964
+ - Open Graph and Twitter tags
965
+
966
+ Do not also hard-code a `<title>` for the current page; that produces two titles. Static root-wide tags such as a favicon or application name can sit beside `<Head />`.
967
+
968
+ ## `<Scripts />`
969
+
970
+ `<Scripts />` emits the escaped `application/json` payload that hydration and client navigation consume. Put it after `#root`, normally near the end of `<body>`.
971
+
972
+ For a Content Security Policy nonce:
973
+
974
+ ```tsx title="src/web/root.tsx"
975
+ import { Head, Scripts } from "@warlock.js/web";
976
+ import type { AppProps } from "@warlock.js/web";
977
+
978
+ export default function App({ children, shared }: AppProps) {
979
+ return (
980
+ <html lang="en">
981
+ <head>
982
+ <Head />
983
+ </head>
984
+ <body>
985
+ <div id="root">{children}</div>
986
+ <Scripts nonce={(shared as { nonce?: string }).nonce} />
987
+ </body>
988
+ </html>
989
+ );
990
+ }
991
+ ```
992
+
993
+ Prefer declaring `nonce` on `SharedContext` so the cast is unnecessary; see [load-page-data](../load-page-data/SKILL.md). If no prop is supplied, `<Scripts />` falls back to the framework's request nonce slot.
994
+
995
+ `<Scripts />` owns the inline data payload. The separate hydration module is appended by the page route handler. Its published `esm/hydration/index.mjs` file is a build input and must never be imported by application code.
996
+
997
+ ## Add an application loader
998
+
999
+ ```tsx title="src/web/root.tsx"
1000
+ import { Head, Scripts } from "@warlock.js/web";
1001
+ import type { AppLoader, AppProps } from "@warlock.js/web";
1002
+
1003
+ export const loader = (async () => {
1004
+ return { applicationName: "Warlock Store" };
1005
+ }) satisfies AppLoader;
1006
+
1007
+ export default function App({ data, children }: AppProps<typeof loader>) {
1008
+ return (
1009
+ <html lang="en">
1010
+ <head>
1011
+ <Head />
1012
+ <meta name="application-name" content={data.applicationName} />
1013
+ </head>
1014
+ <body>
1015
+ <div id="root">{children}</div>
1016
+ <Scripts />
1017
+ </body>
1018
+ </html>
1019
+ );
1020
+ }
1021
+ ```
1022
+
1023
+ The App loader runs in parallel with layout and page loaders. Its return is for the App component; use `shared` for request data that multiple levels need.
1024
+
1025
+ ## Gotchas
1026
+
1027
+ - **The root owns the complete document.** Return `<html>`, `<head>`, and `<body>`, not a fragment.
1028
+ - **Keep `{children}` inside `#root`.** Server rendering can still look correct without it, but hydration cannot mount.
1029
+ - **Render `<Head />` in a custom root.** It is what turns the page's `metadata` into elements.
1030
+ - **Render `<Scripts />` in a custom root.** Without the payload script, the browser cannot hydrate the server-rendered page.
1031
+ - **Do not make the component `async`.** Load data with `AppLoader`.
1032
+ - **Do not import browser-only state into the root expecting it to persist.** The client hydrates the subtree inside the root, not the root itself.
1033
+ - **Stylesheet links are installed separately.** They are inserted before `</head>`; read [serve-styles](../serve-styles/SKILL.md) for the dev/production rules.
1034
+
1035
+ ## See also
1036
+
1037
+ - [`create-a-page/SKILL.md`](../create-a-page/SKILL.md) — the Page level rendered under the root.
1038
+ - [`use-layouts/SKILL.md`](../use-layouts/SKILL.md) — the persistent wrapper inside `#root`.
1039
+ - [`load-page-data/SKILL.md`](../load-page-data/SKILL.md) — `AppLoader` and typed `shared`.
1040
+ - [`serve-styles/SKILL.md`](../serve-styles/SKILL.md) — global CSS from `root.tsx`.
1041
+