@hozu/cli 0.18.2 → 0.20.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 (75) 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/map.js +2 -2
  20. package/dist/commands/map.js.map +1 -1
  21. package/dist/commands/scaffold.js +8 -8
  22. package/dist/commands/scaffold.js.map +1 -1
  23. package/dist/commands/skill.js +1 -1
  24. package/dist/contract.d.ts +4 -0
  25. package/dist/contract.d.ts.map +1 -1
  26. package/dist/guide.d.ts +27 -0
  27. package/dist/guide.d.ts.map +1 -0
  28. package/dist/guide.js +48 -0
  29. package/dist/guide.js.map +1 -0
  30. package/dist/main.d.ts.map +1 -1
  31. package/dist/main.js +6 -5
  32. package/dist/main.js.map +1 -1
  33. package/dist/migrate/steps.d.ts.map +1 -1
  34. package/dist/migrate/steps.js +14 -0
  35. package/dist/migrate/steps.js.map +1 -1
  36. package/package.json +13 -9
  37. package/schema/browse.schema.json +13 -0
  38. package/schema/inspect.schema.json +65 -1
  39. package/skill/SKILL.md +64 -0
  40. package/skill/example/app.css +1 -0
  41. package/skill/example/app.ts +35 -0
  42. package/skill/example/features/bookmarks/feature.ts +13 -0
  43. package/skill/example/features/bookmarks/model.ts +163 -0
  44. package/skill/example/features/bookmarks/views.ts +156 -0
  45. package/skill/example/hozu.config.ts +34 -0
  46. package/skill/example/previews.ts +13 -0
  47. package/skill/example/routes.ts +11 -0
  48. package/skill/example/ui/badge.ts +11 -0
  49. package/skill/example/ui/button.ts +23 -0
  50. package/skill/example/ui/field.ts +20 -0
  51. package/skill/example/ui/input.ts +33 -0
  52. package/skill/example/ui/kit.ts +7 -0
  53. package/skill/example/ui/tv.ts +12 -0
  54. package/skill/topics/auth.md +56 -0
  55. package/skill/topics/components.md +92 -0
  56. package/skill/topics/content.md +37 -0
  57. package/skill/topics/contracts.md +36 -0
  58. package/skill/topics/data.md +75 -0
  59. package/skill/topics/deploy.md +73 -0
  60. package/skill/topics/diagnostics.md +99 -0
  61. package/skill/topics/endpoints.md +39 -0
  62. package/skill/topics/env.md +44 -0
  63. package/skill/topics/feature.md +90 -0
  64. package/skill/topics/fetch.md +66 -0
  65. package/skill/topics/forms.md +47 -0
  66. package/skill/topics/http.md +21 -0
  67. package/skill/topics/i18n.md +34 -0
  68. package/skill/topics/machine.md +83 -0
  69. package/skill/topics/pages.md +69 -0
  70. package/skill/topics/patterns.md +85 -0
  71. package/skill/topics/recipes.md +79 -0
  72. package/skill/topics/requests.md +41 -0
  73. package/skill/topics/testing.md +82 -0
  74. package/skill/topics/views.md +53 -0
  75. package/templates/guide.md +16 -0
