create-warlock 5.34.2 → 5.35.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 (106) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +8 -0
  3. package/esm/commands/create-new-app/index.mjs +4 -3
  4. package/esm/commands/create-new-app/index.mjs.map +1 -1
  5. package/esm/commands/create-warlock-app/index.mjs +7 -1
  6. package/esm/commands/create-warlock-app/index.mjs.map +1 -1
  7. package/esm/features/features-map.mjs +8 -2
  8. package/esm/features/features-map.mjs.map +1 -1
  9. package/esm/helpers/app.mjs +1 -0
  10. package/esm/helpers/app.mjs.map +1 -1
  11. package/esm/prompts/ask-stack.mjs +1 -1
  12. package/esm/prompts/ask-stack.mjs.map +1 -1
  13. package/llms-full.txt +64 -28
  14. package/llms.txt +1 -1
  15. package/package.json +2 -2
  16. package/skills/{create-a-warlock-project/SKILL.md → SKILL.md} +63 -27
  17. package/templates/design-system/SOURCES.md +23 -0
  18. package/templates/design-system/package.json +14 -0
  19. package/templates/design-system/src/web/components/ui/LICENSE.md +21 -0
  20. package/templates/design-system/src/web/components/ui/button.tsx +61 -0
  21. package/templates/design-system/src/web/components/ui/calendar.tsx +178 -0
  22. package/templates/design-system/src/web/components/ui/checkbox.tsx +28 -0
  23. package/templates/design-system/src/web/components/ui/command.tsx +161 -0
  24. package/templates/design-system/src/web/components/ui/dialog.tsx +144 -0
  25. package/templates/design-system/src/web/components/ui/input.tsx +20 -0
  26. package/templates/design-system/src/web/components/ui/label.tsx +20 -0
  27. package/templates/design-system/src/web/components/ui/popover.tsx +73 -0
  28. package/templates/design-system/src/web/components/ui/radio-group.tsx +44 -0
  29. package/templates/design-system/src/web/components/ui/switch.tsx +34 -0
  30. package/templates/design-system/src/web/components/ui/textarea.tsx +17 -0
  31. package/templates/design-system/src/web/design-system/components/checkbox-input.tsx +19 -0
  32. package/templates/design-system/src/web/design-system/components/date-picker-input.tsx +59 -0
  33. package/templates/design-system/src/web/design-system/components/date-time-picker-input.tsx +67 -0
  34. package/templates/design-system/src/web/design-system/components/email-input.tsx +22 -0
  35. package/templates/design-system/src/web/design-system/components/field-frame.tsx +36 -0
  36. package/templates/design-system/src/web/design-system/components/file-input.tsx +24 -0
  37. package/templates/design-system/src/web/design-system/components/multi-select-input.tsx +94 -0
  38. package/templates/design-system/src/web/design-system/components/password-input.tsx +22 -0
  39. package/templates/design-system/src/web/design-system/components/radio-input.tsx +41 -0
  40. package/templates/design-system/src/web/design-system/components/select-input.tsx +94 -0
  41. package/templates/design-system/src/web/design-system/components/submit-button.tsx +20 -0
  42. package/templates/design-system/src/web/design-system/components/switch-input.tsx +19 -0
  43. package/templates/design-system/src/web/design-system/components/text-input.tsx +22 -0
  44. package/templates/design-system/src/web/design-system/components/textarea-input.tsx +22 -0
  45. package/templates/design-system/src/web/design-system/components/time-picker-input.tsx +22 -0
  46. package/templates/design-system/src/web/design-system/hooks/use-calendar-day-button.ts +9 -0
  47. package/templates/design-system/src/web/design-system/hooks/use-control.ts +29 -0
  48. package/templates/design-system/src/web/design-system/hooks/use-date-control.ts +42 -0
  49. package/templates/design-system/src/web/design-system/hooks/use-file-control.ts +24 -0
  50. package/templates/design-system/src/web/design-system/hooks/use-select-control.ts +78 -0
  51. package/templates/design-system/src/web/design-system/hooks/use-submit-control.ts +13 -0
  52. package/templates/design-system/src/web/design-system/types/control-props.ts +46 -0
  53. package/templates/toolchains/oxc/.oxlintrc.json +3 -1
  54. package/templates/warlock/.oxlintrc.json +3 -1
  55. package/templates/warlock/AGENTS.md +10 -10
  56. package/templates/warlock/e2e/home.e2e.ts +19 -0
  57. package/templates/warlock/package.json +1 -1
  58. package/templates/warlock/skills/api-design/SKILL.md +180 -57
  59. package/templates/warlock/skills/code-standards/SKILL.md +48 -39
  60. package/templates/warlock/skills/data-and-persistence/SKILL.md +72 -57
  61. package/templates/warlock/skills/git-workflow/SKILL.md +9 -9
  62. package/templates/warlock/skills/module-boundaries/SKILL.md +2 -2
  63. package/templates/warlock/skills/observability-and-resilience/SKILL.md +8 -6
  64. package/templates/warlock/skills/security-baseline/SKILL.md +23 -22
  65. package/templates/warlock/skills/testing-strategy/SKILL.md +53 -12
  66. package/templates/warlock/skills/web-code-standards/SKILL.md +195 -60
  67. package/templates/warlock/src/app/auth/controllers/forgot-password.controller.ts +6 -0
  68. package/templates/warlock/src/app/auth/controllers/login.controller.ts +17 -0
  69. package/templates/warlock/src/app/auth/controllers/logout-all.controller.ts +6 -0
  70. package/templates/warlock/src/app/auth/controllers/logout.controller.ts +6 -0
  71. package/templates/warlock/src/app/auth/controllers/me.controller.ts +7 -0
  72. package/templates/warlock/src/app/auth/controllers/refresh-token.controller.ts +12 -0
  73. package/templates/warlock/src/app/auth/controllers/reset-password.controller.ts +6 -0
  74. package/templates/warlock/src/app/auth/models/otp/otp.model.ts +9 -3
  75. package/templates/warlock/src/app/auth/services/otp.service.ts +18 -6
  76. package/templates/warlock/src/app/contact/controllers/contact.controller.ts +6 -0
  77. package/templates/warlock/src/app/home/controllers/home-page.controller.ts +4 -0
  78. package/templates/warlock/src/app/locale/controllers/locale.controller.ts +6 -0
  79. package/templates/warlock/src/app/posts/controllers/create-new-post.controller.ts +7 -0
  80. package/templates/warlock/src/app/posts/controllers/update-post.controller.ts +15 -9
  81. package/templates/warlock/src/app/posts/models/post/migrations/09-01-2026_02-07-51-post.migration.ts +1 -0
  82. package/templates/warlock/src/app/posts/models/post/post.model.ts +6 -0
  83. package/templates/warlock/src/app/posts/resources/post.resource.ts +1 -1
  84. package/templates/warlock/src/app/posts/schema/update-post.schema.ts +1 -0
  85. package/templates/warlock/src/app/shared/utils/format-http-error-response.ts +53 -0
  86. package/templates/warlock/src/app/users/commands/hello-world.command.ts +2 -1
  87. package/templates/warlock/src/app/users/controllers/create-new-user.controller.ts +13 -1
  88. package/templates/warlock/src/app/users/controllers/list-users.controller.ts +24 -1
  89. package/templates/warlock/src/app/users/models/user/migrations/11-12-2025_23-58-03-user.migration.ts +1 -0
  90. package/templates/warlock/src/app/users/models/user/user.model.ts +5 -0
  91. package/templates/warlock/src/app/users/repositories/users.repository.ts +2 -2
  92. package/templates/warlock/src/app/users/resources/user.resource.ts +1 -1
  93. package/templates/warlock/src/app/users/schema/list-users.schema.ts +1 -1
  94. package/templates/warlock/src/config/http.ts +6 -0
  95. package/templates/warlock/src/web/home/components/contact-field.tsx +18 -0
  96. package/templates/warlock/src/web/home/components/contact-section.tsx +5 -29
  97. package/templates/warlock/src/web/home/components/contact-submit-button.tsx +16 -0
  98. package/templates/warlock/src/web/home/hooks/use-contact-field.ts +9 -0
  99. package/templates/warlock/src/web/home/hooks/use-contact-form.ts +5 -8
  100. package/templates/warlock/src/web/home/hooks/use-contact-locale.ts +34 -0
  101. package/templates/warlock/src/web/home/hooks/use-contact-submit-button.ts +6 -0
  102. package/templates/warlock/src/web/home/index.page.tsx +1 -1
  103. package/templates/warlock/src/web/home/types/contact-controls.type.ts +13 -0
  104. package/templates/warlock/src/web/shared/utils/set-form-errors.ts +3 -1
  105. package/templates/warlock/warlock.config.ts +1 -0
  106. package/templates/warlock/src/web/home/components/contact-form-controls.tsx +0 -42
