create-hozu 0.6.0 → 0.8.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 (41) hide show
  1. package/dist/guide.d.ts +29 -0
  2. package/dist/guide.d.ts.map +1 -0
  3. package/dist/guide.js +83 -0
  4. package/dist/guide.js.map +1 -0
  5. package/dist/index.d.ts +12 -3
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +18 -33
  8. package/dist/index.js.map +1 -1
  9. package/package.json +1 -1
  10. package/skill/SKILL.md +39 -92
  11. package/skill/example/{server.ts → app.ts} +10 -9
  12. package/skill/example/features/bookmarks/feature.ts +13 -0
  13. package/skill/example/features/bookmarks/views.ts +2 -95
  14. package/skill/example/hozu.config.ts +4 -1
  15. package/skill/topics/auth.md +10 -6
  16. package/skill/topics/content.md +1 -1
  17. package/skill/topics/contracts.md +8 -5
  18. package/skill/topics/data.md +19 -6
  19. package/skill/topics/deploy.md +13 -9
  20. package/skill/topics/diagnostics.md +28 -11
  21. package/skill/topics/endpoints.md +21 -9
  22. package/skill/topics/feature.md +79 -0
  23. package/skill/topics/forms.md +14 -3
  24. package/skill/topics/http.md +1 -1
  25. package/skill/topics/i18n.md +11 -6
  26. package/skill/topics/machine.md +8 -2
  27. package/skill/topics/pages.md +18 -4
  28. package/skill/topics/patterns.md +34 -4
  29. package/skill/topics/recipes.md +48 -0
  30. package/skill/topics/testing.md +42 -17
  31. package/skill/topics/views.md +12 -7
  32. package/skill/topics/widgets.md +6 -4
  33. package/templates/app/app.ts +7 -0
  34. package/templates/app/features/site/feature.ts +8 -0
  35. package/templates/app/features/site/views.ts +1 -7
  36. package/templates/app/hozu.config.ts +3 -1
  37. package/templates/guide.md +4 -16
  38. package/skill/changing.md +0 -91
  39. package/skill/example/serve.ts +0 -14
  40. package/templates/app/serve.ts +0 -14
  41. package/templates/app/server.ts +0 -6