@@ -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:
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` is a link to share, typing filters live): seed the machine from the URL
50
+ and read only the context. A GET form with `name="q"` sets it.
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,79 @@
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
+ - Refresh controls (Pause / Resume / Refresh now): states `live` / `paused`, `refresh: () => [quotesTag()]` on
24
+ `RefreshNow` and on `live`'s `after: [{ ms: 30_000, target: 'live', … }]`; adds return with `done: 'previous'`.
25
+ - Across devices the list needs sign-in and a database instead (`hozu docs auth`).
26
+
27
+ ## A field chosen in the add form (an enum)
28
+ - **model:**
29
+ - `export const Priority = z.enum(['low', 'normal', 'high'])`;
30
+ - add `priority: Priority` to `Item`, `NewItem` and the `Add` payload;
31
+ - context: `priority: Priority`, with `priority: 'normal'` in `initialContext`;
32
+ - `fields` gets `priority: z.string().nullable()`, with `priority: null` in `initialContext` and in the `Add`
33
+ assign that resets it;
34
+ - the `Add` assign also gets `ctx.priority = e.priority`, and the add `invoke` input becomes
35
+ `{ title: ctx.draft, priority: ctx.priority }`.
36
+ - **views:**
37
+ - the form's submit sends `{ title: ui.dom.form('title'), priority: ui.dom.form('priority') }`;
38
+ - inside the form add
39
+ `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])))`;
40
+ - in the item: `ui.span({ class: 'text-xs' }, [item.priority])`.
41
+ - **Contracts:** if the app has contracts that send `Add` or return an item, add `priority` to their payloads,
42
+ inputs and results. These transitions only copy values, so they need no new contract.
43
+ - **server:** store `priority` where the items live (the scaffold's `demoItems` stand-in, or the database) and return it.
44
+
45
+ <!-- more -->
46
+
47
+ With a kit: `ui.use(Button, { variant: { tone: 'quiet' } }, ['Clear done'])`.
48
+
49
+ ## An action button that works on many items (e.g. "Clear done")
50
+ - **model:**
51
+ - `export const ClearDone = event({ payload: z.object({}) })`;
52
+ - `export const clearDone = mutation({ input: z.object({}), output: z.object({ removed: z.number() }), invalidates: () => [itemsTag()], runs: 'server', access: 'anyone' })`;
53
+ - in `idle`: `on(ClearDone, { target: 'clearing', assign: () => { ctx.error = null } })`;
54
+ - a state
55
+ `clearing: { invoke: invoke(clearDone, { input: {}, done: 'idle', failed: { Unexpected: { target: 'idle', assign: () => { ctx.error = 'unexpected' } } } }) }`
56
+ (busy states drop events they do not handle, so no `ignore`).
57
+ - **views:** the control
58
+ `ui.form({ on: { submit: ui.send(ClearDone, {}) } }, [ui.button({ type: 'submit', class: 'text-sm underline' }, ['Clear done'])])`.
59
+ - The new transitions only copy values, so they need no contract (the feature lists `model`, so both are registered).
60
+ - **server:**
61
+ `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).
62
+ - **Try it:** `hozu browse / --do 'click Clear done'`.
63
+
64
+ ## A field shown on the detail page
65
+ In the detail view's `ready`: `ui.p({}, ['Priority: ', item.priority])`. The detail query already returns the whole
66
+ item.
67
+
68
+ ## A detail page, when the feature has none
69
+ Run a fresh scaffold into a scratch app with `--with detail`, and copy the parts it prints:
70
+ - the route with params;
71
+ - the `get` query and its resolver;
72
+ - the detail view;
73
+ - the link in the list;
74
+ - `ui.page(...)` with `head` and `entries`.
75
+
76
+ ## Dark mode
77
+ - Following the system needs no code: Tailwind's `dark:` classes (`bg-white dark:bg-slate-900`).
78
+ - A switch the visitor chooses: a client component (`hozu docs components`) puts `dark` on `<html>` and keeps the
79
+ choice in `localStorage`; `app.css` adds `@custom-variant dark (&:where(.dark, .dark *));`.
@@ -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.
@@ -0,0 +1,82 @@
1
+ # Testing
2
+
3
+ - **Read a page without a server:** `hozu get /path --select 'button[aria-pressed=true]' --forms` (status, title,
4
+ visible text; `--forms` lists each form's fields and submit buttons). Endpoints: `hozu get '/api/items?x=1'`.
5
+ - **Try one query or mutation without a page:** `hozu call notes.listNotes --input '{}' --session '{"user":"ada"}'`;
6
+ a mutation writes real data, so it needs `--write`. An endpoint too:
7
+ `hozu call api.who --input '{"room":"a"}' --header 'Authorization: Bearer t'` (a POST needs `--write`).
8
+ - **Drive the app in a real browser, still without a server:**
9
+ `hozu browse / --session '{"user":"ada"}' --do 'fill New note=Milk' --do 'press Enter' --do 'click Pin in "Milk"'`.
10
+ - Steps: `fill <label>=<value>`, `select <label>=<option>`, `check` / `uncheck <label>`, `click <name>`,
11
+ `submit "<form>"`, `press <key>`, `wait <ms>`, `goto <path>`, `post <path> a=1&b=2`,
12
+ `remember <name> from url|<selector> [@attr]` (later steps read `$name`); a target may end with `in "<text>"`
13
+ (for fill and select, before or after `=value`).
14
+ - Labels are what `hozu get <page> --forms` lists; a missing one prints `Did you mean "…"?`. One `--do` may hold
15
+ several steps: `--do 'fill Title=Milk; press Enter'`.
16
+ - **Other users, other pages, after a reload, after sign-out:** verify any such statement once, in one `browse`
17
+ chain. `--as <name>` starts an actor with its own browser; all actors share one app.
18
+ - A stale form (sent after the data changed elsewhere): `--do 'remember save from form:has([name=title]) @action'`,
19
+ change the data as another `--as`, then `--do 'post $save title=x'`. No server and no curl needed.
20
+ - **The output** is per step only the lines added (`+`) or removed (`−`). A passing six-step run stays under 1.5 KB.
21
+ Exit code 1 when a step failed, the modes differ or an error was printed.
22
+ - **A step that reloads the page** with JS on says `the page reloaded` (a form that should update in place);
23
+ `--full` adds `N elements replaced` (a region drawn again), `--json` has both as `document` / `replaced`.
24
+ - **A pending state:** `--do 'hold notes.addNote'` keeps that effect's answer back; the next steps (and
25
+ `--screenshot`) see the busy UI; `--do 'release'` answers it.
26
+ - `browse` runs the `npm start` app: what only `hozu dev` does (reload on edits, DevTools) is not in it.
27
+ - In code: `const page = await testApp(app).get('/')` from `@hozu/testing` → `{ status, headers, html, text, payload }`.
28
+ - A build with errors renders nothing: run `hozu check`.
29
+
30
+ <!-- more -->
31
+
32
+ - **`hozu get`** prints the status, redirect, `set-cookie` attributes (`HttpOnly`, `SameSite`), title, alerts and
33
+ visible text.
34
+ - `--forms` lists each form: fields with their defaults, checkbox / radio groups with every value (checked ones
35
+ marked ✓), controls that join through `form=` (marked `(form=)`), and submit buttons with their name and value.
36
+ - It needs no browser.
37
+ - **`hozu call`** runs the effect through the app's own handler and prints the value or the declared error. With
38
+ `--write`, a mutation also prints the tags it invalidated and the queries they refresh. `runs: 'browser'` effects
39
+ need `hozu browse`.
40
+ - **`hozu browse`:**
41
+ - It uses the installed Chrome / Chromium / Edge (`HOZU_CHROME=/path` to choose); without one it is a config
42
+ error. The app runs in-process, exactly as `npm start` serves it.
43
+ - `--js off` runs the steps with JS switched off, `--js both` side by side: for a page that must work without it.
44
+ - Steps in detail: a second fill of a repeated name fills the next field; `check` / `uncheck` set the state;
45
+ `click <name>` on a submit button posts with its name and value; `submit "<form>"` takes a form's `aria-label` or
46
+ its submit button text. Labels and names are what a user reads (aria-label, `<label>`, placeholder, button text,
47
+ `title`), or a field's `name`.
48
+ - `in "<text>"` picks the smallest list item, table row or form containing that text
49
+ (`click Delete in "Buy milk"`).
50
+ - A step with no native effect prints `js-only (<reason>)` in the off column, e.g. a `type=button` button.
51
+ - **Actors:** the steps after an `--as <name>` are that actor's, and a later `--as <name>` switches back. `--session`
52
+ right after an `--as` signs that actor in. All actors share one data store and one session store, so what ada
53
+ writes is what bob reads.
54
+ - Signing out and in again inside one actor's chain also works (`click Sign out`, `fill Name=bob`, `press Enter`).
55
+ - `hozu browse /notes --as ada --session '{"user":"ada"}' --as bob --session '{"user":"bob"}' --as ada --do 'click Share in "Milk"' --as bob --do 'goto /inbox'`
56
+ - `--header 'Name: value'` adds a header to every request: before the first `--as` for every actor, after an
57
+ `--as` for that actor.
58
+ - **Another visitor's data:** `--as ada --do 'remember note from li a @href' --as bob --do 'goto $note'` (bob's page
59
+ should answer 403), or `--as bob --do 'post /notes/n1 text=x'`: a forged native post, as bob, without the page.
60
+ Each mode keeps its own remembered values.
61
+ - **The output** is small on purpose: lines print once when both modes agree and per mode where they differ; a
62
+ navigation prints `→ <path>` and the new page's lines; a live update on another actor's page prints under the step
63
+ (`bob: + Milk`).
64
+ - `≠ DIFFERS` marks a step where both modes made a request and the resulting text differs: a no-JS/JS parity bug.
65
+ - Errors: uncaught exceptions, `console.error`s, CSP violations and failed requests, each with the page, the
66
+ resource type and the mode. A 400 re-render of an invalid native post is not an error, and a page answering
67
+ 401, 403, 404 or 410 is the step's status (`→ /notes/n1 (403)`), so an access check exits 0.
68
+ - To forge a post, take the form's `action` from `hozu get <page> --forms` or `remember … @action` on a page the
69
+ server rendered (`goto` it first): forms the client renders after a change carry no `action`.
70
+ - Exit code 1 also when a client component failed. `--json` has every line; `--full` prints them all;
71
+ `--select <css>`, `--screenshot shot.png` (after the steps), `--viewport 390x844` (a phone; default 1280x800) and
72
+ `--reduced-motion`.
73
+ - It also prints the client components on the page (mounted, failed, size, canvases).
74
+ - **`testApp`:** `app` is the default export of `app.ts`; `.post(path, fields)` submits a native form, with fields as
75
+ a record or as `[name, value]` pairs for repeated names. `testApp(app, { session: store })` may swap only the
76
+ session store (a test issuer). `testApp` reads no env files: pass `testApp(app, { env: process.env })` (or a record)
77
+ for the variables your resolvers need; `hozu get`, `call` and `browse` read `env.files` themselves.
78
+ - `hozu get`, `hozu browse` and `testApp` build the app module; a build with errors exits 1 (throws).
79
+ - Vitest: add `hozuTransform()` from `@hozu/transform/vite` to `plugins`.
80
+ - Browser tests: wait for `html[data-hozu-ready]` (set after hydration) before clicking.
81
+ - A person checks the result with `npm run dev` (`hozu dev`: reloads and Hozu DevTools); what they ask for from
82
+ there arrives as requests (`hozu docs requests`). The tools above stay the way you verify.
@@ -0,0 +1,53 @@
1
+ # Views
2
+
3
+ ```ts
4
+ export const Board = ui.view({
5
+ machine: m, // optional: without it, no ctx / when / events, and 0 JS
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
8
+ render: ({ ctx, when, is, params, search, locale }) => ui.main({ class: 'mx-auto max-w-xl' }, [ /* children */ ]),
9
+ })
10
+ ```
11
+ - **Elements:** `ui.<tag>(attrs, children)` for every HTML and SVG element; void tags (`input`, `img`) take only attrs.
12
+ Children are nodes, strings, numbers, data values, and `null` / `false` (render nothing).
13
+ - **Classes:** `class` is a static string of Tailwind classes that must exist (HZ026); conditional classes:
14
+ `toggle: { 'bg-indigo-600 text-white': ctx.tab === t }`. No `style`.
15
+ - **Conditions:** `ctx.error !== null && ui.p({ role: 'alert' }, [ctx.error])`, `item.done ? 'done' : 'open'`.
16
+ By machine state: `when(['adding', 'saving'], [ui.p({}, ['Saving…'])])`; as a value (attributes): `disabled: is(['saving'])`.
17
+ - **Dialogs, popovers, menus:** native, no machine state: `ui.button({ commandfor: 'd', command: 'show-modal' })` +
18
+ `ui.dialog({ id: 'd', closedby: 'any' }, [...])`, `popover` / `popovertarget`, `ui.details`.
19
+ - **Lists:** `ui.each(items, 'id', (item) => ui.li({}, [item.title]))`. Never `.map` over data.
20
+ - **Numbers and dates:** `ui.format.number(q.price, { style: 'currency', currency: 'USD' })`, `ui.format.date(x,
21
+ { dateStyle: 'medium' })`, `ui.format.relative(n, 'day')`, `ui.format.list(xs)` (Intl, the page's locale).
22
+ - **Events:** `on: { click: ui.send(Event, payload) }`; payload fields are literals, data, `ui.dom.value`,
23
+ `ui.dom.form('name')` (submit; `hozu docs forms`).
24
+ - **Links:** `ui.a({ href: ui.link(itemPage, { id: item.id }) }, [...])`; never a string path (HZ032).
25
+ - **Data:** `ui.query(listItems, input, { ready: (items) => …, failed: { NotFound: () => …, Unexpected: () => … } })`;
26
+ `failed` lists every declared error plus `Unexpected`.
27
+ - **Shared UI** (buttons, inputs, fields): `ui.use(Button, { variant, props, on }, ['Save'])` of a kit component
28
+ (`hozu docs components`).
29
+
30
+ <!-- more -->
31
+
32
+ - **Attributes:** HTML names in lower case (`for`, `minlength`, `aria-pressed`, `data-x`), typed per tag. Values are
33
+ literals or data: `'aria-pressed': ctx.show === 'all'`, `title: ctx.error ?? 'OK'`.
34
+ - **CSS variables:** `vars: { '--hue': item.hue }`.
35
+ - **More conditions:** `list.length === 0 ? ui.p({}, ['Empty']) : ui.ul({}, [...])`; a `?:` / `&&` branch may be a list:
36
+ `open ? [a, b] : null`. A query branch or an each item returns one node: wrap several in an element (HZ014).
37
+ With an enter/leave animation: `ui.if(cond, [then], [else], 'fade')` (the motion name is required).
38
+ - **More lists:** `ui.each(tags, null, (t) => …)` for primitives. `.map` only over constants:
39
+ `['a', 'b'].map((k) => ui.option({ value: k }, [k]))`.
40
+ - **Text:** template strings work: `` `${n} items` ``.
41
+ - **Reuse:** `export const row = part((item: Item) => ui.li({}, [item.done ? 'Done' : item.title]))`, called as
42
+ `row(item)`; it is inlined, so the IR equals the inline form. A plain function that receives data is HZ059.
43
+ - **Shared UI:** use the kit component, not a styled `ui.button` per page (`example/` uses a kit).
44
+ - **More events:** any DOM event name plus `visible` (entered the viewport). Payload fields also:
45
+ `ui.dom.formAll('name')`, `ui.dom.checked`, `ui.dom.valueAsNumber`, `ui.dom.key`. `ui.dom.value` / `ui.dom.form`
46
+ fill an enum field only from a `<select>`, radios or submit buttons whose literal values are all members (HZ033).
47
+ - **Search in links:** the third argument of `ui.link` is optional and exists only when the route declares `search`:
48
+ omitted means every default, and a search lists only the fields that differ: `ui.link(home, null, { show: 'done' })`.
49
+ - **More data:** `pending: ui.p({}, ['Loading…'])` is optional; a branch may return `null` to render nothing.
50
+ Server-fetched data is sent with the page and never fetched again; after a mutation, queries whose tags it
51
+ invalidates refresh in place.
52
+ - **Also:** `ui.html(post.html)` (trusted HTML from query data only, HZ030), `ui.asset(new URL('./x.png',
53
+ import.meta.url))`, `ui.window({ on })` / `ui.document({ on })`, `ui.embed(OtherView)`.
@@ -0,0 +1,16 @@
1
+ # __NAME__
2
+
3
+ A web app built with Hozu (`@hozu/*`). Hozu is not in your training data.
4
+
5
+ ## Before writing code
6
+ - __READ__ It has the change loop, the commands and the rules no diagnostic checks.
7
+ - The files in `__SKILL__/` are the whole API. Do not read the framework source in `node_modules/@hozu`.
8
+ __NOTE__
9
+ ## Rules
10
+ - After `hozu add feature`, do not print the generated files: edit the texts it lists; `hozu map` shows the rest.
11
+ - Apply the fix each diagnostic gives; do not work around a rule.
12
+ - `__RUN__ hozu get` and `__RUN__ hozu browse` run the same app as `npm start`; do not start a server to check.
13
+ - Asked to do the Hozu requests (from DevTools under `npm run dev`): `__RUN__ hozu requests --full`, then follow
14
+ `__RUN__ hozu docs requests`.
15
+ - Do not edit `__SKILL__/` or the text between the `hozu` markers: `__RUN__ hozu skill` rewrites both for the
16
+ installed Hozu version.