@hozu/cli 0.18.2 → 0.19.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 (73) hide show
  1. package/dist/agent.d.ts +19 -0
  2. package/dist/agent.d.ts.map +1 -0
  3. package/dist/agent.js +33 -0
  4. package/dist/agent.js.map +1 -0
  5. package/dist/commands/add.d.ts.map +1 -1
  6. package/dist/commands/add.js +1 -0
  7. package/dist/commands/add.js.map +1 -1
  8. package/dist/commands/browse-page.d.ts.map +1 -1
  9. package/dist/commands/browse-page.js +8 -2
  10. package/dist/commands/browse-page.js.map +1 -1
  11. package/dist/commands/browse-tab.d.ts +10 -0
  12. package/dist/commands/browse-tab.d.ts.map +1 -1
  13. package/dist/commands/browse-tab.js +44 -1
  14. package/dist/commands/browse-tab.js.map +1 -1
  15. package/dist/commands/browse.d.ts.map +1 -1
  16. package/dist/commands/browse.js +42 -3
  17. package/dist/commands/browse.js.map +1 -1
  18. package/dist/commands/docs.js +1 -1
  19. package/dist/commands/scaffold.js +8 -8
  20. package/dist/commands/scaffold.js.map +1 -1
  21. package/dist/commands/skill.js +1 -1
  22. package/dist/contract.d.ts +4 -0
  23. package/dist/contract.d.ts.map +1 -1
  24. package/dist/guide.d.ts +27 -0
  25. package/dist/guide.d.ts.map +1 -0
  26. package/dist/guide.js +48 -0
  27. package/dist/guide.js.map +1 -0
  28. package/dist/main.d.ts.map +1 -1
  29. package/dist/main.js +2 -1
  30. package/dist/main.js.map +1 -1
  31. package/dist/migrate/steps.d.ts.map +1 -1
  32. package/dist/migrate/steps.js +7 -0
  33. package/dist/migrate/steps.js.map +1 -1
  34. package/package.json +13 -9
  35. package/schema/browse.schema.json +13 -0
  36. package/schema/inspect.schema.json +20 -0
  37. package/skill/SKILL.md +63 -0
  38. package/skill/example/app.css +1 -0
  39. package/skill/example/app.ts +35 -0
  40. package/skill/example/features/bookmarks/feature.ts +13 -0
  41. package/skill/example/features/bookmarks/model.ts +163 -0
  42. package/skill/example/features/bookmarks/views.ts +156 -0
  43. package/skill/example/hozu.config.ts +34 -0
  44. package/skill/example/previews.ts +13 -0
  45. package/skill/example/routes.ts +11 -0
  46. package/skill/example/ui/badge.ts +11 -0
  47. package/skill/example/ui/button.ts +23 -0
  48. package/skill/example/ui/field.ts +20 -0
  49. package/skill/example/ui/input.ts +33 -0
  50. package/skill/example/ui/kit.ts +7 -0
  51. package/skill/example/ui/tv.ts +12 -0
  52. package/skill/topics/auth.md +56 -0
  53. package/skill/topics/components.md +92 -0
  54. package/skill/topics/content.md +37 -0
  55. package/skill/topics/contracts.md +36 -0
  56. package/skill/topics/data.md +74 -0
  57. package/skill/topics/deploy.md +73 -0
  58. package/skill/topics/diagnostics.md +99 -0
  59. package/skill/topics/endpoints.md +39 -0
  60. package/skill/topics/env.md +44 -0
  61. package/skill/topics/feature.md +90 -0
  62. package/skill/topics/fetch.md +67 -0
  63. package/skill/topics/forms.md +47 -0
  64. package/skill/topics/http.md +21 -0
  65. package/skill/topics/i18n.md +34 -0
  66. package/skill/topics/machine.md +78 -0
  67. package/skill/topics/pages.md +69 -0
  68. package/skill/topics/patterns.md +85 -0
  69. package/skill/topics/recipes.md +73 -0
  70. package/skill/topics/requests.md +41 -0
  71. package/skill/topics/testing.md +84 -0
  72. package/skill/topics/views.md +51 -0
  73. package/templates/guide.md +16 -0