@@ -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') }) } }, [
@@ -33,7 +45,25 @@ ui.form({ on: { submit: ui.send(Toggle, { id: ui.dom.form('id') }) } }, [
33
45
  // machine: on(Toggle, { target: 'toggling', assign: (e) => { ctx.target = e.id } })
34
46
  // toggling: { invoke: invoke(toggleItem, { input: { id: ctx.target }, done: 'idle', failed: { Unexpected: 'idle' } }) }
35
47
  ```
36
- Try it without a server: `hozu post / --field title=x --next 'POST / id=i1&@Done' --next /`.
48
+ Try it without a server: `hozu browse / --do 'fill Title=x' --do 'press Enter' --do 'click Done in "x"'`.
49
+ - **Select many, then act** (bulk delete): checkboxes in the list join one form through a formRef; the invoke
50
+ state drops events, so the checkboxes are disabled while it runs:
51
+ ```ts
52
+ const bulk = ui.formRef() // module level; context { selected: z.array(z.string()), busy: z.boolean() }
53
+ ui.form({ ref: bulk, on: { submit: ui.send(Bulk, { ids: ui.dom.formAll('ids'), action: ui.dom.form('action') }) } }, [
54
+ ui.button({ type: 'submit', name: 'action', value: 'delete' }, ['Delete selected']),
55
+ ui.button({ type: 'submit', name: 'action', value: 'pin' }, ['Pin selected']),
56
+ ])
57
+ ui.each(items, 'id', (item) => ui.li({}, [ui.input({ type: 'checkbox', form: bulk, name: 'ids', value: item.id,
58
+ 'aria-label': `Select ${item.text}`, checked: ctx.selected.includes(item.id), disabled: ctx.busy,
59
+ on: { change: ui.send(Select, { id: item.id, checked: ui.dom.checked }) } }), item.text]))
60
+ // on(Select, { target: 'idle', guard: (e) => e.checked === true, assign: (e) => { ctx.selected.push(e.id) } }),
61
+ // on(Select, { target: 'idle', assign: (e) => { ctx.selected = ctx.selected.filter((id) => id !== e.id) } }),
62
+ // on(Bulk, { target: 'removingMany', guard: (e) => e.action === 'delete', assign: (e) => { ctx.selected = e.ids; ctx.busy = true } }),
63
+ // removingMany: invoke(removeNotes, { input: { ids: ctx.selected }, done/failed: reset selected and busy })
64
+ ```
65
+ The mutation input holds the limit (`z.array(z.string()).min(1, 'Select at least one note')`). Reference app:
66
+ `examples/notes`.
37
67
  - **Sorted or pinned first:** sort in the resolver (the list query returns items in display order), or in a `fn`.
38
68
  - **Refresh after a mutation:** tag the query, list the tag in the mutation's `invalidates`.
39
69
  - **Go to what was just created:** `done: { target: 'idle', navigate: (r) => ui.link(itemPage, { id: r.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 browse / --do 'click Clear done'` (with and without JS).
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`.
@@ -1,21 +1,46 @@
1
1
  # Testing
2
2
 
3
- - **Without a server** (no need to start one): `hozu get /path --select 'button[aria-pressed=true]' --forms` and
4
- `hozu post / --field title=A --next 'POST / @Delete' --next /`.
5
- - `post` submits like a browser **without JavaScript** (a native form post), so it also checks no-JS behaviour.
6
- - Each step prints its status, redirect and `set-cookie` attributes (`HttpOnly`, `SameSite`); the session cookie
7
- is kept across `--next` steps. Two users: run two commands.
8
- - Endpoints: `hozu get '/api/items?x=1'`.
9
- - **In a real browser, still without a server:** `hozu browse / --do 'fill Search=park' --do 'click Tech Park'`.
10
- - It uses the installed Chrome / Chromium / Edge (`HOZU_CHROME=/path` to choose), loads the page, waits for
11
- hydration and runs the steps in order: `fill <label>=<value>`, `select <label>=<option>`, `check <label>`,
12
- `click <name>`, `press <key>`, `wait <ms>`, `goto <path>`. Labels and names are what a user reads (aria-label,
13
- `<label>`, placeholder, button text, `title`).
14
- - It prints the uncaught exceptions, `console.error`s and failed requests, every widget on the page (mounted,
15
- failed, size, canvases), the visible text and `--select <css>` elements; exit code 1 when anything failed.
16
- - `--screenshot shot.png` saves the viewport (open it to look); `--reduced-motion` emulates reduced motion.
17
- - Use it once after client-side work (widgets, islands); `get` / `post` stay the fast checks.
18
- - In code: `const app = testApp({ build, resolvers })` from `@hozu/testing`; `await app.get('/')` →
19
- `{ status, headers, html, text, payload }`; `app.post(path, fields)` submits a native form.
3
+ - **Read a page without a server:** `hozu get /path --select 'button[aria-pressed=true]' --forms`.
4
+ - It prints the status, redirect, `set-cookie` attributes (`HttpOnly`, `SameSite`), title, alerts and visible text.
5
+ - `--forms` lists each form: fields with their defaults, checkbox / radio groups with every value (checked ones
6
+ marked ✓), controls that join through `form=` (marked `(form=)`), and submit buttons with their name and value.
7
+ - Endpoints: `hozu get '/api/items?x=1'`. It needs no browser.
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
+ - It uses the installed Chrome / Chromium / Edge (`HOZU_CHROME=/path` to choose); without one it is a config
11
+ error. The app runs in-process, exactly as `npm start` serves it.
12
+ - `--js both` (the default) runs every step with JS and with JS switched off in the same Chrome, side by side. Use
13
+ `--js on` or `--js off` for one mode.
14
+ - Steps: `fill <label>=<value>` (a second fill of a repeated name fills the next field), `select <label>=<option>`,
15
+ `check <label>` / `uncheck <label>` (set the state), `click <name>` (a submit button posts with its name and
16
+ value), `submit "<form>"` (a form's `aria-label` or its submit button text), `press <key>`, `wait <ms>`,
17
+ `goto <path>`. Labels and names are what a user reads (aria-label, `<label>`, placeholder, button text,
18
+ `title`), or a field's `name`.
19
+ - A target may end with `in "<text>"`: the smallest list item, table row or form containing that text
20
+ (`click Delete in "Buy milk"`).
21
+ - To drive both modes with one step list, submit with `press Enter` or `submit "<form>"`. A step with no native
22
+ effect prints `js-only (<reason>)` in the off column, e.g. a `type=button` button.
23
+ - **Other users, other pages, after a reload, after sign-out:** verify any such statement once, in one `browse`
24
+ chain with `--js both`.
25
+ - `--as <name>` starts an actor with its own browser; the steps after it are that actor's, and a later
26
+ `--as <name>` switches back. `--session` right after an `--as` signs that actor in. All actors share one app
27
+ (one data store, one session store), so what ada writes is what bob reads.
28
+ - Signing out and in again inside one actor's chain also works (`click Sign out`, `fill Name=bob`, `press Enter`).
29
+ - `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'`
30
+ - **The output** is small on purpose: per step, only the lines it added (`+`) or removed (`−`), once when both modes
31
+ agree and per mode where they differ; a navigation prints `→ <path>` and the new page's lines; a live update on
32
+ another actor's page prints under the step (`bob: + Milk`). A passing six-step run stays under 1.5 KB.
33
+ - `≠ DIFFERS` marks a step where both modes made a request and the resulting text differs: a no-JS/JS parity bug.
34
+ - Errors: uncaught exceptions, `console.error`s, CSP violations and failed requests, each with the page, the
35
+ resource type and the mode. A 400 re-render of an invalid native post is not an error.
36
+ - Exit code 1 when a step failed, the modes differ, a widget failed or any error was printed. `--json` has every
37
+ line; `--full` prints them all; `--select <css>`, `--screenshot shot.png` and `--reduced-motion` as before.
38
+ - It also prints the widgets on the page (mounted, failed, size, canvases).
39
+ - In code: `const page = await testApp(app).get('/')` from `@hozu/testing`, with `app` the default export of
40
+ `app.ts` → `{ status, headers, html, text, payload }`; `.post(path, fields)` submits a native form, with fields as
41
+ a record or as `[name, value]` pairs for repeated names. `testApp(app, { session: store })` may swap only the
42
+ session store (a test issuer).
43
+ - `hozu get`, `hozu browse` and `testApp` build the app module; a build with errors exits 1 (throws) and renders
44
+ nothing: run `hozu check`.
20
45
  - Vitest: add `hozuTransform()` from `@hozu/transform/vite` to `plugins`.
21
46
  - Browser tests: wait for `html[data-hozu-ready]` (set after hydration) before clicking.
@@ -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
  ```
@@ -14,20 +15,24 @@ export const Board = ui.view({
14
15
  - **Classes:** `class` is a static string of Tailwind classes that must exist (HZ026). Conditional classes:
15
16
  `toggle: { 'bg-indigo-600 text-white': ctx.tab === t }`. CSS variables: `vars: { '--hue': item.hue }`. No `style`.
16
17
  - **Conditions:** `ctx.error !== null && ui.p({ role: 'alert' }, [ctx.error])`, `item.done ? 'done' : 'open'`,
17
- `list.length === 0 ? ui.p({}, ['Empty']) : ui.ul({}, [...])`. With an enter/leave animation:
18
- `ui.if(cond, [then], [else], 'fade')`.
18
+ `list.length === 0 ? ui.p({}, ['Empty']) : ui.ul({}, [...])`; a branch may be a list: `open ? [a, b] : null`, also as what a query branch or an each item returns.
19
+ With an enter/leave animation: `ui.if(cond, [then], [else], 'fade')` (the motion name is required).
19
20
  - **By machine state:** `when(['adding', 'saving'], [ui.p({}, ['Saving…'])])`.
20
21
  - **Lists:** `ui.each(items, 'id', (item) => ui.li({}, [item.title]))`; `ui.each(tags, null, (t) => …)` for primitives.
21
22
  Never `.map` over data (only over constants: `['a', 'b'].map((k) => ui.option({ value: k }, [k]))`).
22
23
  - **Text:** template strings work: `` `${n} items` ``.
24
+ - **Reuse:** `export const row = part((item: Item) => ui.li({}, [item.done ? 'Done' : item.title]))`, called as
25
+ `row(item)`; it is inlined, so the IR equals the inline form. A plain function that receives data is HZ059.
23
26
  - **Events:** `on: { click: ui.send(Event, payload) }`, any DOM event name plus `visible` (entered the viewport).
24
- Payload fields: literals, data, `ui.dom.value`, `ui.dom.form('name')`, `ui.dom.checked`, `ui.dom.valueAsNumber`,
25
- `ui.dom.key`. `ui.dom.value` / `ui.dom.form` fill an enum field only from a `<select>` or radios whose literal
26
- option values are all members (HZ033).
27
+ Payload fields: literals, data, `ui.dom.value`, `ui.dom.form('name')` / `ui.dom.formAll('name')` (submit; `hozu docs
28
+ forms`), `ui.dom.checked`, `ui.dom.valueAsNumber`, `ui.dom.key`. `ui.dom.value` / `ui.dom.form` fill an enum field
29
+ only from a `<select>`, radios or submit buttons whose literal values are all members (HZ033).
27
30
  - **Links:** `ui.a({ href: ui.link(itemPage, { id: item.id }) }, [...])`; never a string path (HZ032). The third
28
- argument exists only when the route declares `search`: `ui.link(home, null, { show: 'done' })`.
31
+ argument is optional and exists only when the route declares `search`: omitted means every default, and a search
32
+ lists only the fields that differ: `ui.link(home, null, { show: 'done' })`.
29
33
  - **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`.
34
+ () => …, Unexpected: () => … } })`; `pending` is optional, `failed` lists every declared error plus `Unexpected`; a branch may return `null` to render
35
+ nothing.
31
36
  Server-fetched data is sent with the page and never fetched again; after a mutation, queries whose tags it
32
37
  invalidates refresh in place.
33
38
  - **Also:** `ui.html(post.html)` (trusted HTML from query data only, HZ030), `ui.asset(new URL('./x.png',
@@ -1,11 +1,11 @@
1
1
  # Widgets (browser APIs, DOM libraries)
2
2
 
3
- Start with `hozu add widget <feature> <Name>`: it writes the declaration, the client module, the `serve.ts` bundle
3
+ Start with `hozu add widget <feature> <Name>`: it writes the declaration, the client module, the bundle in `app.ts`
4
4
  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,9 +19,11 @@ 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
- - `serve.ts` passes `widgets: await bundleWidgets(build)` (the server refuses to start without it; `hozu build` bundles
24
- them itself). A library's CSS goes in `app.css` (`@import "leaflet/dist/leaflet.css";`); a map or chart host needs a
25
+ - `app.ts` passes `widgets: bundleWidgets` to `app()` (`import { bundleWidgets } from '@hozu/bundle'`; without it
26
+ the server refuses to start and `hozu check` reports HZ045; `hozu build` bundles them itself). A library's CSS goes in `app.css` (`@import "leaflet/dist/leaflet.css";`); a map or chart host needs a
25
27
  height class.
26
28
  - Check it with `hozu browse /` (no server): each widget is listed as mounted / failed / not mounted with its size
27
29
  and canvases, next to any error it threw. A mounted host carries `data-hozu-widget="<feature>.<Name>"` and
@@ -0,0 +1,7 @@
1
+ import { resolvers } from '@hozu/data'
2
+ import { app } from '@hozu/runtime-server'
3
+ import project from './hozu.config.ts'
4
+
5
+ export default app({
6
+ resolvers: resolvers(project, () => []),
7
+ })
@@ -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,11 +1,13 @@
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({
7
8
  schema: zodAdapter,
8
9
  styles: new URL('./app.css', import.meta.url),
10
+ app: new URL('./app.ts', import.meta.url),
9
11
  site: { url: 'http://localhost:3000', name: '__NAME__', lang: 'en' },
10
12
  routes: { home },
11
13
  pages: [ui.page(home, { views: [Home], head: { render: () => ({ title: '__NAME__' }) } })],
@@ -3,24 +3,12 @@
3
3
  A web app built with Hozu (`@hozu/*`). Hozu is not in your training data.
4
4
 
5
5
  ## Before writing code
6
- - __READ__
7
- - Changing existing code: read `__SKILL__/changing.md` first.
6
+ - __READ__ It has the change loop, the commands and the rules no diagnostic checks.
8
7
  - The files in `__SKILL__/` are the whole API. Do not read the framework source in `node_modules/@hozu`.
9
-
10
- ## The loop
11
- ```
12
- __RUN__ hozu add feature tasks --page / --with detail,toggle,filter,remove # then edit the texts it lists
13
- # add auth to the list for sign-in and per-user data
14
- __RUN__ hozu map # outline of the app with file:line, before a change
15
- __RUN__ hozu check # after every change: types, rules, contracts
16
- __RUN__ hozu check --update-lock # only to accept a clean, intended behaviour change
17
- __RUN__ hozu get / --select button --forms # try pages without a server: text, attributes, forms
18
- __RUN__ hozu post / --field title=Ship --next / # submit a form like a browser
19
- __RUN__ hozu browse / --do 'click Save' # real browser, no server: errors, widgets, text after steps
20
- ```
21
8
  __NOTE__
22
9
  ## Rules
23
10
  - After `hozu add feature`, do not print the generated files: edit the texts it lists; `hozu map` shows the rest.
24
11
  - Apply the fix each diagnostic gives; do not work around a rule.
25
- - Every behaviour change comes with a contract change.
26
- - Do not edit `__SKILL__/`: `__RUN__ hozu skill` rewrites it for the installed Hozu version.
12
+ - `__RUN__ hozu get` and `__RUN__ hozu browse` run the same app as `npm start`; do not start a server to check.
13
+ - Do not edit `__SKILL__/` or the text between the `hozu` markers: `__RUN__ hozu skill` rewrites both for the
14
+ installed Hozu version.
package/skill/changing.md DELETED
@@ -1,91 +0,0 @@
1
- # Changing a Hozu app
2
-
3
- Keep the loop short: map once, edit everything, check once, verify once.
4
-
5
- ## 1. Read
6
- - The change request.
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.
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
62
- | Change | Touch |
63
- |---|---|
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). |
67
-
68
- Whenever the machine changes:
69
- - A transition that decides something (a guard, `navigate`, or a `fn` in its values) needs a contract; HZ016
70
- prints each missing one ready to paste. Transitions that only copy values are reviewed through the lock.
71
- - States with `invoke` drop unhandled events by themselves. Other states must handle or `ignore` every event their
72
- visible controls send; HZ005 prints the missing entries.
73
-
74
- ## 3. Check (once, after all edits)
75
- ```
76
- pnpm exec hozu check
77
- ```
78
- Fix what it reports. When the behaviour change is intended, run `pnpm exec hozu check --update-lock`: HZ018 shows
79
- each changed transition as `was: … now: …`, and accepting it updates the lock.
80
-
81
- ## 4. Verify (once, no server needed)
82
- - **Pages:** `pnpm exec hozu get / /items/i1` prints the status, title, alerts and visible text.
83
- - **Attributes and forms:** `--select button` (or `'[role=alert]'`, `a[href]`, `#id`) prints elements with their
84
- attributes, e.g. `aria-pressed`; `--forms` lists each form's fields and buttons. Never start a server for this.
85
- - **Forms:** `pnpm exec hozu post / --field title=A --field priority=high --next /items` fills the form like a
86
- browser (other fields keep their defaults). It follows the redirect, then runs the next steps in the same
87
- process.
88
- - **Chaining:** chain what must share data, e.g. `--next 'POST / title=a'` (fields as `a=1&b=2`) or
89
- `--next /items/i3`.
90
- - **A form with only a button:** `--button 'Clear done'`, or `--next 'POST / @Clear done'`. With one form per item,
91
- `--field id=t2` picks the item's form.
@@ -1,14 +0,0 @@
1
- import { createServer } from '@hozu/adapter-node'
2
- import { buildProject } from '@hozu/core/ir'
3
- import { compileStyles } from '@hozu/css'
4
- import project from './hozu.config.ts'
5
- import { createResolvers } from './server.ts'
6
-
7
- const port = Number(process.env.PORT ?? 3000)
8
- const build = buildProject(project, { sources: false })
9
-
10
- createServer({
11
- build,
12
- styles: await compileStyles(build),
13
- resolvers: createResolvers(),
14
- }).listen(port, () => console.log(`Bookmarks on http://localhost:${port}`))
@@ -1,14 +0,0 @@
1
- import { createServer } from '@hozu/adapter-node'
2
- import { buildProject } from '@hozu/core/ir'
3
- import { compileStyles } from '@hozu/css'
4
- import project from './hozu.config.ts'
5
- import { createResolvers } from './server.ts'
6
-
7
- const port = Number(process.env.PORT ?? 3000)
8
- const build = buildProject(project, { sources: false })
9
-
10
- createServer({
11
- build,
12
- styles: await compileStyles(build),
13
- resolvers: createResolvers(),
14
- }).listen(port, () => console.log(`__NAME__ on http://localhost:${port}`))
@@ -1,6 +0,0 @@
1
- import { resolvers } from '@hozu/data'
2
- import project from './hozu.config.ts'
3
-
4
- export function createResolvers() {
5
- return resolvers(project, () => [])
6
- }