@@ -1,16 +1,12 @@
1
1
  ---
2
2
  name: web-code-standards
3
- description: >-
4
- Strict separation-of-concerns standards for Warlock.js src/web pages and
5
- components: loaders delegate data and business rules to app services;
6
- components delegate all behavior to separate custom-hook files.
7
- Use alongside code-standards for web work; skip for backend-only changes.
3
+ description: "Warlock web pages and setup files, component and hook ownership, Mongez forms, accessible controls, locale behavior and UI kit choices. Use when building or reviewing src/web features. Use code-standards instead for shared TypeScript rules."
8
4
  ---
9
5
 
10
6
  # Web code standards
11
7
 
12
8
  **Applies to:** `src/web/**` in this project. Read
13
- [`code-standards`](../code-standards/SKILL.md) for the shared TypeScript rules,
9
+ the `code-standards` skill for the shared TypeScript rules,
14
10
  and the relevant `@warlock.js/web` skill before using a framework feature.
15
11
  This skill adds the web-specific rules; it does not override security or
16
12
  module-boundary requirements. When reviewing existing code, identify debt
@@ -45,7 +41,7 @@ ancestor and child dictionaries cannot redefine the same key. Keep legacy
45
41
  Within a web feature, use `components/`, `hooks/`, and `utils/` for their
46
42
  named purpose; do not create `lib/` as a catch-all. These folders have no
47
43
  barrel `index.ts`: import the named component, hook, or helper from its own
