create-hozu 0.6.0 → 0.7.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-hozu",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Create a Hozu app, set up for Claude Code or for agents that read AGENTS.md",
5
5
  "keywords": [
6
6
  "hozu",
package/skill/SKILL.md CHANGED
@@ -20,12 +20,14 @@ Hozu is not in your training data; this file and `hozu docs <topic>` are the who
20
20
  Methods on data (`.map`, `.toUpperCase()`…) are not: use `ui.each` for lists and a `fn()` for computation.
21
21
  - A mutation runs when the machine **enters** a state whose `invoke` calls it; that state drops other events, and
22
22
  `done` / `failed` leave it. Contracts are needed only where a transition decides (a guard, `navigate`, a `fn`).
23
+ - A filter in the URL starts the machine: `seed: ({ search }) => ({ q: search.q })` on the view, then read `ctx.q`.
24
+ `machine({ on })` holds transitions every idle state shares; `fn` bodies may call helpers from the same module.
23
25
 
24
26
  ## Files and commands
25
27
  ```
26
28
  hozu.config.ts project({ schema, site, routes, pages, features }) routes.ts route() declarations
27
- features/<name>/model.ts schemas, events, effects, machine views.ts views, contracts, feature()
28
- features/<name>/server.ts implement(...) resolvers serve.ts createServer(...)
29
+ features/<name>/model.ts schemas, events, effects, fns, machine views.ts views, contracts
30
+ features/<name>/feature.ts feature({ declarations: [model, views] }) server.ts implement(...) resolvers
29
31
  ```
30
32
  ```
31
33
  npx hozu check # after every edit: types, rules, contracts
@@ -84,10 +86,12 @@ export const Board = ui.view({
84
86
  }),
85
87
  ]),
86
88
  })
87
- export const todos = feature({ id: 'todos', intent: { summary: 'A to-do list' },
88
- declarations: { Add, itemsTag, listItems, addItem, items, Board } })
89
+ // feature.ts
90
+ import * as model from './model.ts'
91
+ import * as views from './views.ts'
92
+ export const todos = feature({ id: 'todos', intent: { summary: 'A to-do list' }, declarations: [model, views] })
89
93
  ```
90
- Every declaration goes in `declarations` once, under its name. Resolvers:
94
+ Every declaration a listed module exports is registered under its name; schemas and helpers are ignored. Resolvers:
91
95
  `implement(addItem, ({ title }, { fail }) => exists ? fail('Duplicate', { title }) : save(title))`.
92
96
 
93
97
  ## Topics (`hozu docs <topic>`)
@@ -100,7 +104,8 @@ Every declaration goes in `declarations` once, under its name. Resolvers:
100
104
  | routes, params, search, pages, `head`, 404, sitemap | `pages` |
101
105
  | forms without JS, field errors, selects | `forms` |
102
106
  | sign-in, sessions, per-user data | `auth` |
103
- | common UI: filters, empty states, per-item actions, load more, optimistic | `patterns` |
107
+ | common UI: filters, search in the URL, modes, per-item actions, load more | `patterns` |
108
+ | worked changes: enum field, bulk action, detail field / page | `recipes` |
104
109
  | webhooks and JSON APIs | `endpoints` |
105
110
  | browser APIs and DOM libraries (maps, charts) | `widgets` |
106
111
  | languages, env, HTTP, Markdown, images, preview, PWA, tests, deployment | `i18n`, `env`, `http`, `content`, `testing`, `deploy` |
package/skill/changing.md CHANGED
@@ -5,65 +5,19 @@ Keep the loop short: map once, edit everything, check once, verify once.
5
5
  ## 1. Read
6
6
  - The change request.
7
7
  - `pnpm exec hozu map`: every route, query, mutation, event, state, view and contract, each with its `file:line`.
8
- Open only the lines the change touches. Below, *model* is where the app keeps schemas, events, effects and
9
- the machine (`model.ts`), and *views* where it keeps views, contracts and `feature()`.
10
- - The API is in `SKILL.md`. Use a recipe below when one fits; run `hozu docs <topic>` only for
11
- something else.
8
+ Open only the lines the change touches. *model* is `model.ts` (schemas, events, effects, `fn`s, the machine),
9
+ *views* is `views.ts` (views, contracts); `feature.ts` lists both modules, so new exports need no registration.
10
+ - The API is in `SKILL.md`. `hozu docs recipes` has worked steps for an enum field in the add form, a bulk action
11
+ button, a detail field and a detail page; `hozu docs <topic>` for anything else.
12
12
 
13
- ## 2. Recipes
14
- Names follow `hozu add feature items`: `Item`, `NewItem`, `Add`, `addItem`, `itemsMachine`, `ItemsBoard`.
15
-
16
- ### A field chosen in the add form (an enum)
17
- - **model:**
18
- - `export const Priority = z.enum(['low', 'normal', 'high'])`;
19
- - add `priority: Priority` to `Item`, `NewItem` and the `Add` payload;
20
- - context: `priority: Priority`, with `priority: 'normal'` in `initialContext`;
21
- - `fields` gets `priority: z.string().nullable()`, with `priority: null` in `initialContext` and in the `Add`
22
- assign that resets it;
23
- - the `Add` assign also gets `ctx.priority = e.priority`, and the add `invoke` input becomes
24
- `{ title: ctx.draft, priority: ctx.priority }`.
25
- - **views:**
26
- - the form's submit sends `{ title: ui.dom.form('title'), priority: ui.dom.form('priority') }`;
27
- - inside the form add
28
- `ui.select({ name: 'priority', 'aria-label': 'Priority', class: 'rounded border px-2' }, ['low', 'normal', 'high'].map((p) => ui.option({ value: p, selected: p === 'normal' }, [p])))`;
29
- - in the item: `ui.span({ class: 'text-xs' }, [item.priority])`.
30
- - **Contracts:** if the app has contracts that send `Add` or return an item, add `priority` to their payloads,
31
- inputs and results. These transitions only copy values, so they need no new contract.
32
- - **server:** store `priority` (seed items included) and return it.
33
-
34
- ### An action button that works on many items (e.g. "Clear done")
35
- - **model:**
36
- - `export const ClearDone = event({ payload: z.object({}) })`;
37
- - `export const clearDone = mutation({ input: z.object({}), output: z.object({ removed: z.number() }), invalidates: () => [itemsTag()] })`;
38
- - in `idle`: `on(ClearDone, { target: 'clearing', assign: () => { ctx.error = null } })`;
39
- - a state
40
- `clearing: { invoke: invoke(clearDone, { input: {}, done: 'idle', failed: { Unexpected: { target: 'idle', assign: (e) => { ctx.error = e.message } } } }) }`
41
- (busy states drop events they do not handle, so no `ignore`).
42
- - **views:** the control
43
- `ui.form({ on: { submit: ui.send(ClearDone, {}) } }, [ui.button({ type: 'submit', class: 'text-sm underline' }, ['Clear done'])])`.
44
- - Add `ClearDone` and `clearDone` to `declarations`. The new transitions only copy values, so they need no contract.
45
- - **server:**
46
- `implement(clearDone, () => { const before = items.length; items.splice(0, items.length, ...items.filter((i) => !i.done)); return { removed: before - items.length } })`.
47
- - **Try it:** `hozu post / --button 'Clear done' --next /`.
48
-
49
- ### A field shown on the detail page
50
- In the detail view's `ready`: `ui.p({}, ['Priority: ', item.priority])`. The detail query already returns the whole
51
- item.
52
-
53
- ### A detail page, when the feature has none
54
- Run a fresh scaffold into a scratch app with `--with detail`, and copy the parts it prints:
55
- - the route with params;
56
- - the `get` query and its resolver;
57
- - the detail view;
58
- - the link in the list;
59
- - `ui.page(...)` with `head` and `entries`.
60
-
61
- ### Other changes
13
+ ## 2. What to touch
62
14
  | Change | Touch |
63
15
  |---|---|
64
- | New UI-only state (a tab) | model: the context field and its initial value, an event, an `on` whose `assign` sets it → views: the control, the event in `declarations` (no contract: it only copies a value). |
65
- | Filter / sort / page in the URL | the route's `search` schema (with a default) → links with `ui.link(route, params, { key: value })` → read `search.key` in the view. No machine change. |
66
- | New page | `routes.ts` → a view with `route`, in `declarations` → `ui.page(...)` in `hozu.config.ts` (`head`, and `entries` when the route has params). |
16
+ | New UI-only state (a tab) | model: the context field and its initial value, an event, an `on` whose `assign` sets it → views: the control (no contract: it only copies a value). |
17
+ | Filter / sort / page in the URL | the route's `search` schema (with a default) → links with `ui.link(route, params, { key: value })` → read `search.key` in the view. If the page also filters as you type, `seed: ({ search }) => ({ key: search.key })` on the view and read `ctx.key` only. |
18
+ | A per-item action stored on the server (pin, archive, star) | model: a field on the item, an event, a mutation that `invalidates` the list tag, `on(Event, { target: 'pinning', assign: (e) => { ctx.target = e.id } })` and a state with `invoke` → views: the per-item form from `hozu docs patterns` → server: store it and sort in the list resolver. These transitions only copy values: no contract. |
19
+ | A control that works in every mode | `machine({ on: [...] })` instead of repeating it per state. |
20
+ | New page | `routes.ts` → a view with `route` in `views.ts` → `ui.page(...)` in `hozu.config.ts` (`head`, and `entries` when the route has params). |
67
21
 
68
22
  Whenever the machine changes:
69
23
  - A transition that decides something (a guard, `navigate`, or a `fn` in its values) needs a contract; HZ016
@@ -0,0 +1,13 @@
1
+ import { feature } from '@hozu/core'
2
+ import * as model from './model.ts'
3
+ import * as views from './views.ts'
4
+
5
+ export const bookmarks = feature({
6
+ id: 'bookmarks',
7
+ intent: {
8
+ summary:
9
+ 'A shared reading list: add bookmarks with a kind, mark them read, filter unread, one page each.',
10
+ invariants: ['Titles are unique, case-insensitive', 'New bookmarks are listed first'],
11
+ },
12
+ declarations: [model, views],
13
+ })
@@ -1,10 +1,9 @@
1
- import { contract, feature, ui } from '@hozu/core'
1
+ import { contract, ui } from '@hozu/core'
2
2
  import { bookmarkPage, home } from '../../routes.ts'
3
3
  import {
4
4
  Add,
5
5
  addBookmark,
6
6
  bookmarksMachine,
7
- bookmarksTag,
8
7
  Draft,
9
8
  DUPLICATE,
10
9
  getBookmark,
@@ -210,35 +209,3 @@ export const toggleFails = contract(bookmarksMachine, {
210
209
  when: [{ failed: toggleRead, error: 'Unexpected', data: { message: 'offline' } }],
211
210
  expect: { state: 'idle', changes: { error: 'offline' } },
212
211
  })
213
-
214
- export const bookmarks = feature({
215
- id: 'bookmarks',
216
- intent: {
217
- summary:
218
- 'A shared reading list: add bookmarks with a kind, mark them read, filter unread, one page each.',
219
- invariants: ['Titles are unique, case-insensitive', 'New bookmarks are listed first'],
220
- },
221
- declarations: {
222
- bookmarksTag,
223
- Draft,
224
- Add,
225
- ToggleRead,
226
- listBookmarks,
227
- getBookmark,
228
- addBookmark,
229
- toggleRead,
230
- visible,
231
- isEmpty,
232
- bookmarksMachine,
233
- Board,
234
- Detail,
235
- typesDraft,
236
- addsBookmark,
237
- rejectsDuplicate,
238
- rejectsInvalidTitle,
239
- addFails,
240
- togglesRead,
241
- toggleMissing,
242
- toggleFails,
243
- },
244
- })
@@ -1,7 +1,8 @@
1
1
  import { project, ui } from '@hozu/core'
2
2
  import { zodAdapter } from '@hozu/schema-zod'
3
+ import { bookmarks } from './features/bookmarks/feature.ts'
3
4
  import { getBookmark, listBookmarks } from './features/bookmarks/model.ts'
4
- import { Board, bookmarks, Detail } from './features/bookmarks/views.ts'
5
+ import { Board, Detail } from './features/bookmarks/views.ts'
5
6
  import { bookmarkPage, home } from './routes.ts'
6
7
 
7
8
  export default project({
@@ -5,7 +5,7 @@ missing one, ready to paste). Transitions that only copy values need none: `hozu
5
5
  in readable form, and a change shows as HZ018 `was: … now: …` until `hozu check --update-lock` accepts it.
6
6
  ```ts
7
7
  export const addsValid = contract(m, {
8
- given: { state: 'idle' }, // context defaults to initialContext
8
+ given: { state: 'idle' }, // context: initialContext; { touring: true } overrides fields
9
9
  when: [
10
10
  { send: Add, payload: { title: 'Milk' } },
11
11
  { done: addItem, result: { id: 'i9', title: 'Milk', done: false } },
@@ -17,5 +17,5 @@ export const addsValid = contract(m, {
17
17
  },
18
18
  })
19
19
  ```
20
- Add contracts to the feature's `declarations`. When a contract fails (HZ015), decide which is intended — the
20
+ Export contracts from `views.ts` (or any module the feature lists). When a contract fails (HZ015), decide which is intended — the
21
21
  machine or the contract — before changing either.
@@ -15,12 +15,14 @@ export const addItem = mutation({
15
15
  errors: { Duplicate: z.object({ title: z.string() }) }, // optional: declared failures
16
16
  invalidates: () => [itemsTag()], // refreshes queries with these tags
17
17
  })
18
- export const visible = fn({ // computation: pure JS, self-contained (no imports, no helpers outside impl: HZ047)
18
+ export const visible = fn({ // computation: pure JS; may call const/function helpers of this module
19
19
  input: z.object({ items: z.array(Item), show: Show }), output: z.array(Item),
20
20
  impl: ({ items, show }) => items.filter((i) => show === 'all' || !i.done),
21
21
  })
22
22
  ```
23
23
  - Call a `fn` from views or machines with data: `ui.each(visible({ items, show: ctx.show }), 'id', …)`.
24
+ - A `fn` body may call functions and JSON constants declared in the same module; they are sent to the browser with
25
+ it. Imported names and `let` state are not (HZ047): pass them as input.
24
26
  - Rendering is derived: `scope` and `freshness` decide static, ISR, SWR, streamed or client rendering;
25
27
  `scope: 'user'` data never reaches a cached page (HZ022). A mutation's tags can read only its input.
26
28
  - **Resolvers** (`server.ts`, or `features/<name>/server.ts` from the scaffold):
@@ -7,7 +7,7 @@ around the rule.
7
7
  |---|---|---|
8
8
  | HZ001 | state unreachable | add a transition to it or delete it |
9
9
  | HZ002 | event handled nowhere | handle it in a state or remove it |
10
- | HZ003 / HZ007 | unknown effect / reference | add it to `feature({ declarations })`, or fix the name (the patch suggests one) |
10
+ | HZ003 / HZ007 | unknown effect / reference | export it from a module the feature lists in `declarations`, or fix the name (the patch suggests one) |
11
11
  | HZ004 | a declared error is not handled | add every `failed` key, plus `Unexpected`, in `invoke` and `ui.query` |
12
12
  | HZ005 | a node sends an event in a state (without `invoke`) that does not handle it | `ignore: [Event]` in that state, or show the node only via `when` |
13
13
  | HZ006 | crossing a feature boundary | import the feature and use its `exports` |
@@ -39,5 +39,6 @@ around the rule.
39
39
  | HZ044 | a feature file was loaded without the Hozu transform | run node with `--import @hozu/transform/register` (`npm start` does), or add `hozuTransform()` to Vite / Vitest |
40
40
  | HZ045 | `serve.ts` misses the widget bundle or the session store | add `widgets: await bundleWidgets(build)` / `session: sessionCookie(…)` |
41
41
  | HZ046 | an endpoint path is reserved, has params, or collides with a page, redirect or endpoint | use a static path such as `/api/…` (patch) |
42
- | HZ047 | a `fn` body uses a helper or constant defined outside `impl` (it is sent to the browser as source) | write the helper inside `impl`, or pass the value as input |
42
+ | HZ047 | a `fn` body uses an imported name or `let` state (it is sent to the browser as source) | pass the value as input, or write it as a `const` helper in the module |
43
+ | HZ048 | `seed` names a field the context lacks, has no machine or route, or two views on one page seed a machine | seed top-level context fields, on one view per page |
43
44
  | HZ042 | `site.locales` empty / missing `site.lang` / not a canonical tag, or `ui.alternate` of an undeclared locale | fix the list (`'zh-TW'`, not `'zh_tw'`) |
@@ -2,7 +2,7 @@
2
2
 
3
3
  ```ts
4
4
  export const orderHook = endpoint({ method: 'POST', path: '/api/hooks/order',
5
- input: z.object({ id: z.string() }), output: z.object({ received: z.string() }) }) // in declarations
5
+ input: z.object({ id: z.string() }), output: z.object({ received: z.string() }) }) // exported from model.ts
6
6
  implement(orderHook, ({ id }, { request, session, setSession, env }) => ({ received: id })) // in resolvers
7
7
  ```
8
8
  - GET input comes from the query string, POST input from a JSON or form body; invalid input answers 400
@@ -3,7 +3,7 @@
3
3
  - `site: { lang: 'en', locales: ['en', 'zh-TW'], … }`: every URL gets a locale prefix (`/en/posts/a`); routes and
4
4
  `ui.link` stay locale-free; `/` redirects by `Accept-Language`. `<html lang>`, hreflang and the sitemap are derived.
5
5
  - `export const text = ui.messages('en', { en: { saved: '{count} saved' }, 'zh-TW': { saved: '已儲存 {count} 筆' } })`
6
- in the feature's `declarations`; use `text.title` or `text.saved({ count })` in views and `head.render`. Every locale
6
+ exported from a module the feature lists; use `text.title` or `text.saved({ count })` in views and `head.render`. Every locale
7
7
  needs every key with the same `{placeholders}` (HZ040). Plurals: `'{n, plural, =0 {none} one {# item} other {# items}}'`.
8
8
  - Machines never hold translated text (HZ041): store a code and choose the message in the view.
9
9
  - `ui.format.number(x, { style: 'currency', currency: 'EUR' })`, `ui.format.date(x, { dateStyle: 'medium' })`,
@@ -5,10 +5,10 @@ export const m = machine({
5
5
  context: z.object({ draft: z.string(), error: z.string().nullable(), target: z.string() }),
6
6
  initialContext: { draft: '', error: null, target: '' },
7
7
  initial: 'idle',
8
+ on: ({ ctx }) => [on(Draft, { assign: (e) => { ctx.draft = e.text } })], // shared by every state without invoke
8
9
  states: ({ ctx }) => ({
9
10
  idle: {
10
11
  on: [
11
- on(Draft, { target: 'idle', assign: (e) => { ctx.draft = e.text } }),
12
12
  on(Add, { target: 'adding', guard: (e) => e.title.length >= 2 }), // first matching guard wins
13
13
  on(Add, { target: 'idle', assign: () => { ctx.error = 'Too short' } }),
14
14
  on(Remove, { target: 'removing', assign: (e) => { ctx.target = e.id } }),
@@ -35,6 +35,12 @@ export const m = machine({
35
35
  - **guard** returns a condition: comparisons, `&&`, `||`, `!`, or a boolean `fn()`.
36
36
  - **navigate** sends the browser to `ui.link(route, params, search)` after the transition.
37
37
  - `done` and each `failed` entry take a state name, one transition, or a list of guarded transitions.
38
+ - **Shared transitions:** `machine({ on })` entries are copied into every state that has no `invoke`, is not final,
39
+ and neither handles nor ignores the event itself. Without `target` they stay in the state they fire in; one
40
+ contract covers every copy.
41
+ - **Start from the URL:** a view with a `route` may declare `seed: ({ params, search }) => ({ q: search.q })`; the
42
+ page's machine then starts with those context fields (server render, hydration and no-JS posts alike). One view
43
+ per page may seed a machine (HZ048).
38
44
  - A transition to the same state re-enters it and re-runs its `invoke`: do not handle the busy event in the busy
39
45
  state. Machines never hold translated text (store a code, choose the message in the view).
40
46
  - Events: `export const Add = event({ payload: z.object({ title: z.string() }) })`.
@@ -6,12 +6,13 @@ Each pattern is complete here; there is no need to open other files.
6
6
  `when(['adding'], [ui.p({ 'aria-busy': 'true' }, ['Saving…'])])`. Do not duplicate controls under `when`.
7
7
  - **Optimistic item:** `when(['adding'], [ui.li({ class: 'opacity-50' }, [ctx.draft])])`; leaving the state removes it
8
8
  and the refreshed query shows the real item.
9
- - **Filter and empty state** (in context): two `fn`s over the list:
9
+ - **Filter and empty state** (in context): one helper, two `fn`s over the list:
10
10
  ```ts
11
+ const shows = (i: Item, show: Show) => show === 'all' || (show === 'done') === i.done // sent with the fns
11
12
  export const visible = fn({ input: z.object({ items: z.array(Item), show: Show }), output: z.array(Item),
12
- impl: ({ items, show }) => items.filter((i) => show === 'all' || (show === 'done') === i.done) })
13
+ impl: ({ items, show }) => items.filter((i) => shows(i, show)) })
13
14
  export const isEmpty = fn({ input: z.object({ items: z.array(Item), show: Show }), output: z.boolean(),
14
- impl: ({ items, show }) => !items.some((i) => show === 'all' || (show === 'done') === i.done) })
15
+ impl: ({ items, show }) => !items.some((i) => shows(i, show)) })
15
16
  // view
16
17
  isEmpty({ items, show: ctx.show })
17
18
  ? ui.p({ class: 'text-slate-500' }, ['No items'])
@@ -24,6 +25,17 @@ isEmpty({ items, show: ctx.show })
24
25
  `ui.button({ type: 'button', 'aria-pressed': ctx.show === s.value, on: { click: ui.send(SetShow, { show: s.value }) } }, [s.label])`.
25
26
  - **Filter in the URL** (shareable, no JS): `search` on the route, options as
26
27
  `ui.a({ href: ui.link(home, null, { show: s.value }), 'aria-current': search.show === s.value }, [s.label])`.
28
+ - **In the URL and as you type** (`/?q=park` works without JS, typing filters live): seed the machine from the URL
29
+ and read only the context. A GET form with `name="q"` submits it without JS.
30
+ ```ts
31
+ export const Board = ui.view({ machine: m, route: home, seed: ({ search }) => ({ q: search.q, district: search.district }),
32
+ render: ({ ctx }) => ui.form({ method: 'get' }, [
33
+ ui.input({ type: 'search', name: 'q', 'aria-label': 'Search', value: ctx.q, on: { input: ui.send(Search, { q: ui.dom.value }) } }),
34
+ /* … */ ui.each(visible({ items, q: ctx.q, district: ctx.district }), 'id', (s) => …) ]) })
35
+ ```
36
+ - **A mode with shared controls** (a tour, an edit mode): put what every mode handles the same way in
37
+ `machine({ on: [on(Search, { assign: (e) => { ctx.q = e.q } })] })` (no `target`: stays in its state); each state
38
+ lists only what differs.
27
39
  - **Per-item action** (toggle, pin, delete): each item gets its own small form, so it works without JS:
28
40
  ```ts
29
41
  ui.form({ on: { submit: ui.send(Toggle, { id: ui.dom.form('id') }) } }, [
@@ -0,0 +1,48 @@
1
+ # Recipes for common changes
2
+
3
+ Names follow `hozu add feature items`: `Item`, `NewItem`, `Add`, `addItem`, `itemsMachine`, `ItemsBoard`.
4
+
5
+ ## A field chosen in the add form (an enum)
6
+ - **model:**
7
+ - `export const Priority = z.enum(['low', 'normal', 'high'])`;
8
+ - add `priority: Priority` to `Item`, `NewItem` and the `Add` payload;
9
+ - context: `priority: Priority`, with `priority: 'normal'` in `initialContext`;
10
+ - `fields` gets `priority: z.string().nullable()`, with `priority: null` in `initialContext` and in the `Add`
11
+ assign that resets it;
12
+ - the `Add` assign also gets `ctx.priority = e.priority`, and the add `invoke` input becomes
13
+ `{ title: ctx.draft, priority: ctx.priority }`.
14
+ - **views:**
15
+ - the form's submit sends `{ title: ui.dom.form('title'), priority: ui.dom.form('priority') }`;
16
+ - inside the form add
17
+ `ui.select({ name: 'priority', 'aria-label': 'Priority', class: 'rounded border px-2' }, ['low', 'normal', 'high'].map((p) => ui.option({ value: p, selected: p === 'normal' }, [p])))`;
18
+ - in the item: `ui.span({ class: 'text-xs' }, [item.priority])`.
19
+ - **Contracts:** if the app has contracts that send `Add` or return an item, add `priority` to their payloads,
20
+ inputs and results. These transitions only copy values, so they need no new contract.
21
+ - **server:** store `priority` (seed items included) and return it.
22
+
23
+ ## An action button that works on many items (e.g. "Clear done")
24
+ - **model:**
25
+ - `export const ClearDone = event({ payload: z.object({}) })`;
26
+ - `export const clearDone = mutation({ input: z.object({}), output: z.object({ removed: z.number() }), invalidates: () => [itemsTag()] })`;
27
+ - in `idle`: `on(ClearDone, { target: 'clearing', assign: () => { ctx.error = null } })`;
28
+ - a state
29
+ `clearing: { invoke: invoke(clearDone, { input: {}, done: 'idle', failed: { Unexpected: { target: 'idle', assign: (e) => { ctx.error = e.message } } } }) }`
30
+ (busy states drop events they do not handle, so no `ignore`).
31
+ - **views:** the control
32
+ `ui.form({ on: { submit: ui.send(ClearDone, {}) } }, [ui.button({ type: 'submit', class: 'text-sm underline' }, ['Clear done'])])`.
33
+ - The new transitions only copy values, so they need no contract (the feature lists `model`, so both are registered).
34
+ - **server:**
35
+ `implement(clearDone, () => { const before = items.length; items.splice(0, items.length, ...items.filter((i) => !i.done)); return { removed: before - items.length } })`.
36
+ - **Try it:** `hozu post / --button 'Clear done' --next /`.
37
+
38
+ ## A field shown on the detail page
39
+ In the detail view's `ready`: `ui.p({}, ['Priority: ', item.priority])`. The detail query already returns the whole
40
+ item.
41
+
42
+ ## A detail page, when the feature has none
43
+ Run a fresh scaffold into a scratch app with `--with detail`, and copy the parts it prints:
44
+ - the route with params;
45
+ - the `get` query and its resolver;
46
+ - the detail view;
47
+ - the link in the list;
48
+ - `ui.page(...)` with `head` and `entries`.
@@ -4,6 +4,7 @@
4
4
  export const Board = ui.view({
5
5
  machine: m, // optional: without it, no ctx / when / events, and 0 JS
6
6
  route: home, // optional: render gets { params, search } typed by the route
7
+ seed: ({ search }) => ({ q: search.q }), // optional, with machine + route: context fields from the URL
7
8
  render: ({ ctx, when, params, search, locale }) => ui.main({ class: 'mx-auto max-w-xl' }, [ /* children */ ]),
8
9
  })
9
10
  ```
@@ -27,7 +28,8 @@ export const Board = ui.view({
27
28
  - **Links:** `ui.a({ href: ui.link(itemPage, { id: item.id }) }, [...])`; never a string path (HZ032). The third
28
29
  argument exists only when the route declares `search`: `ui.link(home, null, { show: 'done' })`.
29
30
  - **Data:** `ui.query(listItems, input, { ready: (items) => …, pending: ui.p({}, ['Loading…']), failed: { NotFound:
30
- () => …, Unexpected: () => … } })`; `pending` is optional, `failed` lists every declared error plus `Unexpected`.
31
+ () => …, Unexpected: () => … } })`; `pending` is optional, `failed` lists every declared error plus `Unexpected`; a branch may return `null` to render
32
+ nothing.
31
33
  Server-fetched data is sent with the page and never fetched again; after a mutation, queries whose tags it
32
34
  invalidates refresh in place.
33
35
  - **Also:** `ui.html(post.html)` (trusted HTML from query data only, HZ030), `ui.asset(new URL('./x.png',
@@ -5,7 +5,7 @@ and the `@hozu/bundle` dependency. There is no `widget` export; the pieces are:
5
5
  ```ts
6
6
  export const Map = ui.widget({ tag: 'div', props: z.object({ lat: z.number(), lng: z.number() }),
7
7
  events: { picked: z.object({ id: z.string() }) }, client: new URL('./map.client.ts', import.meta.url),
8
- load: 'visible', wraps: false }) // in declarations
8
+ load: 'visible', wraps: false }) // widgets.ts; the feature lists the module
9
9
  ui.use(Map, { props: { lat: ctx.lat, lng: ctx.lng }, on: { picked: (d) => ui.send(Pick, { id: d.id }) },
10
10
  class: 'h-96 w-full' }, []) // in a view
11
11
  ```
@@ -19,6 +19,8 @@ export default implement<typeof Map>(({ el, props, emit, signal }) => {
19
19
  return { update(next) { map.move(next) }, destroy() { map.remove() } }
20
20
  })
21
21
  ```
22
+ - `on` is optional. `ui.use` takes no other attributes: put a role or label on a wrapping element,
23
+ `ui.section({ role: 'region', 'aria-label': 'Map' }, [ui.use(Map, { props }, [])])`.
22
24
  - `load`: `'eager' | 'visible' | 'idle'`; `wraps: true` keeps the children as server HTML.
23
25
  - `serve.ts` passes `widgets: await bundleWidgets(build)` (the server refuses to start without it; `hozu build` bundles
24
26
  them itself). A library's CSS goes in `app.css` (`@import "leaflet/dist/leaflet.css";`); a map or chart host needs a
@@ -0,0 +1,8 @@
1
+ import { feature } from '@hozu/core'
2
+ import * as views from './views.ts'
3
+
4
+ export const site = feature({
5
+ id: 'site',
6
+ intent: { summary: 'The start page' },
7
+ declarations: [views],
8
+ })
@@ -1,4 +1,4 @@
1
- import { feature, ui } from '@hozu/core'
1
+ import { ui } from '@hozu/core'
2
2
 
3
3
  export const Home = ui.view({
4
4
  render: () =>
@@ -7,9 +7,3 @@ export const Home = ui.view({
7
7
  ui.p({ class: 'text-slate-600' }, ['Edit features/site/views.ts to get started.']),
8
8
  ]),
9
9
  })
10
-
11
- export const site = feature({
12
- id: 'site',
13
- intent: { summary: 'The start page' },
14
- declarations: { Home },
15
- })
@@ -1,6 +1,7 @@
1
1
  import { project, ui } from '@hozu/core'
2
2
  import { zodAdapter } from '@hozu/schema-zod'
3
- import { Home, site } from './features/site/views.ts'
3
+ import { site } from './features/site/feature.ts'
4
+ import { Home } from './features/site/views.ts'
4
5
  import { home } from './routes.ts'
5
6
 
6
7
  export default project({