@@ -0,0 +1,90 @@
1
+ # A new feature: the files and a complete example
2
+
3
+ Start with `npx hozu add feature <name> --page / --with auth,detail,toggle,filter,remove` and edit the texts it
4
+ lists. It writes the files below and the first `hozu.lock.json`.
5
+
6
+ Files: `features/<name>/model.ts`, `views.ts`, `feature.ts`; resolvers in `app.ts`. Relative imports end in
7
+ `.ts`. Callbacks are ordinary TypeScript, but methods on data (`.map`…) are not: use `ui.each` and a `fn()`.
8
+
9
+ ## A feature in one screen
10
+ ```ts
11
+ // model.ts
12
+ export const Item = z.object({ id: z.string(), title: z.string(), done: z.boolean() })
13
+ export const Add = event({ payload: z.object({ title: z.string() }) })
14
+ export const itemsTag = tag({ param: null })
15
+ export const listItems = query({ input: z.object({}), output: z.array(Item), scope: 'public',
16
+ freshness: 'static', tags: () => [itemsTag()], runs: 'server' }) // resolvers in app.ts
17
+ export const addItem = mutation({ input: z.object({ title: z.string().min(2, 'Too short') }), output: Item,
18
+ errors: { Duplicate: z.object({ title: z.string() }) }, invalidates: () => [itemsTag()], runs: 'server',
19
+ access: 'anyone' }) // who may run it: hozu docs auth
20
+ export const items = machine({
21
+ context: z.object({ draft: z.string(), error: z.string().nullable() }),
22
+ initialContext: { draft: '', error: null },
23
+ initial: 'idle',
24
+ states: ({ ctx }) => ({
25
+ idle: { on: [on(Add, { target: 'adding', assign: (e) => { ctx.draft = e.title; ctx.error = null } })] },
26
+ adding: {
27
+ invoke: invoke(addItem, {
28
+ input: { title: ctx.draft },
29
+ done: { target: 'idle', assign: () => { ctx.draft = '' } },
30
+ failed: {
31
+ Duplicate: { target: 'idle', assign: () => { ctx.error = 'Already listed' } },
32
+ Unexpected: { target: 'idle', assign: (e) => { ctx.error = e.message } },
33
+ },
34
+ }),
35
+ },
36
+ }),
37
+ })
38
+ // views.ts
39
+ export const Board = ui.view({
40
+ machine: items,
41
+ render: ({ ctx, when }) =>
42
+ ui.main({ class: 'mx-auto max-w-xl' }, [
43
+ ui.form({ on: { submit: ui.send(Add, { title: ui.dom.form('title') }) } }, [
44
+ ui.input({ name: 'title', required: true, value: ctx.draft }),
45
+ ui.button({ type: 'submit' }, ['Add']),
46
+ ]),
47
+ ctx.error !== null && ui.p({ role: 'alert' }, [ctx.error]),
48
+ when(['adding'], [ui.p({ 'aria-busy': 'true' }, [`Adding ${ctx.draft}…`])]),
49
+ ui.query(listItems, {}, {
50
+ ready: (list) => ui.ul({}, [ui.each(list, 'id', (i) => ui.li({}, [i.title, i.done ? ' ✓' : '']))]),
51
+ failed: { Unexpected: () => ui.p({ role: 'alert' }, ['Unavailable']) },
52
+ }),
53
+ ]),
54
+ })
55
+ // feature.ts
56
+ import * as model from './model.ts'
57
+ import * as views from './views.ts'
58
+ export const todos = feature({ id: 'todos', intent: { summary: 'A to-do list' }, declarations: [model, views] })
59
+ ```
60
+ Every declaration a listed module exports is registered under its name; schemas and helpers are ignored. Resolvers:
61
+ `implement(addItem, ({ title }, { fail }) => exists ? fail('Duplicate', { title }) : save(title))`.
62
+
63
+ <!-- more -->
64
+
65
+ ## How it works
66
+ - A feature is **declarations**: events, queries / mutations (the only side effects), one machine, views,
67
+ contracts. Builders record them as data (an IR) that is validated, then rendered on the server. Only views bound
68
+ to the machine ship JS.
69
+ - **Callbacks are ordinary TypeScript** (`render`, `guard`, `assign`, `navigate`, `ui.each` / `ui.query`
70
+ callbacks): `===`, `!==`, `<`, `&&`, `||`, `!`, `??`, `c ? a : b`, template strings, `+`, `-`, `.length`, and in
71
+ `assign`, `ctx.x = v`, `ctx.n += 1`, `ctx.list.push(v)`, `ctx.list = ctx.list.filter((i) => i.id !== e.id)`.
72
+ Methods on data (`.map`, `.toUpperCase()`…) are not: use `ui.each` for lists and a `fn()` for computation.
73
+ - A mutation runs when the machine **enters** a state whose `invoke` calls it; that state drops other events, and
74
+ `done` / `failed` leave it.
75
+ - A filter in the URL starts the machine: `seed: ({ search }) => ({ q: search.q })` on the view, then read `ctx.q`.
76
+ `machine({ on })` holds transitions every idle state shares; `fn` bodies may call helpers from the same module.
77
+ - Reusable view logic is a `part((…) => …)`, inlined where it is used (`hozu docs views`).
78
+
79
+ ## Files
80
+ ```
81
+ hozu.config.ts project({ schema, app, site, routes, pages, features }) routes.ts route() declarations
82
+ features/<name>/model.ts schemas, events, effects, fns, machine views.ts views, contracts
83
+ features/<name>/feature.ts feature({ declarations: [model, views] }) app.ts app({ resolvers })
84
+ ui/kit.ts ui.kit({ id: 'ui', components }) — Button, Input, Field… ui/*.ts one component each
85
+ ```
86
+ Relative imports end in `.ts`. The example above uses plain elements so it runs in any app; with a kit the input
87
+ and button are `ui.use(Input, …)` and `ui.use(Button, …)`, as in `example/` (`hozu docs components`).
88
+ - **Sharing with another feature:** the owner lists what it shares, `exports: [listRooms, roomsTag]`, next to
89
+ `declarations`; the user lists the owner, `imports: [bookings]`. Imports go one way (each `feature.ts` imports
90
+ the other's module): when two features need each other's data, one of them owns it and exports it.
@@ -0,0 +1,67 @@
1
+ # Where effects run: runs and fetch.ts
2
+
3
+ Every query and mutation declares `runs` (required, no default): what its implementation needs.
4
+
5
+ | `runs` | The implementation needs | Implemented in |
6
+ |---|---|---|
7
+ | `'server'` | a database, a server secret, the session | `app.ts` / `features/<name>/server.ts` resolvers |
8
+ | `'browser'` | the visitor's own data in the browser (a list in `localStorage`), or browser credentials (a token, an OIDC library, the API's own cookies) | `features/<name>/fetch.ts` |
9
+ | `'either'` | nothing special: a public API, or your own API with CORS | `features/<name>/fetch.ts` |
10
+
11
+ ```ts
12
+ // fetch.ts: one export per effect, under its name; the model import is type-only
13
+ import { implement } from '@hozu/core/fetch'
14
+ import type * as model from './model.ts'
15
+ export const searchRepos = implement<typeof model.searchRepos>(async ({ q }, { fail, signal, env }) => {
16
+ const r = await fetch(`${env.API_URL}/search/repositories?q=${encodeURIComponent(q)}`, { signal })
17
+ return r.ok ? (await r.json()).items : fail('Unavailable', {})
18
+ })
19
+ export const myRepos = implement<typeof model.myRepos>(async (_, { fail, signal }) => {
20
+ const token = localStorage.getItem('gh-token')
21
+ if (!token) return fail('Unauthorized', {})
22
+ const r = await fetch('https://api.github.com/user/repos', { headers: { authorization: `Bearer ${token}` }, signal })
23
+ return r.status === 401 ? fail('Unauthorized', {}) : r.json()
24
+ })
25
+ ```
26
+ - The feature names the module: `feature({ …, fetch: new URL('./fetch.ts', import.meta.url) })`; `app()` needs
27
+ `components: bundleComponents` (HZ045). A missing or extra export is HZ081.
28
+ - Browser-held data (`localStorage`, a token, the API's own cookies) is `scope: 'user'`; `'either'` needs
29
+ `scope: 'public'` (HZ081).
30
+ - fetch.ts runs in the browser: no Node-only imports, no secrets; `env` is the public env only.
31
+ - Every other origin fetch.ts calls goes in `feature({ connect: ['https://api.github.com', { env: 'API_URL' }] })`
32
+ (HZ083); the API must allow the page's origin (CORS), otherwise use `runs: 'server'`.
33
+
34
+ <!-- more -->
35
+
36
+ ## The model side
37
+ ```ts
38
+ // model.ts
39
+ export const searchRepos = query({ input: z.object({ q: z.string() }), output: Repos,
40
+ errors: { Unavailable: z.object({}) }, scope: 'public', freshness: 'request', runs: 'either' })
41
+ export const myRepos = query({ input: z.object({}), output: Repos, errors: { Unauthorized: z.object({}) },
42
+ scope: 'user', freshness: 'request', tags: () => [reposTag()], runs: 'browser' })
43
+ export const star = mutation({ input: z.object({ repo: z.string() }), output: z.object({}),
44
+ invalidates: () => [reposTag()], runs: 'browser' })
45
+ // feature.ts
46
+ export const repos = feature({ id: 'repos', intent, declarations: [model, views],
47
+ fetch: new URL('./fetch.ts', import.meta.url) })
48
+ ```
49
+
50
+ ## Details
51
+ - **What runs where:** `'either'` is server-rendered on first paint (data in the HTML, cached per `freshness`), and
52
+ later in-page reads and mutations call the API from the browser, never through the server. `'browser'` renders its
53
+ `pending` branch on the server and reads after hydration. `'server'` always goes through the server.
54
+ - **Checked at the boundary:** inputs and outputs are checked against their schemas in the browser too; a wrong
55
+ output is `Unexpected` with its path, `fail(Name, data)` is the declared error.
56
+ - `env` is the parsed `public` environment; server env and the session never reach fetch.ts.
57
+ - **fetch.ts runs in the browser** (and on the server for `'either'`). A token read in the browser is sent only to
58
+ the API, never to your own server.
59
+ - **CSP:** Hozu adds every `connect` origin to the page's CSP `connect-src` (an `{ env }` entry reads that public env
60
+ variable's URL at startup). An absolute URL in fetch.ts that `connect` does not list is HZ083; a call blocked at
61
+ run time shows in `hozu browse`.
62
+ - The bundle (`bundleComponents`) carries fetch.ts for the browser.
63
+ - **Rules:** HZ081 (a missing or extra export, or `'either'` with user data), HZ082 (a `'browser'` query in a page
64
+ `head` or `entries`; a browser mutation that invalidates a tag a server-cached query reads), HZ036 (a form that
65
+ starts a `'browser'` mutation needs JS).
66
+ - **Static host:** pages with only `'browser'` / `'either'` data export completely; `exportStatic` lists in
67
+ `needsServer` the server effects a page would still call.
@@ -0,0 +1,47 @@
1
+ # Forms
2
+
3
+ - **Works without JavaScript** when the submit payload reads only `ui.dom.form('name')`, `ui.dom.formAll('name')`,
4
+ literals, context, params and search (else HZ036 warns). Put every value the submit needs in a named field.
5
+ ```ts
6
+ ui.form({ on: { submit: ui.send(Add, { title: ui.dom.form('title'), kind: ui.dom.form('kind') }) } }, [
7
+ ui.label({ for: 'title' }, ['Title']),
8
+ ui.input({ id: 'title', name: 'title', required: true, minlength: 2, value: ctx.draft,
9
+ 'aria-invalid': ctx.fields.title !== null, 'aria-describedby': 'title-error',
10
+ on: { input: ui.send(Draft, { text: ui.dom.value }) } }),
11
+ ui.select({ name: 'kind', 'aria-label': 'Kind' }, kinds.map((k) => ui.option({ value: k, selected: ctx.kind === k }, [k]))),
12
+ ui.button({ type: 'submit' }, ['Add']),
13
+ ])
14
+ ui.p({ id: 'title-error', class: 'text-sm text-rose-600' }, [ctx.fields.title])
15
+ ```
16
+ - **Field errors:** context `fields: z.object({ title: z.string().nullable() })`, reset on submit
17
+ (`ctx.fields = { title: null }`), and `failed.Invalid: { target: 'idle', assign: (e) => { ctx.fields = e.fields } }`.
18
+ Limits live in the mutation's input schema (`z.string().min(2, '…')`), never in the event payload (HZ061).
19
+ - **Server error:** a declared error sets `ctx.error`; show `ctx.error !== null && ui.p({ role: 'alert' }, [ctx.error])`.
20
+ - **Enum from a select:** fills an enum field only when every literal option value is a member (HZ033).
21
+
22
+ <!-- more -->
23
+
24
+ - **Without JavaScript,** the server runs the same machine for a native post, then redirects or re-renders with the
25
+ result (e.g. a `<select name="kind">` carries the kind).
26
+ - **With the app's kit** (as in `example/`): a `Field` with a `control` slot holds the label, the input and its error.
27
+ ```ts
28
+ ui.use(Field, { props: { for: 'title', label: 'Title', error: ctx.fields.title, errorId: 'title-error' },
29
+ slots: { control: ui.use(Input, { props: { id: 'title', name: 'title', value: ctx.draft, required: true,
30
+ invalid: ctx.fields.title !== null, describedby: 'title-error' }, on: { input: ui.send(Draft, { text: ui.dom.value }) } }) } }),
31
+ ui.use(Button, { props: { type: 'submit' } }, ['Add']),
32
+ ```
33
+ - **Limit messages:** `z.string().min(2, 'Use at least 2 characters')`.
34
+ - **Clear after success:** bind `value: ctx.draft` and reset it in `done`.
35
+ - **Per-item actions without JS:** wrap each button in its own small form.
36
+ - **Enum source:** `ui.dom.form('kind')` or `ui.dom.value`.
37
+ - **Several values:** `ui.dom.formAll('ids')` is every value of the name in tree order (`[]` when none) for checkbox
38
+ groups, `select multiple` and controls inside `ui.each`, into a list field; `ui.dom.form(name)` is the first (HZ054).
39
+ - **Which button:** give submit buttons `name` and a literal `value` and read `ui.dom.form('action')` in the form's
40
+ submit; JS and no-JS read the same value. Into an enum only when every submit button of the form has that name and
41
+ a member value, else make the field nullable (HZ033). No `on.click` on a submit button (HZ056).
42
+ - **Controls outside the form** (forms cannot nest): `const bulk = ui.formRef()` at module level,
43
+ `ui.form({ ref: bulk, … })`, `ui.input({ form: bulk, … })`; a string `form` is HZ014, a name no control has HZ055.
44
+ - **A flag or a number:** a checkbox posts `'on'` only while checked: `ui.dom.formAll('remember')` into
45
+ `z.array(z.string())`, or a radio pair. Send numbers as text and parse them in the mutation input (`z.coerce.number()`).
46
+ - **Invalid without JS:** a native post whose payload or mutation input fails re-renders with 400 through
47
+ `failed.Invalid`, like the JS submit.
@@ -0,0 +1,21 @@
1
+ # HTTP
2
+
3
+ Without `http`, the site is served at `/` without trailing slashes (`/about/` answers 308 → `/about`).
4
+ `project({ http: { basePath, trailingSlash, redirects, headers } })` changes that (see --more). There are no
5
+ rewrites: one URL has one owner. Your own HTTP routes: `hozu docs endpoints`.
6
+
7
+ <!-- more -->
8
+
9
+ ```ts
10
+ http: {
11
+ basePath: '/shop', // every URL and /_hozu/* move under it (HZ039)
12
+ trailingSlash: 'always', // or 'never'; the other form answers 308
13
+ redirects: { // keyed by the old path; never a path a page owns (HZ037)
14
+ '/blog/:slug': { to: (p) => ui.link(post, { slug: p.slug }), permanent: true },
15
+ '/docs': { to: 'https://docs.example.com', permanent: false },
16
+ },
17
+ headers: [{ routes: 'all', set: { 'permissions-policy': 'camera=()' } }], // not cache-control (HZ038)
18
+ },
19
+ ```
20
+ Server options live in the app module: `app({ resolvers, session?, components?, onError?, csp?, og?, preview? })`.
21
+ A strict CSP, `nosniff` and a cross-site POST check are on by default; `csp: { script: ['https://…'] }` adds sources.
@@ -0,0 +1,34 @@
1
+ # Languages
2
+
3
+ - **The URL holds the language:** `site: { lang: 'en', locales: ['en', 'de'], … }` keeps `/posts/a` for `en` and
4
+ prefixes the others (`/de/posts/a`). Routes and `ui.link` stay locale-free. No cookie, session field or rewrite.
5
+ - Switch: `ui.a({ href: ui.alternate('de') }, ['Deutsch'])`.
6
+ - `export const text = ui.messages('en', { en: { saved: '{count} saved' }, de: { saved: '{count} gespeichert' } })`
7
+ in a listed module; `text.saved({ count })` in views. Every locale needs every key (HZ040).
8
+ - Machines never hold translated text (HZ041): store a code.
9
+ - **Data per language:** `locale` (in every view, and the second argument of `head.input` / `head.render`) goes into
10
+ the query input: `ui.query(listPosts, { locale })`; `head: { input: (params, locale) => ({ slug: params.slug,
11
+ locale }) }`. It is typed `string`: declare the input `z.string()`, or narrow it with `locale as Locale`.
12
+
13
+ <!-- more -->
14
+
15
+ ## Details
16
+ - Reload, sign-in and sign-out keep the language, because every link and `navigate` resolves `ui.link` in the
17
+ page's locale.
18
+ - `/en/posts/a` answers 308 `/posts/a`. There is no `Accept-Language` redirect: a new visit to an unprefixed URL
19
+ shows `site.lang`. Contracts expect the default URLs. `<html lang>`, hreflang and the sitemap are derived. A page
20
+ route starting with a locale segment is HZ060.
21
+ - A toggle: `locale === 'en' ? ui.alternate('de') : ui.alternate('en')`.
22
+ - Messages: use `text.title` or `text.saved({ count })` in views and `head.render`; every locale needs the same
23
+ `{placeholders}` (HZ040). Plurals: `'{n, plural, =0 {none} one {# item} other {# items}}'`.
24
+ - Machines store a code and the view chooses the message.
25
+ - `ui.format.number(x, { style: 'currency', currency: 'EUR' })`, `ui.format.date(x, { dateStyle: 'medium' })`,
26
+ `ui.format.relative(n, 'day')`, `ui.format.list(xs)`. `locale` is in every view.
27
+ - Resolvers have no locale of their own; it reaches them only through the input. The sitemap lists one entry per
28
+ `entries` input in every locale.
29
+ - `ui.format.date` takes an ISO string or a timestamp and formats it in the page's locale, in the time zone of the
30
+ machine that renders it. A date without a time (`'2026-09-12'`) is midnight UTC: pass `{ timeZone: 'UTC' }` so
31
+ every server and browser shows the same day.
32
+ - A form's `Invalid` messages come from the schema in one language: store `invalid: true` (or a code) in context and
33
+ choose the text in the view.
34
+
@@ -0,0 +1,78 @@
1
+ # Machine (one per feature)
2
+
3
+ ```ts
4
+ export const m = machine({
5
+ context: z.object({ draft: z.string(), error: z.string().nullable() }),
6
+ initialContext: { draft: '', error: null },
7
+ initial: 'idle',
8
+ states: ({ ctx }) => ({
9
+ idle: { on: [on(Add, { target: 'adding', assign: (e) => { ctx.draft = e.title } })] },
10
+ adding: {
11
+ invoke: invoke(addItem, {
12
+ input: { title: ctx.draft },
13
+ done: { target: 'idle', assign: () => { ctx.draft = '' } },
14
+ failed: { Unexpected: { target: 'idle', assign: (e) => { ctx.error = e.message } } },
15
+ }),
16
+ },
17
+ }),
18
+ })
19
+ ```
20
+ - Events: `export const Add = event({ payload: z.object({ title: z.string() }) })`.
21
+ - **assign** writes context: `ctx.x = v`, `ctx.n += 1`, `ctx.list.push(item)`,
22
+ `ctx.list = ctx.list.filter((i) => i.id !== e.id)`.
23
+ - **guard** returns a condition: `on(Add, { target: 'adding', guard: (e) => e.title.length >= 2 })`; the first
24
+ matching guard wins.
25
+ - **invoke** runs a mutation on entry; the state drops events it does not handle. `failed` lists every declared error
26
+ of the mutation plus `Unexpected` (`Invalid` optional, `hozu docs forms`).
27
+ - Do not handle the busy event in the busy state: a transition to the same state re-runs its `invoke`.
28
+ - `target: 'previous'` (or `done: 'previous'`) returns to the state the machine came from, so a busy state entered
29
+ from two modes (viewing, editing) needs no copy per mode.
30
+
31
+ <!-- more -->
32
+
33
+ The full form: shared transitions, guards, `navigate`, errors, a timer.
34
+
35
+ ```ts
36
+ export const m = machine({
37
+ context: z.object({ draft: z.string(), error: z.string().nullable(), target: z.string() }),
38
+ initialContext: { draft: '', error: null, target: '' },
39
+ initial: 'idle',
40
+ on: ({ ctx }) => [on(Draft, { assign: (e) => { ctx.draft = e.text } })], // shared by every state without invoke
41
+ states: ({ ctx }) => ({
42
+ idle: {
43
+ on: [
44
+ on(Add, { target: 'adding', guard: (e) => e.title.length >= 2 }), // first matching guard wins
45
+ on(Add, { target: 'idle', assign: () => { ctx.error = 'Too short' } }),
46
+ on(Remove, { target: 'removing', assign: (e) => { ctx.target = e.id } }),
47
+ ],
48
+ },
49
+ adding: { // runs addItem on entry; drops events it does not handle
50
+ invoke: invoke(addItem, {
51
+ input: { title: ctx.draft },
52
+ done: { target: 'idle', assign: () => { ctx.draft = '' }, navigate: (r) => ui.link(itemPage, { id: r.id }) },
53
+ failed: { // every declared error + Unexpected (+ optional Invalid)
54
+ Duplicate: { target: 'idle', assign: () => { ctx.error = 'Already exists' } },
55
+ Unexpected: { target: 'idle', assign: (e) => { ctx.error = e.message } },
56
+ },
57
+ }),
58
+ },
59
+ removing: { invoke: invoke(removeItem, { input: { id: ctx.target }, done: 'idle', failed: { Unexpected: 'idle' } }) },
60
+ flash: { after: [{ ms: 3000, target: 'idle' }], ignore: [Add] }, // timers; ignore only without invoke
61
+ }),
62
+ })
63
+ ```
64
+ - **assign** values are event (`e`), result (`r`) or error fields, context, literals, operators and `fn()` calls.
65
+ - **guard** conditions: a field (`() => ctx.auto`), comparisons, `&&`, `||`, `!`, or a boolean `fn()`.
66
+ - **navigate** sends the browser to `ui.link(route, params, search?)` after the transition. It returns one link: to
67
+ choose between links, write one guarded transition per link (`[{ guard: () => …, navigate: … }, { navigate: … }]`);
68
+ a `?:` inside `navigate` is HZ014.
69
+ - `done` and each `failed` entry take a state name, one transition, or a list of guarded transitions.
70
+ - **Shared transitions:** `machine({ on })` entries are copied into every state that has no `invoke`, is not final,
71
+ and neither handles nor ignores the event itself. Without `target` they stay in the state they fire in; one
72
+ contract covers every copy.
73
+ - **Start from the URL:** a view with a `route` may declare `seed: ({ params, search }) => ({ q: search.q })`; the
74
+ page's machine then starts with those context fields (server render, hydration and no-JS posts alike). One view
75
+ per page may seed a machine (HZ048).
76
+ - A transition to the same state re-enters it. In an app with `site.locales`, machines never hold
77
+ translated text (HZ041): store a code (`ctx.error = 'duplicate'`) and choose the message in the view. The
78
+ scaffold does this in every app.
@@ -0,0 +1,69 @@
1
+ # Routes and pages
2
+
3
+ ```ts
4
+ // routes.ts
5
+ export const home = route({ path: '/', params: null, search: z.object({ show: Show.default('all') }) })
6
+ export const itemPage = route({ path: '/items/:id', params: z.object({ id: z.string() }), search: null })
7
+ export const docs = route({ path: '/docs/:path+', params: z.object({ path: z.array(z.string()).min(1) }), search: null })
8
+ ```
9
+ - `:x` one segment, `:x?` optional (nullable), `:x+` / `:x*` one-or-more / zero-or-more (string[]) (HZ024).
10
+ - `search`: flat scalars or enums, each with a default or nullable (HZ035).
11
+ - **Pages** go in `project({ routes: { home, itemPage }, pages: [...] })` (the whole config: see --more):
12
+ `ui.page(home, { views: [Board], head: { render: () => ({ title: 'Items' }) } })`.
13
+ - **Head from a query:** `head: { query: getItem, input: (params, locale) => ({ id: params.id }), render: (item) => ({ title:
14
+ item.title }), failed: { NotFound: 404 } }`. `failed` maps every declared error of the query (HZ051) to a route
15
+ without params (303) or to `403`, `404` or `410`.
16
+ - `head.render` fields: `title`, `description`, `type` (`'website' | 'article'`), `image`, `published`, `noindex`;
17
+ any other is HZ014 (Open Graph, `twitter:card` and the JSON-LD are derived from these).
18
+ - A route no page renders is HZ052.
19
+
20
+ <!-- more -->
21
+
22
+ - URLs are canonical (keys sorted, defaults left out). Changing `search` is a navigation: a filter in the URL is a
23
+ plain `ui.link`, no machine.
24
+
25
+ ```ts
26
+ // hozu.config.ts
27
+ export default project({
28
+ schema: zodAdapter, app: new URL('./app.ts', import.meta.url), styles: new URL('./app.css', import.meta.url),
29
+ site: { url: 'https://example.com', name: 'Items', lang: 'en' },
30
+ routes: { home, itemPage }, notFound: missing, // notFound / error: routes rendered for 404 / 500
31
+ pages: [
32
+ ui.page(home, { views: [Board], head: { render: () => ({ title: 'Items' }) } }),
33
+ ui.page(itemPage, {
34
+ views: [Detail],
35
+ head: {
36
+ query: getItem,
37
+ input: (params) => ({ id: params.id }),
38
+ render: (item) => ({ title: item.title, description: item.title, type: 'article' }),
39
+ failed: { NotFound: 404 }, // every declared error of the query (HZ051)
40
+ },
41
+ entries: { query: listItems, input: {}, params: (item) => ({ id: item.id }) }, // sitemap + static export
42
+ }),
43
+ ],
44
+ kits: [kit], // shared UI (hozu docs components)
45
+ features: [items],
46
+ })
47
+ ```
48
+ - `image` is a URL, `ui.asset(...)` or `ui.og({ title })`. The share card is derived: `og:image:width` / `height`
49
+ from the file, `og:image:alt` from the title, `twitter:card` large from 600 px wide. Use a 1200×630 image.
50
+ - `entries.lastmod: (item) => item.updatedAt` (an ISO date) adds `<lastmod>` to the sitemap.
51
+ - `site.url: { env: 'SITE_URL' }` reads the origin at startup from a variable declared in `env.public` (HZ085).
52
+ - Check the head without a server: `hozu get / --select 'meta[property^="og:"]'`; `--select script` prints the JSON-LD.
53
+ - `head.failed` example: `failed: { Unauthorized: login, Forbidden: 403 }`. `Unexpected` is always 500.
54
+ It maps declared errors only: a head query that always fails is not a redirect.
55
+ When the head query fails, no head field is computed: the `<title>` is `site.name`, with no description.
56
+ - **Which redirect** (one per purpose):
57
+
58
+ | Need | Form |
59
+ |---|---|
60
+ | a static path moved | `http.redirects` (`hozu docs http`) |
61
+ | this visitor may not see the page | `head.failed` |
62
+ | a decision on success, e.g. `/` by session | a GET endpoint with `output: 'redirect'` (`hozu docs endpoints`) |
63
+ | after a machine transition | `navigate` |
64
+
65
+ - For a route no page renders (HZ052), link to an endpoint with `ui.link(endpoint, input)` instead.
66
+ - A detail view: `ui.view({ route: itemPage, render: ({ params }) => ui.query(getItem, { id: params.id }, { ready,
67
+ failed: { NotFound: () => ui.p({}, ['Not found']), Unexpected: () => … } }) })`.
68
+ - A page loads JS only when a machine-bound part renders on it (`hozu plan <route or path>`). Every link loads a document;
69
+ state across pages lives in the URL (`seed`), on the server (queries) or in a client component's own storage.
@@ -0,0 +1,85 @@
1
+ # Common UI patterns
2
+
3
+ The controls are plain elements; in an app with a kit, use its components (`ui.use(Button, …)`,
4
+ `hozu docs components`).
5
+
6
+ - **Busy state:** render every control once; the state with `invoke` drops repeated submits. Progress:
7
+ `when(['adding'], [ui.p({ 'aria-busy': 'true' }, ['Saving…'])])`. Do not duplicate controls under `when`.
8
+ - **Optimistic item:** `when(['adding'], [ui.li({ class: 'opacity-50' }, [ctx.draft])])`; leaving the state removes it
9
+ and the refreshed query shows the real item.
10
+ - **Refresh after a mutation:** tag the query, list the tag in the mutation's `invalidates`.
11
+ - **Go to what was just created:** `done: { target: 'idle', navigate: (r) => ui.link(itemPage, { id: r.id }) }`.
12
+ - **Per-item action** (toggle, pin, delete): each item gets its own small form, so it works without JS:
13
+ ```ts
14
+ ui.form({ on: { submit: ui.send(Toggle, { id: ui.dom.form('id') }) } }, [
15
+ ui.input({ type: 'hidden', name: 'id', value: item.id }),
16
+ ui.button({ type: 'submit' }, [item.done ? 'Reopen' : 'Done']),
17
+ ])
18
+ // machine: on(Toggle, { target: 'toggling', assign: (e) => { ctx.target = e.id } })
19
+ // toggling: { invoke: invoke(toggleItem, { input: { id: ctx.target }, done: 'idle', failed: { Unexpected: 'idle' } }) }
20
+ ```
21
+ - **Filter in the URL** (shareable, no JS): `search` on the route, options as
22
+ `ui.a({ href: ui.link(home, null, { show: s.value }), 'aria-current': search.show === s.value }, [s.label])`.
23
+ - **Filter as you type, empty state:** context `search: z.string()`, `on: { input: ui.send(Search, { text:
24
+ ui.dom.value }) }`, filter and test emptiness with a `fn` (see --more).
25
+ - **Detail page with a 404:** `hozu docs pages`.
26
+
27
+ <!-- more -->
28
+
29
+ Each pattern is complete here; there is no need to open other files.
30
+
31
+ - **Per-item action, tried without a server:** `hozu browse / --do 'fill Title=x' --do 'press Enter' --do 'click Done in "x"'`.
32
+ - **Filter and empty state** (in context): one helper, two `fn`s over the list:
33
+ ```ts
34
+ const shows = (i: Item, show: Show) => show === 'all' || (show === 'done') === i.done // sent with the fns
35
+ export const visible = fn({ input: z.object({ items: z.array(Item), show: Show }), output: z.array(Item),
36
+ impl: ({ items, show }) => items.filter((i) => shows(i, show)) })
37
+ export const isEmpty = fn({ input: z.object({ items: z.array(Item), show: Show }), output: z.boolean(),
38
+ impl: ({ items, show }) => !items.some((i) => shows(i, show)) })
39
+ // view
40
+ isEmpty({ items, show: ctx.show })
41
+ ? ui.p({ class: 'text-slate-500' }, ['No items'])
42
+ : ui.ul({}, [ui.each(visible({ items, show: ctx.show }), 'id', (i) => ui.li({}, [i.title]))])
43
+ ```
44
+ - **Search as you type:** context `search: z.string()`; `ui.input({ type: 'search', 'aria-label': 'Search', value:
45
+ ctx.search, on: { input: ui.send(Search, { text: ui.dom.value }) } })`; `on(Search, { target: 'idle', assign: (e) =>
46
+ { ctx.search = e.text } })`; filter with a `fn({ input: z.object({ items, text: z.string() }), … })`.
47
+ - **Toggle buttons:** for each option of a constant list,
48
+ `ui.button({ type: 'button', 'aria-pressed': ctx.show === s.value, on: { click: ui.send(SetShow, { show: s.value }) } }, [s.label])`.
49
+ - **In the URL and as you type** (`/?q=park` works without JS, typing filters live): seed the machine from the URL
50
+ and read only the context. A GET form with `name="q"` submits it without JS.
51
+ ```ts
52
+ export const Board = ui.view({ machine: m, route: home, seed: ({ search }) => ({ q: search.q, district: search.district }),
53
+ render: ({ ctx }) => ui.form({ method: 'get' }, [
54
+ ui.input({ type: 'search', name: 'q', 'aria-label': 'Search', value: ctx.q, on: { input: ui.send(Search, { q: ui.dom.value }) } }),
55
+ /* … */ ui.each(visible({ items, q: ctx.q, district: ctx.district }), 'id', (s) => …) ]) })
56
+ ```
57
+ - **A mode with shared controls** (a tour, an edit mode): put what every mode handles the same way in
58
+ `machine({ on: [on(Search, { assign: (e) => { ctx.q = e.q } })] })` (no `target`: stays in its state); each state
59
+ lists only what differs.
60
+ - **Select many, then act** (bulk delete): checkboxes in the list join one form through a formRef; the invoke
61
+ state drops events, so the checkboxes are disabled while it runs:
62
+ ```ts
63
+ const bulk = ui.formRef() // module level; context { selected: z.array(z.string()), busy: z.boolean() }
64
+ ui.form({ ref: bulk, on: { submit: ui.send(Bulk, { ids: ui.dom.formAll('ids'), action: ui.dom.form('action') }) } }, [
65
+ ui.button({ type: 'submit', name: 'action', value: 'delete' }, ['Delete selected']),
66
+ ui.button({ type: 'submit', name: 'action', value: 'pin' }, ['Pin selected']),
67
+ ])
68
+ ui.each(items, 'id', (item) => ui.li({}, [ui.input({ type: 'checkbox', form: bulk, name: 'ids', value: item.id,
69
+ 'aria-label': `Select ${item.text}`, checked: ctx.selected.includes(item.id), disabled: ctx.busy,
70
+ on: { change: ui.send(Select, { id: item.id, checked: ui.dom.checked }) } }), item.text]))
71
+ // on(Select, { target: 'idle', guard: (e) => e.checked === true, assign: (e) => { ctx.selected.push(e.id) } }),
72
+ // on(Select, { target: 'idle', assign: (e) => { ctx.selected = ctx.selected.filter((id) => id !== e.id) } }),
73
+ // on(Bulk, { target: 'removingMany', guard: (e) => e.action === 'delete', assign: (e) => { ctx.selected = e.ids; ctx.busy = true } }),
74
+ // removingMany: invoke(removeNotes, { input: { ids: ctx.selected }, done/failed: reset selected and busy })
75
+ ```
76
+ The mutation input holds the limit (`z.array(z.string()).min(1, 'Select at least one note')`).
77
+ - **Sorted or pinned first:** sort in the resolver (the list query returns items in display order), or in a `fn`.
78
+ - **UI kept across links** (a cart, a player): list the same machine view on each page, in the same order.
79
+ - **Load more:** context `{ cursors: [null], last: null }`;
80
+ `ui.each(ctx.cursors, null, (cursor) => ui.query(listPage, { cursor }, { ready: (page) => … }))`; on the last page
81
+ (`cursor === ctx.last && page.next !== null`) a sentinel `on: { visible: ui.send(More, { cursor: page.next }) }`;
82
+ `More` pushes the cursor, guarded by `e.cursor !== null && e.cursor !== ctx.last`.
83
+ - **A link starts the page again:** every internal link is a document navigation, so a machine's context starts
84
+ from `initialContext` (or `seed`). Keep what must survive in the URL: put both filters in `search` and `seed` the
85
+ context from it, instead of one in the URL and one in context.
@@ -0,0 +1,73 @@
1
+ # Recipes for common changes
2
+
3
+ Names follow `hozu add feature items`: `Item`, `NewItem`, `Add`, `addItem`, `itemsMachine`, `ItemsBoard`. The controls are plain elements; with a
4
+ kit, use its components instead. More recipes (an action over many items, a field on the detail page, a detail page): see --more.
5
+
6
+ ## A personal list without sign-in (a watchlist, favourites)
7
+ The list is the visitor's own: it lives in their browser, so two visitors never share it (`examples/watchlist`).
8
+ - **model:** `myList` query `scope: 'user'`, `freshness: 'request'`, `tags: () => [listTag()]`, `runs: 'browser'`;
9
+ `addSymbol` / `removeSymbol` mutations `invalidates: () => [listTag()]`, `runs: 'browser'` (no `access`).
10
+ - **fetch.ts:** `localStorage`, one export per effect:
11
+ ```ts
12
+ const read = (): string[] => JSON.parse(localStorage.getItem('watchlist:symbols') ?? '[]')
13
+ export const myList = implement<typeof model.myList>(async () => read())
14
+ export const addSymbol = implement<typeof model.addSymbol>(async ({ symbol }, { fail }) => {
15
+ if (read().includes(symbol)) return fail('Duplicate', { symbol })
16
+ localStorage.setItem('watchlist:symbols', JSON.stringify([...read(), symbol]))
17
+ return {}
18
+ })
19
+ ```
20
+ - **feature.ts:** `fetch: new URL('./fetch.ts', import.meta.url)`; `app.ts`: `components: bundleComponents`.
21
+ - **Data about the items** (quotes, prices) is public: a `runs: 'server'` (or `'either'`) query inside the list's
22
+ `ready` branch, `ui.query(quotes, { symbols }, …)`.
23
+ - The form needs JavaScript (HZ036): `project({ accept: [{ code: 'HZ036', at: 'watchlist.Add', reason: … }] })`.
24
+ - Across devices the list needs sign-in and a database instead (`hozu docs auth`).
25
+
26
+ ## A field chosen in the add form (an enum)
27
+ - **model:**
28
+ - `export const Priority = z.enum(['low', 'normal', 'high'])`;
29
+ - add `priority: Priority` to `Item`, `NewItem` and the `Add` payload;
30
+ - context: `priority: Priority`, with `priority: 'normal'` in `initialContext`;
31
+ - `fields` gets `priority: z.string().nullable()`, with `priority: null` in `initialContext` and in the `Add`
32
+ assign that resets it;
33
+ - the `Add` assign also gets `ctx.priority = e.priority`, and the add `invoke` input becomes
34
+ `{ title: ctx.draft, priority: ctx.priority }`.
35
+ - **views:**
36
+ - the form's submit sends `{ title: ui.dom.form('title'), priority: ui.dom.form('priority') }`;
37
+ - inside the form add
38
+ `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])))`;
39
+ - in the item: `ui.span({ class: 'text-xs' }, [item.priority])`.
40
+ - **Contracts:** if the app has contracts that send `Add` or return an item, add `priority` to their payloads,
41
+ inputs and results. These transitions only copy values, so they need no new contract.
42
+ - **server:** store `priority` where the items live (the scaffold's `demoItems` stand-in, or the database) and return it.
43
+
44
+ <!-- more -->
45
+
46
+ With a kit: `ui.use(Button, { variant: { tone: 'quiet' } }, ['Clear done'])`.
47
+
48
+ ## An action button that works on many items (e.g. "Clear done")
49
+ - **model:**
50
+ - `export const ClearDone = event({ payload: z.object({}) })`;
51
+ - `export const clearDone = mutation({ input: z.object({}), output: z.object({ removed: z.number() }), invalidates: () => [itemsTag()], runs: 'server', access: 'anyone' })`;
52
+ - in `idle`: `on(ClearDone, { target: 'clearing', assign: () => { ctx.error = null } })`;
53
+ - a state
54
+ `clearing: { invoke: invoke(clearDone, { input: {}, done: 'idle', failed: { Unexpected: { target: 'idle', assign: () => { ctx.error = 'unexpected' } } } }) }`
55
+ (busy states drop events they do not handle, so no `ignore`).
56
+ - **views:** the control
57
+ `ui.form({ on: { submit: ui.send(ClearDone, {}) } }, [ui.button({ type: 'submit', class: 'text-sm underline' }, ['Clear done'])])`.
58
+ - The new transitions only copy values, so they need no contract (the feature lists `model`, so both are registered).
59
+ - **server:**
60
+ `implement(clearDone, () => { const before = demoItems.length; demoItems.splice(0, demoItems.length, ...demoItems.filter((i) => !i.done)); return { removed: before - demoItems.length } })` (with a database: one delete of the done rows).
61
+ - **Try it:** `hozu browse / --do 'click Clear done'` (with and without JS).
62
+
63
+ ## A field shown on the detail page
64
+ In the detail view's `ready`: `ui.p({}, ['Priority: ', item.priority])`. The detail query already returns the whole
65
+ item.
66
+
67
+ ## A detail page, when the feature has none
68
+ Run a fresh scaffold into a scratch app with `--with detail`, and copy the parts it prints:
69
+ - the route with params;
70
+ - the `get` query and its resolver;
71
+ - the detail view;
72
+ - the link in the list;
73
+ - `ui.page(...)` with `head` and `entries`.
@@ -0,0 +1,41 @@
1
+ # Requests from Hozu DevTools
2
+
3
+ - **Read every open one in one call:** `npx hozu requests --full` prints them as one prompt (saved under
4
+ `.hozu/requests/`, or pasted to you).
5
+ - **Each item:** `Want` (the person's words), `Where` (`file:line:column` and the view), `Scope`, `Style`, `Text`,
6
+ `Mind` (where a plain edit goes wrong), `Locate` (the IR pointer).
7
+ - **Do it:** edit at `Where`; when the lines moved, `npx hozu why <pointer>` finds the node again. Style: replace the
8
+ named class with the given utility; never a `style` attribute. A behaviour change that decides needs a contract
9
+ (`hozu docs contracts`).
10
+ - **Finish:** `npx hozu check`, then `npx hozu requests done <n> --result "<one line: what changed>"` for each one;
11
+ it removes the file. Do not edit request files. Report the result lines to the person.
12
+ - **Show the person what changed:** `npx hozu show <views.ts:line | a Locate id | page:<route>> --note "<what changed,
13
+ in their words>"` frames that part on their page under `npm run dev`; `--in "<text>"` picks one row of a list. A
14
+ reply comes back as a request. `npx hozu show` lists the notes (a `STALE` one names a part that moved: re-add it),
15
+ `--done <n>` removes one, `--clear` all.
16
+
17
+ <!-- more -->
18
+
19
+ - **The person's language:** DevTools may show their own translation (`hozu dev --devtools-messages <file>`,
20
+ `HOZU_DEVTOOLS_MESSAGES`); the request Markdown you read is always English.
21
+ - **Where they come from:** under `npm run dev` (`hozu dev`) a person selects parts of the running app, describes the
22
+ change, tries styles or text, and saves a request to `.hozu/requests/NNNN-<title>.md` or pastes it to you.
23
+ `npx hozu requests` lists the numbers and places.
24
+ - **The other fields:**
25
+ - `Scope`: only this one, every item of a list, or every use of a component;
26
+ - `Style`: the class to replace and the theme utility to use;
27
+ - `Text`: wording the person tried;
28
+ - `Shown when`, and `preview <state>` on the page line: the state the person was looking at.
29
+ - **More on doing it:**
30
+ - A component use: `class` at the use for this one (a property the component owns needs a trailing `!`), the
31
+ variant in the kit for every use (`npx hozu why <ui.X>` lists them).
32
+ - An arbitrary value (`px-[22px]`) only when the line says no theme step fits.
33
+ - A message text changes in every locale; text from data changes the data or its formatting.
34
+ - **Notes (`hozu show`):** numbered in the order you add them, so several make a tour (Back / Next in the dock's Agent
35
+ panel). The target is anything `hozu why` takes; `--page /path` says where it is when the target is not on the page
36
+ the person has open. Notes live in `.hozu/notes.json` and never reach production.
37
+ - **The API drawer** (the dock's API button) lists the queries the page reads and the mutations its machines start,
38
+ with `runs`, freshness, errors and the `file:line` that implements each, and runs them with an input the person
39
+ edits (mutations ask first: they write development data; the page then re-reads in place). A request may carry a
40
+ `npx hozu call …` line or a `curl` command (the requests a call sent out) copied from it. When a request says "this query returns X", reproduce it with
41
+ `npx hozu call <feature>.<effect> --input '…'` before changing the resolver.