48
- file. Keep types in `types/` when a feature has several contracts, or in one
44
+ file. These exact imports keep client bundles lean and prevent server-only code from being pulled through a barrel. The `code-standards` skill allows sub-folder barrels only for `src/app/**` server modules. Keep types in `types/` when a feature has several contracts, or in one
49
45
  `<feature>.types.ts` file when they are small. Do not duplicate a contract in
50
46
  both places. Put component props in that dedicated type module by default;
51
47
  keep a tiny private prop type beside a component only when extracting it would
@@ -54,13 +50,17 @@ make the feature harder to read.
54
50
  ## 1. Keep the page contract, make the implementation thin
55
51
 
56
52
  Page logic lives in the matching `.setup.ts` file: `config`, `loader`,
57
- `register`, and `action`/`actions`. UI files contain the default
58
- component and named components such as `ErrorBoundary`. Pair `layout.tsx` with
53
+ `register`, and `action`/`actions`. The UI file owns its named default
54
+ component; re-export a framework-required named component such as `ErrorBoundary`
55
+ from that component's own file. Pair `layout.tsx` with
59
56
  `layout.setup.ts`, `root.tsx` with `root.setup.ts`, and `error.page.tsx` with
60
57
  `error.setup.ts`. Use type-only imports from setup for loader/action inference.
61
58
  The framework projects browser-safe `register()` code to the client. Put route,
62
59
  cache, validation, middleware, metadata, and sitemap policy inside `config`;
63
- metadata always belongs in `config.metadata`.
60
+ metadata always belongs in `config.metadata`. Do not add an empty setup file
61
+ when a component has no configuration, loader, action, or registration work.
62
+ The starter follows this boundary for root configuration, home route policy
63
+ and translations, and not-found metadata.
64
64
 
65
65
  Use the config type that matches the module. A page can preserve loader data
66
66
  inference for metadata by satisfying `PageConfig<typeof loader>`:
@@ -82,15 +82,18 @@ export const config = {
82
82
  ```
83
83
 
84
84
  ```tsx title="src/web/posts.page.tsx"
85
- export default function PostsPage() {
86
- return <main>Posts</main>;
85
+ import type { PageProps } from "@warlock.js/web";
86
+ import type { loader } from "./posts.setup";
87
+
88
+ export default function PostsPage({ data }: PageProps<typeof loader>) {
89
+ return <main>{data.title}</main>;
87
90
  }
88
91
  ```
89
92
 
90
- When code navigates by a page name, `href(name, params, query)` validates the
91
- published route table at runtime. `href("posts.indxe")` is a string that
92
- TypeScript accepts, then the helper throws with the known route names. Generated
93
- route-name and parameter types are not available in 5.17.
93
+ Use `href(name, params, query)` for named page navigation. Generated declarations
94
+ in `.warlock/typings/web-routes.d.ts` check route names and required path
95
+ parameters when included in the app's TypeScript project; runtime route-table
96
+ validation still applies. Do not widen those contracts to strings or add casts.
94
97
 
95
98
  Layouts use one `config satisfies LayoutConfig` object for `prefix`,
96
99
  `middleware`, `metadata`, and a sitemap default. Roots use one
@@ -133,11 +136,22 @@ queries, aggregations, and domain transformations in the service. A service
133
136
  must have a meaningful domain name and own the operation, not exist as a
134
137
  generic pass-through for a repository method.
135
138
 
136
- Extract substantive view sections into cohesive components. A component
137
- that needs any behavior gets a separate feature-named custom-hook file; a
138
- component with no behavior stays presentational and needs no hook. Split by
139
- responsibility, not by a mechanical line-count limit. Framework exports
140
- for logic remain in the setup file even when their work is delegated elsewhere.
139
+ Give every component one responsibility and one reason to change. Define one
140
+ component per file, with a named default export. Extract substantive view
141
+ sections into cohesive components. Put hooks in `hooks/` and pure UI helpers
142
+ in `utils/`, one hook or helper per file, exported by name. A hook owns state,
143
+ effects, handlers, navigation, and client mutations; a utility has no React or
144
+ browser lifecycle dependency. A presentational component needs no hook.
145
+
146
+ Keep a small piece of logic or a private helper inline only when it is tightly
147
+ coupled to that one component and keeping it there leaves the file small and
148
+ focused. A component file past roughly 150 lines, a second component, or multiple
149
+ helpers is a split signal, not a mechanical line limit: split at ownership
150
+ boundaries, never into arbitrary chunks to meet a count. The one-component rule
151
+ still applies. Framework logic exports remain in setup even when delegated.
152
+ Keep feature CSS beside the feature and import it from the owning presentation
153
+ boundary; keep loading, error, empty, focus, reduced-motion, and locale behavior
154
+ with that boundary.
141
155
 
142
156
  Preserve the framework's rendering boundary: `root.tsx` owns the document,
143
157
  `#vessel` and `<Scripts />`; the loader runs on the server, while the rendered
@@ -153,35 +167,6 @@ output; the page composes `PostArticle`, `PostComments`, and `RelatedPosts`;
153
167
  `use-post-comments.ts` owns subscription, state, and handlers for
154
168
  `PostComments`. Each piece has one reason to change.
155
169
 
156
- ## Setup and component files
157
-
158
- Use a setup file when a feature registers framework behavior. The setup file
159
- owns its `register`, `config`, and `loader` exports; its matching component
160
- only renders the supplied data. Keep loader inference type-only so it does
161
- not pull server work into a browser component:
162
-
163
- The default starter follows this boundary: `root.setup.ts` owns strict-mode
164
- configuration, `home/index.setup.ts` owns route policy, its loader, and the
165
- universal translations `register()`, and `404.setup.ts` owns not-found
166
- metadata. Do not add an empty setup file when a component has no server
167
- configuration, loader, or registration work.
168
-
169
- ```tsx
170
- import type { PageProps } from "@warlock.js/web";
171
- import type { loader } from "./posts.setup";
172
-
173
- export default function PostsPage({ data }: PageProps<typeof loader>) {
174
- return <PostsList posts={data.posts} />;
175
- }
176
- ```
177
-
178
- Each React component file has one named default component. Export hooks,
179
- utilities, and types by name. A component renders; its feature hook owns
180
- state, effects, handlers, navigation, and client mutations. A utility stays
181
- pure and has no React or browser lifecycle dependency. Keep feature CSS beside
182
- the feature and import it from the owning presentation boundary; keep loading,
183
- error, empty, focus, reduced-motion, and locale behavior with that boundary.
184
-
185
170
  ## Declaration spacing and local types
186
171
 
187
172
  Put one blank line after the final import. Keep a short related declaration run
@@ -218,17 +203,17 @@ state starts unset, write `useState<ErrorText>()`; do not add a redundant
218
203
  same validated, public data as the page. Keep private routes out of public
219
204
  discovery, and do not generate database-backed assets on every GET.
220
205
 
221
- ## 3. Components render; custom hooks own all behavior
206
+ ## 3. Components render; custom hooks own behavior
222
207
 
223
- **No behavior in React component bodies, even when it is small.** A
224
- component may declare props, call its dedicated custom hook if needed,
225
- and render JSX from its props and the hook's returned values. Put `useState`,
208
+ **Delegate behavior to the component's dedicated custom hook by default.** A
209
+ component declares props, calls that hook if needed, and renders JSX from its
210
+ props and the hook's returned values. Put `useState`,
226
211
  `useEffect`, subscriptions, timers, browser API calls, derived-value
227
212
  calculations, event handlers, mutations, and navigation decisions in a
228
213
  separate custom-hook file. Keep pure domain calculations in `src/app`
229
214
  services; a hook may call a small pure UI helper outside the component.
230
- Do not put a one-line callback or inline computation in JSX because it
231
- "doesn't warrant a hook". Pure presentational components need no hook.
215
+ Use section 1's inline exception only for small, tightly coupled logic;
216
+ reuse or file growth means it belongs in its own hook or utility file.
232
217
  JSX conditionals that only select which markup to show are presentation,
233
218
  not a license to implement business rules in render.
234
219
 
@@ -244,6 +229,11 @@ Avoid copying props into hook state without a plan for refresh and client
244
229
  navigation. Avoid setting another state variable inside a state-updater
245
230
  callback. Prefer deriving display values from one authoritative state.
246
231
 
232
+ **State ownership.** Loader data is server truth. UI interaction state (open,
233
+ selection, draft input) lives in the component's own hook. Shared client state
234
+ goes only through a named store module, never a global variable or a copy
235
+ spread across components. Never copy props into state without a refresh plan.
236
+
247
237
  Effects own their subscriptions and timers: unsubscribe, cancel, or ignore
248
238
  stale async work on unmount or entity change. A failed script/socket load
249
239
  must not leave a permanently rejected shared promise or silently disable a
@@ -272,6 +262,150 @@ enforce the same policy independently. Use a full navigation when an auth or
272
262
  document boundary requires it, otherwise prefer the framework's navigation
273
263
  and refresh primitives so state and loader data stay in sync.
274
264
 
265
+ ### Use Mongez React Form for every form
266
+
267
+ Always use `Form`, `useFormControl`, and `useSubmitButton` from
268
+ `@mongez/react-form`; the web stack already uses this package, including the
269
+ starter's contact form. Do not add a second form library or manage field
270
+ validation and submission state in parallel React state. Keep those hooks in
271
+ the control's and submit button's dedicated hook files, following section 1.
272
+
273
+ Declare validation in a browser-safe `<Form schema={schema}>` (Seal implements
274
+ Standard Schema), a control's `schema`, or `useFormControl({ rules: [...] })`.
275
+ The `rules` array contains imported rule objects, never rule-name strings.
276
+ For example, `{ name: "email", type: "email", required: true,
277
+ rules: [requiredRule, emailRule] }` uses Mongez's rules: `requiredRule` needs
278
+ `required: true`, and `emailRule` needs the control's `type: "email"`.
279
+ Put `requiredRule` first. Schema issues map back to registered control names,
280
+ so keep schema paths and control names aligned.
281
+
282
+ Never use `required`, `pattern`, `min`, `max`, `minLength`, or `type="email"`
283
+ as browser validation, or rely on the browser's constraint UI. These may be
284
+ Mongez rule configuration props; do not spread raw control props onto DOM
285
+ inputs. Mongez v4's `Form` renders `noValidate` by default; keep it enabled
286
+ and preserve it if using a custom form host. A schema-backed email field can
287
+ render `type="text"` with `inputMode="email"` and `autoComplete="email"`,
288
+ leaving email validation entirely to the form.
289
+
290
+ Render the control's `error` through `getErrorProps()` and connect the field
291
+ with `getInputProps()` (or equivalent explicit wiring for a custom widget).
292
+ This keeps field identity, focus, blur validation, and error announcement in
293
+ the same form contract. For example, each block below is its own file:
294
+
295
+ ```ts title="src/web/contact/contact.schema.ts"
296
+ import { v } from "@warlock.js/seal";
297
+
298
+ export const contactSchema = v.object({ email: v.email() });
299
+ ```
300
+
301
+ ```ts title="src/web/contact/hooks/use-contact-email.ts"
302
+ import { useFormControl } from "@mongez/react-form";
303
+
304
+ export function useContactEmail() {
305
+ return useFormControl({ name: "email" });
306
+ }
307
+ ```
308
+
309
+ ```tsx title="src/web/contact/components/contact-email.tsx"
310
+ import { useContactEmail } from "../hooks/use-contact-email";
311
+
312
+ export default function ContactEmail() {
313
+ const { id, error, getInputProps, getErrorProps } = useContactEmail();
314
+
315
+ return (
316
+ <div>
317
+ <label htmlFor={id}>Email</label>
318
+ <input {...getInputProps({ type: "text", inputMode: "email", autoComplete: "email" })} />
319
+ {error ? <span {...getErrorProps()}>{error}</span> : null}
320
+ </div>
321
+ );
322
+ }
323
+ ```
324
+
325
+ Render `ContactEmail` inside `<Form schema={contactSchema} onSubmit={submit}>`;
326
+ the feature hook owns `submit`. The submit button's hook calls
327
+ `useSubmitButton()` and exposes `disabled` and `isSubmitting` to its component.
328
+ For API mutations, use `useSubmitForm` from `@warlock.js/web/form` in the
329
+ feature hook. With generated route declarations and a handler's `responseSchema`,
330
+ `useSubmitForm({ route })` infers the declared success body; `ApiResponse` from
331
+ `@warlock.js/web` reads a declared status body. Do not replace this inference
332
+ with casts. Server endpoints must still validate input independently.
333
+
334
+ ### Use the shared design-system controls
335
+
336
+ For Tailwind + shadcn projects, use the controls in `src/web/design-system/`:
337
+ TextInput, EmailInput, PasswordInput, TextareaInput, SelectInput (search),
338
+ MultiSelectInput, DatePickerInput, TimePickerInput, DateTimePickerInput,
339
+ CheckboxInput, RadioInput, SwitchInput, FileInput, and SubmitButton.
340
+ Import each component directly, for example
341
+ `import TextInput from "web/design-system/components/text-input";` — no barrels.
342
+ Use `Form` from `@mongez/react-form` directly. Every control requires a stable
343
+ explicit `id`, `name`, and visible `label`, and accepts `hint`, `error`,
344
+ Mongez `schema`/`rules`, and `validateOn`. Put behavior in design-system hooks
345
+ and props in design-system types. See the Web `build-accessible-controls`
346
+ topic for keyboard/focus, RTL, reset and calendar-date semantics.
347
+
348
+ ```tsx
349
+ import { Form } from "@mongez/react-form";
350
+ import TextInput from "web/design-system/components/text-input";
351
+ import SubmitButton from "web/design-system/components/submit-button";
352
+
353
+ export default function ProfileForm() {
354
+ return (
355
+ <Form>
356
+ <TextInput id="profile-name" name="name" label="Name" hint="Display name" />
357
+ <SubmitButton id="profile-save" name="save" label="Save" />
358
+ </Form>
359
+ );
360
+ }
361
+ ```
362
+
363
+ The full-stack preset and shadcn selection scaffold these controls. Existing
364
+ Tailwind apps can run `npx warlock add design-system`; install Tailwind first
365
+ with `npx warlock add tailwind` when missing. Existing files are preserved.
366
+
367
+ ### Build custom controls with the project's UI kit
368
+
369
+ Do not render native `<select>`, `<input type="checkbox">`,
370
+ `<input type="radio">`, or `<input type="date">` UI. Build a searchable
371
+ select/dropdown with a search input, a custom checkbox, custom radio
372
+ controls, and a custom date picker. Each field uses `useFormControl` through
373
+ its dedicated hook; a radio group registers one selected value for the group.
374
+ Connect value/checked changes, blur, disabled state, focus refs, validation,
375
+ and errors to that control. Configure a checkbox control with
376
+ `type: "checkbox"` and wire `checked` / `setChecked`. Adapt other widget
377
+ callbacks to `changeValue`; do not blindly spread DOM input props onto a kit
378
+ component with a different callback contract. Keep search text and popup state separate from
379
+ the selected form value. A kit's hidden input for submission is acceptable;
380
+ it must not display native UI or become a second validation source.
381
+
382
+ Check the existing styling stack and UI kit before building controls:
383
+
384
+ - With Tailwind and shadcn/ui already installed, compose its components:
385
+ Command + Popover for a searchable select, Checkbox, RadioGroup, and
386
+ Calendar + Popover for dates. Keep Mongez as the form owner rather than
387
+ copying a kit example that introduces another form library.
388
+ - With Tailwind but no shadcn/ui, or with no UI kit, ask the user which kit
389
+ to use **before installing anything**. Recommend shadcn/ui for Tailwind
390
+ projects because its composable accessible primitives fit the styling
391
+ stack. Explain the recommendation for other stacks and wait for the
392
+ user's choice; never silently add dependencies.
393
+ - With another established kit and no Tailwind, use its corresponding
394
+ accessible primitives and the same Mongez adapters.
395
+
396
+ Custom controls must preserve the accessibility native controls supplied.
397
+ Follow the WAI-ARIA APG [combobox/listbox](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/),
398
+ [checkbox](https://www.w3.org/WAI/ARIA/apg/patterns/checkbox/),
399
+ [radiogroup](https://www.w3.org/WAI/ARIA/apg/patterns/radio/), and
400
+ [date picker dialog/grid](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/examples/datepicker-dialog/)
401
+ patterns. Preserve labels, visible focus, disabled/selected/checked states,
402
+ `aria-invalid`, and error descriptions linked to `getErrorProps()` output.
403
+ Support keyboard search and arrow navigation, Enter selection, Space toggling,
404
+ and Escape dismissal as appropriate. Manage popup entry and focus return;
405
+ a modal date dialog traps focus and its calendar grid supports day/month
406
+ keyboard navigation. Kit composition still needs correct labels, focus refs,
407
+ and announced errors; styling alone does not make a control accessible.
408
+
275
409
  ## 5. Ship usable, localizable presentation
276
410
 
277
411
  - Use semantic landmarks, headings, buttons, links, and explicit form labels.
@@ -311,12 +445,13 @@ Before approving a web change, ask:
311
445
  effects without untangling unrelated responsibilities?
312
446
  2. Does every loader obtain data and business decisions from a named app
313
447
  service, with models/repositories kept out of the web layer?
314
- 3. Does each behavioral component delegate all state, effects, derived
315
- values, event handlers, and mutations to its own custom hook file?
448
+ 3. Does each component have one responsibility and its own file, with hooks
449
+ and helpers extracted except for the small, tightly coupled inline case?
316
450
  4. Is the service query bounded, the resource shape intentional, and the
317
451
  cache scope safe?
318
452
  5. Do refresh, navigation, errors, retries, and cleanup leave the UI correct?
319
- 6. Are forms and interactions accessible and translated to the degree the
320
- interface promises?
453
+ 6. Do forms use Mongez schema/rules and accessible custom controls, with
454
+ native validation disabled and the UI kit choice agreed before installation?
455
+ Are interactions translated to the degree the interface promises?
321
456
  7. Are the relevant quality rules enforced or covered by focused tests,
322
457
  rather than described only in comments?
@@ -23,3 +23,9 @@ forgotPasswordController.validation = {
23
23
  email: v.email(),
24
24
  }),
25
25
  };
26
+
27
+ forgotPasswordController.responseSchema = {
28
+ 200: { body: { message: "string" } },
29
+ 422: { body: { error: { code: "string", message: "string" }, requestId: "string" } },
30
+ 500: { body: { error: { code: "string", message: "string" }, requestId: "string" } },
31
+ };
@@ -1,4 +1,5 @@
1
1
  import { type Request, type RequestHandler } from "@warlock.js/core";
2
+ import { UserResource } from "app/users/resources/user.resource";
2
3
  import { loginSchema, type LoginSchema } from "../schema/login.schema";
3
4
  import { loginService } from "../services/auth.service";
4
5
 
@@ -23,3 +24,19 @@ loginController.description = "User Login";
23
24
  loginController.validation = {
24
25
  schema: loginSchema,
25
26
  };
27
+
28
+ // The starter disables refresh tokens; add tokens.refreshToken when enabling them.
29
+ loginController.responseSchema = {
30
+ 200: {
31
+ body: {
32
+ user: UserResource,
33
+ tokens: {
34
+ accessToken: { token: "string", expiresAt: "string" },
35
+ },
36
+ },
37
+ },
38
+ 401: { body: { error: { code: "string", message: "string" }, requestId: "string" } },
39
+ 403: { body: { error: { code: "string", message: "string" }, requestId: "string" } },
40
+ 422: { body: { error: { code: "string", message: "string" }, requestId: "string" } },
41
+ 500: { body: { error: { code: "string", message: "string" }, requestId: "string" } },
42
+ };
@@ -17,3 +17,9 @@ export const logoutAllController: GuardedRequestHandler = async ({ request, resp
17
17
  };
18
18
 
19
19
  logoutAllController.description = "Logout from all devices";
20
+
21
+ logoutAllController.responseSchema = {
22
+ 200: { body: { message: "string" } },
23
+ 401: { body: { error: { code: "string", message: "string" }, requestId: "string" } },
24
+ 500: { body: { error: { code: "string", message: "string" }, requestId: "string" } },
25
+ };
@@ -19,3 +19,9 @@ export const logoutController: GuardedRequestHandler = async ({ request, respons
19
19
  };
20
20
 
21
21
  logoutController.description = "User Logout";
22
+
23
+ logoutController.responseSchema = {
24
+ 200: { body: { message: "string" } },
25
+ 401: { body: { error: { code: "string", message: "string" }, requestId: "string" } },
26
+ 500: { body: { error: { code: "string", message: "string" }, requestId: "string" } },
27
+ };
@@ -1,4 +1,5 @@
1
1
  import { type GuardedRequestHandler } from "app/auth/requests/guarded.request";
2
+ import { UserResource } from "app/users/resources/user.resource";
2
3
 
3
4
  /**
4
5
  * Get current user controller
@@ -16,3 +17,9 @@ export const meController: GuardedRequestHandler = async ({ request, response })
16
17
  };
17
18
 
18
19
  meController.description = "Get Current User";
20
+
21
+ meController.responseSchema = {
22
+ 200: { body: { user: UserResource } },
23
+ 401: { body: { error: { code: "string", message: "string" }, requestId: "string" } },
24
+ 500: { body: { error: { code: "string", message: "string" }, requestId: "string" } },
25
+ };
@@ -24,3 +24,15 @@ refreshTokenController.validation = {
24
24
  refreshToken: v.string(),
25
25
  }),
26
26
  };
27
+
28
+ refreshTokenController.responseSchema = {
29
+ 200: {
30
+ body: {
31
+ accessToken: { token: "string", expiresAt: "string" },
32
+ refreshToken: { token: "string", expiresAt: "string" },
33
+ },
34
+ },
35
+ 401: { body: { error: { code: "string", message: "string" }, requestId: "string" } },
36
+ 422: { body: { error: { code: "string", message: "string" }, requestId: "string" } },
37
+ 500: { body: { error: { code: "string", message: "string" }, requestId: "string" } },
38
+ };
@@ -21,3 +21,9 @@ resetPasswordController.description = "Reset password with OTP";
21
21
  resetPasswordController.validation = {
22
22
  schema: resetPasswordSchema,
23
23
  };
24
+
25
+ resetPasswordController.responseSchema = {
26
+ 200: { body: { message: "string" } },
27
+ 422: { body: { error: { code: "string", message: "string" }, requestId: "string" } },
28
+ 500: { body: { error: { code: "string", message: "string" }, requestId: "string" } },
29
+ };
@@ -33,9 +33,15 @@ export class OTP extends Model<OTPSchema> {
33
33
  * Check if OTP is valid (not expired, not used, not max attempts)
34
34
  */
35
35
  public get isValid(): boolean {
36
- if (this.get("usedAt")) return false;
37
- if ((this.get("attempts") ?? 0) >= (this.get("maxAttempts") ?? 0)) return false;
38
- if (new Date() > new Date(this.get("expiresAt"))) return false;
36
+ if (this.get("usedAt")) {
37
+ return false;
38
+ }
39
+ if ((this.get("attempts") ?? 0) >= (this.get("maxAttempts") ?? 0)) {
40
+ return false;
41
+ }
42
+ if (new Date() > new Date(this.get("expiresAt"))) {
43
+ return false;
44
+ }
39
45
  return true;
40
46
  }
41
47
 
@@ -19,12 +19,24 @@ const DEFAULT_OTP_EXPIRATION: Duration = { minutes: 15 };
19
19
  // Parse duration to milliseconds
20
20
  function parseDurationToMs(duration: Duration): number {
21
21
  let ms = 0;
22
- if (duration.milliseconds) ms += duration.milliseconds;
23
- if (duration.seconds) ms += duration.seconds * 1000;
24
- if (duration.minutes) ms += duration.minutes * 60 * 1000;
25
- if (duration.hours) ms += duration.hours * 60 * 60 * 1000;
26
- if (duration.days) ms += duration.days * 24 * 60 * 60 * 1000;
27
- if (duration.weeks) ms += duration.weeks * 7 * 24 * 60 * 60 * 1000;
22
+ if (duration.milliseconds) {
23
+ ms += duration.milliseconds;
24
+ }
25
+ if (duration.seconds) {
26
+ ms += duration.seconds * 1000;
27
+ }
28
+ if (duration.minutes) {
29
+ ms += duration.minutes * 60 * 1000;
30
+ }
31
+ if (duration.hours) {
32
+ ms += duration.hours * 60 * 60 * 1000;
33
+ }
34
+ if (duration.days) {
35
+ ms += duration.days * 24 * 60 * 60 * 1000;
36
+ }
37
+ if (duration.weeks) {
38
+ ms += duration.weeks * 7 * 24 * 60 * 60 * 1000;
39
+ }
28
40
  return ms;
29
41
  }
30
42
 
@@ -19,3 +19,9 @@ export const contactController: RequestHandler<Request<ContactSchema>> = async (
19
19
  };
20
20
 
21
21
  contactController.validation = { schema: contactSchema };
22
+
23
+ contactController.responseSchema = {
24
+ 200: { body: { message: "string" } },
25
+ 422: { body: { error: { code: "string", message: "string" }, requestId: "string" } },
26
+ 500: { body: { error: { code: "string", message: "string" }, requestId: "string" } },
27
+ };
@@ -10,3 +10,7 @@ export const homePageController: RequestHandler = async ({ response }) => {
10
10
  };
11
11
 
12
12
  homePageController.description = "Welcome Home Page";
13
+
14
+ homePageController.responseSchema = {
15
+ 200: { body: { message: "string", version: "string", docs: "string" } },
16
+ };
@@ -14,3 +14,9 @@ export const localeController: RequestHandler<Request<LocaleSchema>> = async ({
14
14
  };
15
15
 
16
16
  localeController.validation = { schema: localeSchema };
17
+
18
+ localeController.responseSchema = {
19
+ 200: { body: { locale: "string" } },
20
+ 422: { body: { error: { code: "string", message: "string" }, requestId: "string" } },
21
+ 500: { body: { error: { code: "string", message: "string" }, requestId: "string" } },
22
+ };
@@ -1,4 +1,5 @@
1
1
  import { type GuardedRequestHandler } from "app/auth/requests/guarded.request";
2
+ import { PostResource } from "app/posts/resources/post.resource";
2
3
  import { Post } from "../models/post/post.model";
3
4
  import { createPostSchema, type CreatePostSchema } from "../schema/create-post.schema";
4
5
 
@@ -19,3 +20,9 @@ export const createNewPostController: GuardedRequestHandler<CreatePostSchema> =
19
20
  createNewPostController.validation = {
20
21
  schema: createPostSchema,
21
22
  };
23
+
24
+ createNewPostController.responseSchema = {
25
+ 201: { body: { post: PostResource } },
26
+ 422: { body: { error: { code: "string", message: "string" }, requestId: "string" } },
27
+ 500: { body: { error: { code: "string", message: "string" }, requestId: "string" } },
28
+ };
@@ -1,4 +1,7 @@
1
+ import { ResourceNotFoundError } from "@warlock.js/core";
2
+ import { v } from "@warlock.js/seal";
1
3
  import { type GuardedRequestHandler } from "app/auth/requests/guarded.request";
4
+ import { PostResource } from "app/posts/resources/post.resource";
2
5
  import { Post } from "../models/post/post.model";
3
6
  import { updatePostSchema, type UpdatePostSchema } from "../schema/update-post.schema";
4
7
 
@@ -6,19 +9,14 @@ export const updatePostController: GuardedRequestHandler<UpdatePostSchema> = asy
6
9
  request,
7
10
  response,
8
11
  }) => {
9
- const id = request.int("id");
10
-
11
- if (!id) {
12
- return response.notFound();
13
- }
14
-
15
- const post = await Post.find(id);
12
+ const { id, ...input } = request.validated();
13
+ const post = await Post.first({ publicId: id });
16
14
 
17
15
  if (!post) {
18
- return response.notFound();
16
+ throw new ResourceNotFoundError("Post not found.");
19
17
  }
20
18
 
21
- await post.save({ merge: request.validated() });
19
+ await post.save({ merge: input });
22
20
 
23
21
  return response.success({
24
22
  post,
@@ -27,4 +25,12 @@ export const updatePostController: GuardedRequestHandler<UpdatePostSchema> = asy
27
25
 
28
26
  updatePostController.validation = {
29
27
  schema: updatePostSchema,
28
+ params: v.object({ id: v.string().uuid() }),
29
+ };
30
+
31
+ updatePostController.responseSchema = {
32
+ 200: { body: { post: PostResource } },
33
+ 422: { body: { error: { code: "string", message: "string" }, requestId: "string" } },
34
+ 500: { body: { error: { code: "string", message: "string" }, requestId: "string" } },
35
+ 404: { body: { error: { code: "string", message: "string" }, requestId: "string" } },
30
36
  };
@@ -2,6 +2,7 @@ import { integer, Migration, string, text, timestamp } from "@warlock.js/cascade
2
2
  import { Post } from "../post.model";
3
3
 
4
4
  export default Migration.create(Post, {
5
+ publicId: string(36).unique(),
5
6
  title: string(255),
6
7
  description: text(),
7
8
  slug: string(255).unique(),
@@ -1,9 +1,15 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { Model, RegisterModel } from "@warlock.js/cascade";
2
3
  import { useComputedSlug } from "@warlock.js/core";
3
4
  import { v, type Infer } from "@warlock.js/seal";
4
5
  import { PostResource } from "app/posts/resources/post.resource";
5
6
 
6
7
  export const postSchema = v.object({
8
+ publicId: v
9
+ .string()
10
+ .uuid()
11
+ .default(() => randomUUID()),
12
+ authorId: v.number(),
7
13
  title: v.string(),
8
14
  description: v.string(),
9
15
  slug: v.computed(useComputedSlug()),
@@ -2,7 +2,7 @@ import { defineResource } from "@warlock.js/core";
2
2
 
3
3
  export const PostResource = defineResource({
4
4
  schema: {
5
- id: "number",
5
+ id: ["publicId", "string"],
6
6
  slug: "string",
7
7
  title: "string",
8
8
  description: "string",
@@ -1,6 +1,7 @@
1
1
  import { v, type Infer } from "@warlock.js/seal";
2
2
 
3
3
  export const updatePostSchema = v.object({
4
+ id: v.string().uuid(),
4
5
  title: v.string(),
5
6
  description: v.string(),
6
7
  image: v.file().image().maxSize({ size: 2, unit: "MB" }).saveTo("posts"),