@hozu/cli 0.24.0 → 0.26.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 (68) hide show
  1. package/CHANGELOG.md +1510 -0
  2. package/README.md +3 -1
  3. package/dist/commands/add.d.ts +1 -1
  4. package/dist/commands/add.d.ts.map +1 -1
  5. package/dist/commands/add.js +3 -3
  6. package/dist/commands/add.js.map +1 -1
  7. package/dist/commands/browse-page.d.ts.map +1 -1
  8. package/dist/commands/browse-page.js +12 -4
  9. package/dist/commands/browse-page.js.map +1 -1
  10. package/dist/commands/browse-tab.d.ts +6 -1
  11. package/dist/commands/browse-tab.d.ts.map +1 -1
  12. package/dist/commands/browse-tab.js +38 -12
  13. package/dist/commands/browse-tab.js.map +1 -1
  14. package/dist/commands/browse-world.d.ts.map +1 -1
  15. package/dist/commands/browse-world.js +73 -9
  16. package/dist/commands/browse-world.js.map +1 -1
  17. package/dist/commands/browse.d.ts +2 -0
  18. package/dist/commands/browse.d.ts.map +1 -1
  19. package/dist/commands/browse.js +15 -7
  20. package/dist/commands/browse.js.map +1 -1
  21. package/dist/commands/explain.d.ts.map +1 -1
  22. package/dist/commands/explain.js +17 -3
  23. package/dist/commands/explain.js.map +1 -1
  24. package/dist/commands/request.d.ts +1 -0
  25. package/dist/commands/request.d.ts.map +1 -1
  26. package/dist/commands/request.js +14 -4
  27. package/dist/commands/request.js.map +1 -1
  28. package/dist/commands/scaffold.d.ts.map +1 -1
  29. package/dist/commands/scaffold.js +4 -5
  30. package/dist/commands/scaffold.js.map +1 -1
  31. package/dist/commands/target.d.ts +17 -0
  32. package/dist/commands/target.d.ts.map +1 -0
  33. package/dist/commands/target.js +168 -0
  34. package/dist/commands/target.js.map +1 -0
  35. package/dist/commands/validate.d.ts.map +1 -1
  36. package/dist/commands/validate.js +10 -3
  37. package/dist/commands/validate.js.map +1 -1
  38. package/dist/contract.d.ts +10 -0
  39. package/dist/contract.d.ts.map +1 -1
  40. package/dist/main.d.ts.map +1 -1
  41. package/dist/main.js +20 -2
  42. package/dist/main.js.map +1 -1
  43. package/dist/migrate/steps.d.ts.map +1 -1
  44. package/dist/migrate/steps.js +33 -0
  45. package/dist/migrate/steps.js.map +1 -1
  46. package/package.json +10 -9
  47. package/schema/browse.schema.json +17 -0
  48. package/schema/inspect.schema.json +2 -1
  49. package/schema/why.schema.json +6 -1
  50. package/skill/SKILL.md +4 -5
  51. package/skill/example/features/bookmarks/views.ts +10 -12
  52. package/skill/example/ui/button.ts +6 -2
  53. package/skill/topics/auth.md +2 -2
  54. package/skill/topics/components.md +2 -2
  55. package/skill/topics/contracts.md +3 -1
  56. package/skill/topics/data.md +3 -3
  57. package/skill/topics/deploy.md +18 -14
  58. package/skill/topics/diagnostics.md +8 -8
  59. package/skill/topics/feature.md +5 -5
  60. package/skill/topics/forms.md +2 -0
  61. package/skill/topics/http.md +2 -1
  62. package/skill/topics/i18n.md +4 -2
  63. package/skill/topics/machine.md +10 -5
  64. package/skill/topics/pages.md +3 -1
  65. package/skill/topics/patterns.md +7 -6
  66. package/skill/topics/recipes.md +15 -6
  67. package/skill/topics/testing.md +9 -0
  68. package/skill/topics/views.md +29 -14
@@ -29,7 +29,7 @@ export const items = machine({
29
29
  done: { target: 'idle', assign: () => { ctx.draft = '' } },
30
30
  failed: {
31
31
  Duplicate: { target: 'idle', assign: () => { ctx.error = 'Already listed' } },
32
- Unexpected: { target: 'idle', assign: (e) => { ctx.error = e.message } },
32
+ Unexpected: { target: 'idle', assign: () => { ctx.error = 'Try again' } },
33
33
  },
34
34
  }),
35
35
  },
@@ -38,14 +38,14 @@ export const items = machine({
38
38
  // views.ts
39
39
  export const Board = ui.view({
40
40
  machine: items,
41
- render: ({ ctx, when }) =>
41
+ render: ({ ctx, is }) =>
42
42
  ui.main({ class: 'mx-auto max-w-xl' }, [
43
43
  ui.form({ on: { submit: ui.send(Add, { title: ui.dom.form('title') }) } }, [
44
44
  ui.input({ name: 'title', required: true, value: ctx.draft }),
45
- ui.button({ type: 'submit' }, ['Add']),
45
+ ui.button({ type: 'submit', disabled: is(['adding']) }, ['Add']),
46
46
  ]),
47
47
  ctx.error !== null && ui.p({ role: 'alert' }, [ctx.error]),
48
- when(['adding'], [ui.p({ 'aria-busy': 'true' }, [`Adding ${ctx.draft}…`])]),
48
+ is(['adding']) && ui.p({ 'aria-busy': 'true' }, [`Adding ${ctx.draft}…`]),
49
49
  ui.query(listItems, {}, {
50
50
  ready: (list) => ui.ul({}, [ui.each(list, 'id', (i) => ui.li({}, [i.title, i.done ? ' ✓' : '']))]),
51
51
  failed: { Unexpected: () => ui.p({ role: 'alert' }, ['Unavailable']) },
@@ -73,7 +73,7 @@ Every declaration a listed module exports is registered under its name; schemas
73
73
  - A mutation runs when the machine **enters** a state whose `invoke` calls it; that state drops other events, and
74
74
  `done` / `failed` leave it.
75
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.
76
+ `machine({ on })` holds transitions every state without `invoke` shares; `fn` bodies may call helpers from the same module.
77
77
  - Reusable view logic is a `part((…) => …)`, inlined where it is used (`hozu docs views`).
78
78
 
79
79
  ## Files
@@ -43,5 +43,7 @@ ui.use(Button, { props: { type: 'submit' } }, ['Add']),
43
43
  `ui.form({ ref: bulk, … })`, `ui.input({ form: bulk, … })`; a string `form` is HZ014, a name no control has HZ055.
44
44
  - **A flag or a number:** a checkbox posts `'on'` only while checked: `ui.dom.formAll('remember')` into
45
45
  `z.array(z.string())`, or a radio pair. Send numbers as text and parse them in the mutation input (`z.coerce.number()`).
46
+ - **Several steps without JavaScript** (a checkout): each step is a state; every form carries the machine's state
47
+ in a signed hidden field, so the next native post continues from it (`hozu docs recipes`, "A multi-step checkout").
46
48
  - **An invalid native post:** a native post whose payload or mutation input fails re-renders with 400 through
47
49
  `failed.Invalid`, like the JS submit.
@@ -17,6 +17,7 @@ http: {
17
17
  headers: [{ routes: 'all', set: { 'permissions-policy': 'camera=()' } }], // not cache-control (HZ038)
18
18
  },
19
19
  ```
20
- Server options live in the app module: `app({ resolvers, session?, components?, onError?, csp?, og?, preview? })`.
20
+ Server options live in the app module: `app({ resolvers, session?, components?, onError?, csp?, og?, preview?,
21
+ refreshSession?, dispose?, dataCache?, cache?, bus?, staticTtl? })` (`hozu docs deploy`).
21
22
  A strict CSP, `nosniff` and a cross-site POST check are on by default; `csp: { img: ['https://…'] }` adds sources
22
23
  (`script style img font connect frame media`).
@@ -6,7 +6,7 @@
6
6
  - `export const text = ui.messages('en', { en: { saved: '{count} saved' }, de: { saved: '{count} gespeichert' } })`
7
7
  in a listed module; `text.saved({ count })` in views. Every locale needs every key (HZ040).
8
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
9
+ - **Data per language:** `locale` (in every view, 2nd argument of `head.input`, 3rd of `head.render`) goes into
10
10
  the query input: `ui.query(listPosts, { locale })`; `head: { input: (params, locale) => ({ slug: params.slug,
11
11
  locale }) }`. It is typed `string`: declare the input `z.string()`, or narrow it with `locale as Locale`.
12
12
 
@@ -23,7 +23,9 @@
23
23
  `{placeholders}` (HZ040). Plurals: `'{n, plural, =0 {none} one {# item} other {# items}}'`.
24
24
  - Machines store a code and the view chooses the message.
25
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.
26
+ `ui.format.relative(n, 'day')`, `ui.format.list(xs)`, `ui.format.plural(n, { one: '# item', other: '# items' })`.
27
+ `locale` is in every view.
28
+ - Head signatures: `input: (params, locale, search)`, `render: (data, params, locale, search)`.
27
29
  - Resolvers have no locale of their own; it reaches them only through the input. The sitemap lists one entry per
28
30
  `entries` input in every locale.
29
31
  - `ui.format.date` takes an ISO string or a timestamp and formats it in the page's locale, in the time zone of the
@@ -11,7 +11,7 @@ export const m = machine({
11
11
  invoke: invoke(addItem, {
12
12
  input: { title: ctx.draft },
13
13
  done: { target: 'idle', assign: () => { ctx.draft = '' } },
14
- failed: { Unexpected: { target: 'idle', assign: (e) => { ctx.error = e.message } } },
14
+ failed: { Unexpected: { target: 'idle', assign: () => { ctx.error = 'Try again' } } },
15
15
  }),
16
16
  },
17
17
  }),
@@ -29,8 +29,8 @@ export const m = machine({
29
29
  - `target: 'previous'` (or `done: 'previous'`) returns to the last state without `invoke`, so a busy state entered
30
30
  from two modes (viewing, editing) needs no copy per mode.
31
31
  - **refresh** reads the page's queries with those tags again: `on(RefreshNow, { refresh: () => [quotesTag()] })`;
32
- every 30 s while live: `live: { after: [{ ms: 30_000, target: 'live', refresh: () => [quotesTag()] }] }` (Pause is
33
- another state). **copy** writes text to the clipboard: `on(CopyLink, { copy: (e) => e.url })` (on an event: the
32
+ every 30 s unless paused: `after: [{ ms: 30_000, target: 'idle', guard: () => !ctx.paused, refresh: … }]`. A mode
33
+ the person sets (paused) is a context field: busy states keep it. **copy** writes text to the clipboard: `on(CopyLink, { copy: (e) => e.url })` (on an event: the
34
34
  browser allows it only right after a click). **replace** writes the address without loading a page:
35
35
  `replace: () => ui.link(home, null, { q: ctx.q })`, so a reload or a shared link keeps it (with `seed`). Effects
36
36
  and `navigate` read the context after `assign`.
@@ -59,7 +59,7 @@ export const m = machine({
59
59
  done: { target: 'idle', assign: () => { ctx.draft = '' }, navigate: (r) => ui.link(itemPage, { id: r.id }) },
60
60
  failed: { // every declared error + Unexpected (+ optional Invalid)
61
61
  Duplicate: { target: 'idle', assign: () => { ctx.error = 'Already exists' } },
62
- Unexpected: { target: 'idle', assign: (e) => { ctx.error = e.message } },
62
+ Unexpected: { target: 'idle', assign: () => { ctx.error = 'unexpected' } }, // a code; the view words it
63
63
  },
64
64
  }),
65
65
  },
@@ -68,7 +68,12 @@ export const m = machine({
68
68
  }),
69
69
  })
70
70
  ```
71
- - Calm state follows the visitor to a page that shows the same view, and back to the same address.
71
+ - **Across pages:** the calm state (the last state without `invoke`, with its context) is kept in `sessionStorage`
72
+ and resumed on a page that shows the same machine view, or on the same address; a reload or a page without that
73
+ view starts from `initialContext` (or `seed`).
74
+ - `Unexpected`'s `message` is `Internal error (call <id>)` in production (the real one reaches `onError`). A page
75
+ for customers stores a code or a fixed text; a staff tool may show `e.message`, whose call id matches the server
76
+ log line.
72
77
  - **assign** values are event (`e`), result (`r`) or error fields, context, literals, operators and `fn()` calls.
73
78
  - **guard** conditions: a field (`() => ctx.auto`), comparisons, `&&`, `||`, `!`, or a boolean `fn()`.
74
79
  - **navigate** sends the browser to `ui.link(route, params, search?)` after the transition. It returns one link: to
@@ -66,4 +66,6 @@ export default project({
66
66
  - A detail view: `ui.view({ route: itemPage, render: ({ params }) => ui.query(getItem, { id: params.id }, { ready,
67
67
  failed: { NotFound: () => ui.p({}, ['Not found']), Unexpected: () => … } }) })`.
68
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.
69
+ a machine whose view both pages show (or the same address) resumes its calm state, the last state without `invoke`,
70
+ from `sessionStorage`; a reload or a page without that view starts from `initialContext` (or `seed`). What must
71
+ survive a reload lives in the URL (`seed`), on the server (queries) or in a client component's own storage.
@@ -3,9 +3,9 @@
3
3
  The controls are plain elements; in an app with a kit, use its components (`ui.use(Button, …)`,
4
4
  `hozu docs components`).
5
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
6
+ - **Busy state:** render every control once and disable it: `disabled: is(['adding'])` (`invoke` drops repeats).
7
+ Progress: `is(['adding']) && ui.p({ 'aria-busy': 'true' }, ['Saving…'])`.
8
+ - **Optimistic item:** `is(['adding']) && ui.li({ class: 'opacity-50' }, [ctx.draft])`; leaving the state removes it
9
9
  and the refreshed query shows the real item.
10
10
  - **Refresh after a mutation:** tag the query, list the tag in the mutation's `invalidates`.
11
11
  - **Go to what was just created:** `done: { target: 'idle', navigate: (r) => ui.link(itemPage, { id: r.id }) }`.
@@ -82,6 +82,7 @@ ui.each(items, 'id', (item) => ui.li({}, [ui.input({ type: 'checkbox', form: bul
82
82
  `ui.each(ctx.cursors, null, (cursor) => ui.query(listPage, { cursor }, { ready: (page) => … }))`; on the last page
83
83
  (`cursor === ctx.last && page.next !== null`) a sentinel `on: { visible: ui.send(More, { cursor: page.next }) }`;
84
84
  `More` pushes the cursor, guarded by `e.cursor !== null && e.cursor !== ctx.last`.
85
- - **A link starts the page again:** every internal link is a document navigation, so a machine's context starts
86
- from `initialContext` (or `seed`). Keep what must survive in the URL: put both filters in `search` and `seed` the
87
- context from it, instead of one in the URL and one in context.
85
+ - **What a link keeps:** every internal link loads a document. A machine whose view both pages show (or the same
86
+ address) resumes its calm state, the last state without `invoke`, from `sessionStorage`; a reload or a page
87
+ without that view starts from `initialContext` (or `seed`). Keep what must survive a reload or a shared link in
88
+ the URL: put both filters in `search` and `seed` the context from it, not one in the URL and one in context.
@@ -20,8 +20,8 @@ The list is the visitor's own: it lives in their browser, so two visitors never
20
20
  - **feature.ts:** `fetch: new URL('./fetch.ts', import.meta.url)`; `app.ts`: `components: bundleComponents`.
21
21
  - **Data about the items** (quotes, prices) is public: a `runs: 'server'` (or `'either'`) query inside the list's
22
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'`.
23
+ - Pause / Resume / Refresh now: `paused` is a context field (a mode), `refresh: () => [quotesTag()]` on
24
+ `RefreshNow`, on `Resume` (`target: 'idle'` restarts the timer) and on the guarded `after` (`hozu docs machine`).
25
25
  - **Ask the server before saving** (normalize "2330" to "2330.TW"): a `runs: 'server'` query `resolveSymbol`, then
26
26
  the browser mutation: `looking: { invoke: invoke(resolveSymbol, { input: { q: ctx.symbol }, done: { target:
27
27
  'saving', assign: (r) => { ctx.symbol = r.symbol } }, failed: { … target: 'previous' } }) }`, `saving: { invoke:
@@ -89,8 +89,16 @@ const staffHead = { query: me, render: (m) => ({ title: `${m.name} · Admin` }),
89
89
  const staff = (route, View) => ui.page(route, { views: [Sidebar, View], head: staffHead })
90
90
  export default project({ /* … */ pages: [staff(orders, OrderList), staff(orderDetail, OrderPage), …] })
91
91
  ```
92
- The sidebar marks its sections with `current(route)` from its render:
93
- `'aria-current': current(orders) || current(orderDetail)`, styled `aria-[current]:font-bold`.
92
+ The sidebar marks its sections with `current(route)` from its render, one line per section:
93
+ ```ts
94
+ render: ({ current }) => ui.nav({}, [
95
+ ...[
96
+ [orders, 'Orders', current(orders) || current(orderDetail)],
97
+ [customers, 'Customers', current(customers) || current(customer)],
98
+ ].map(([r, label, here]) => ui.a({ href: ui.link(r, null), 'aria-current': here, class: 'aria-[current]:font-bold' }, [label])),
99
+ ])
100
+ ```
101
+ A store's categories: `current(shop, { category: c })` over a constant list of categories.
94
102
 
95
103
  ## Screens with different state
96
104
  One machine per feature: an order list (filters, selection) and an order page (shipping, refund) are two features,
@@ -99,8 +107,9 @@ One machine per feature: an order list (filters, selection) and an order page (s
99
107
  ## A multi-step checkout that also works without JavaScript
100
108
  Each step is a state and each step's form posts only its own fields: after a native post the server renders the next
101
109
  step, and every form on that page carries the machine's state in a signed hidden field, so the next post continues
102
- from it (going back to edit a step too). Prefill from the member with
103
- `seed: ({ query }) => ({ email: query(me, {}).email })`.
110
+ from it (going back to edit a step too; the field is `__hozu_state`, sealed with `SESSION_SECRET`). Prefill from
111
+ the member on the view that has both `machine` and `route` (HZ048):
112
+ `ui.view({ machine: checkout, route: checkoutPage, seed: ({ query }) => ({ email: query(me, {}).email }), render })`.
104
113
 
105
114
  ## A notice after saving
106
115
  A `notice` context field set in `done` and cleared by `after: [{ ms: 4000, target: 'idle' }]` on a `saved` state;
@@ -29,6 +29,7 @@
29
29
 
30
30
  <!-- more -->
31
31
 
32
+ - `press Mod+s` (with `Mod`, `Ctrl`, `Meta`, `Alt`, `Shift`) presses the control whose `keys` match, as a person would; with `--js off` it reports that a shortcut needs JavaScript.
32
33
  - **Server errors:** what the app's `onError` receives (a resolver that threw, an invalid input) is listed under the
33
34
  step or the `get` request that caused it, `server error: <message> (<feature.effect>)`; `--json` `serverErrors`.
34
35
  - **A calm page:** a step that rebuilds elements unchanged says `N elements rebuilt unchanged (a flash: main > form >
@@ -80,6 +81,14 @@
80
81
  `--select <css>`, `--screenshot shot.png` (after the steps), `--viewport 390x844` (a phone; default 1280x800) and
81
82
  `--reduced-motion`.
82
83
  - It also prints the client components on the page (mounted, failed, size, canvases).
84
+ - **More checks in a step:**
85
+ - A click that would land on another element fails the step: `the click would land on <h3>, which contains it, above
86
+ <a href="/x">: a person cannot click it` (an overlay, a card covering its link).
87
+ - A navigation shows how it arrived: `→ /x (loaded, 32 ms)`; with `--js both`, per mode. Browse always shows
88
+ `loaded`: Chrome turns prerendering off under DevTools request interception, so this is the worst case.
89
+ - `--select` prints each element's `class` too. An element moved to another parent is not a flash.
90
+ - A server error that `get` or `browse` lists is noted once with `a production server shows "Internal error" here`:
91
+ the visitor sees that text and the call id; `onError` gets the message.
83
92
  - **`testApp`:** `app` is the default export of `app.ts`; `.post(path, fields)` submits a native form, with fields as
84
93
  a record or as `[name, value]` pairs for repeated names. `testApp(app, { session: store })` may swap only the
85
94
  session store (a test issuer). `testApp` reads no env files: pass `testApp(app, { env: process.env })` (or a record)
@@ -2,10 +2,10 @@
2
2
 
3
3
  ```ts
4
4
  export const Board = ui.view({
5
- machine: m, // optional: without it, no ctx / when / events, and 0 JS
5
+ machine: m, // optional: without it, no ctx / is / events, and 0 JS
6
6
  route: home, // optional: render gets { params, search } typed by the route
7
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 */ ]),
8
+ render: ({ ctx, is, current, params, search, locale }) => ui.main({ class: 'mx-auto max-w-xl' }, [ /* children */ ]),
9
9
  })
10
10
  ```
11
11
  - **Elements:** `ui.<tag>(attrs, children)` for every HTML and SVG element; void tags (`input`, `img`) take only attrs.
@@ -13,10 +13,10 @@ export const Board = ui.view({
13
13
  - **Classes:** `class` is a static string of Tailwind classes that must exist (HZ026); conditional classes:
14
14
  `toggle: { 'bg-indigo-600 text-white': ctx.tab === t }`. No `style`.
15
15
  - **Conditions:** `ctx.error !== null && ui.p({ role: 'alert' }, [ctx.error])`, `item.done ? 'done' : 'open'`.
16
- By machine state: `is(['paused']) ? resume : pause`, `!is(['idle']) && ui.p({}, ['Saving…'])`,
17
- `disabled: is(['saving'])` (keep a control and disable it rather than hide it while busy: no flash).
18
- - **Dialogs, popovers, menus:** native, no machine state: `ui.button({ commandfor: 'd', command: 'show-modal' })` +
19
- `ui.dialog({ id: 'd', closedby: 'any' }, [...])`, `popover` / `popovertarget`, `ui.details`.
16
+ By machine state: `!is(['idle']) && ui.p({}, ['Saving…'])`,
17
+ `disabled: is(['saving'])` (disable a control while busy, not hide it: it stays put).
18
+ - **Dialogs, popovers, menus:** native (bound to the machine: --more): `ui.button({ commandfor: 'd', command:
19
+ 'show-modal' })` + `ui.dialog({ id: 'd', closedby: 'any' }, [...])`, `popover` / `popovertarget`, `ui.details`.
20
20
  - **Lists:** `ui.each(items, 'id', (item) => ui.li({}, [item.title]))`. Never `.map` over data.
21
21
  - **Numbers and dates:** `ui.format.number(q.price, { style: 'currency', currency: 'USD' })`, `ui.format.date(x,
22
22
  { dateStyle: 'medium' })`, `ui.format.relative(n, 'day')`, `ui.format.list(xs)` (Intl, the page's locale).
@@ -33,7 +33,10 @@ export const Board = ui.view({
33
33
  <!-- more -->
34
34
 
35
35
  - **Motion:** what an update adds fades in by itself (not with reduced motion); a view two pages show stays still
36
- across a page change. `hozu browse` reports a flash or a layout shift: fix those.
36
+ across a page change. `hozu browse` reports a flash or a layout shift: fix those. `c ? a : b` whose branches are
37
+ one element of the same tag and shape (`ctx.paused ? resumeButton : pauseButton`) keeps the element: its text,
38
+ classes, attributes and listener follow `c`, and focus stays (not when a differing value computes, such as a `fn`
39
+ or a template string, or links elsewhere).
37
40
  - **Attributes:** HTML names in lower case (`for`, `minlength`, `aria-pressed`, `data-x`), typed per tag. Values are
38
41
  literals or data: `'aria-pressed': ctx.show === 'all'`, `title: ctx.error ?? 'OK'`.
39
42
  - **Sizes and colours from data:** `vars` with an arbitrary-value class: `class: 'w-[calc(var(--pct)*1%)]'`,
@@ -42,7 +45,9 @@ export const Board = ui.view({
42
45
  (runs on the server, and in the browser inside an island).
43
46
  - **More conditions:** `list.length === 0 ? ui.p({}, ['Empty']) : ui.ul({}, [...])`; a `?:` / `&&` branch may be a list:
44
47
  `open ? [a, b] : null`. A query branch or an each item returns one node: wrap several in an element (HZ014).
45
- With an enter/leave animation: `ui.if(cond, [then], [else], 'fade')` (the motion name is required).
48
+ With an enter/leave animation (only then): `ui.if(cond, [then], [else], 'fade')`, by machine state
49
+ `when(['saving'], [children], 'fade')`, and list rows `ui.each(items, 'id', row, 'fade')` (the motion name is
50
+ required; without a motion, `is([...]) && …`).
46
51
  - **More lists:** `ui.each(tags, null, (t) => …)` for primitives. `.map` only over constants:
47
52
  `['a', 'b'].map((k) => ui.option({ value: k }, [k]))`.
48
53
  - **Text:** template strings work: `` `${n} items` ``.
@@ -50,27 +55,37 @@ export const Board = ui.view({
50
55
  `row(item)`; it is inlined, so the IR equals the inline form. A plain function that receives data is HZ059.
51
56
  - **Shared UI:** use the kit component, not a styled `ui.button` per page (`example/` uses a kit).
52
57
  - **More events:** any DOM event name plus `visible` (entered the viewport). Payload fields also:
53
- `ui.dom.formAll('name')`, `ui.dom.checked`, `ui.dom.valueAsNumber`, `ui.dom.key`. `ui.dom.value` / `ui.dom.form`
58
+ `ui.dom.formAll('name')`, `ui.dom.checked`, `ui.dom.valueAsNumber`, `ui.dom.key`. Keyboard shortcuts
59
+ belong to the control they press: `ui.input({ name: 'q', keys: ['/'] })` focuses the field, `ui.button({ type:
60
+ 'submit', keys: ['Mod+s'] }, ['Save'])` clicks it (so the form submits; no machine needed); a kit control takes
61
+ them too: `ui.use(Button, { props, keys: ['Mod+Enter'] }, ['Add'])`. `Mod` is ⌘ on Apple,
62
+ Ctrl elsewhere; also `Ctrl`, `Meta`, `Alt`, `Shift`. A printable key without a modifier waits while the person types
63
+ in another field (`Escape` does not); inside an open modal only its controls count. The page loads a small module
64
+ for it and writes `aria-keyshortcuts`; two controls always shown together with one key is HZ014. `ui.dom.value` / `ui.dom.form`
54
65
  fill an enum field only from a `<select>`, radios or submit buttons whose literal values are all members (HZ033).
55
66
  - **Search in links:** the third argument of `ui.link` is optional and exists only when the route declares `search`:
56
67
  omitted means every default, and a search lists only the fields that differ: `ui.link(home, null, { show: 'done' })`.
57
68
  - **More data:** `pending: ui.p({}, ['Loading…'])` is optional; a branch may return `null` to render nothing.
58
69
  Server-fetched data is sent with the page and never fetched again; after a mutation, queries whose tags it
59
- invalidates refresh in place.
70
+ invalidates refresh in place. When a query's input changes, the rows stay (`aria-busy` on the parent) and update
71
+ by key; `pending` shows only before the first answer.
60
72
  - **From Vue or React:** `computed` → a `fn`; `ref` + `@click` → a context field + `ui.set`; `v-if` → `?:` / `&&`
61
- (with `is([...])` for a machine state); `v-for` + `:key` → `ui.each(list, 'id', …)`; `setInterval` → `after` with
62
- `refresh` (`hozu docs machine`); `watch` → a transition's `assign`; DOM libraries (charts, maps) → a client component
73
+ (with `is([...])` for a machine state); `v-for` + `:key` → `ui.each(list, 'id', …)`; `setInterval` → `freshness: { poll: s }`
74
+ for data that changes on its own (`hozu docs data`), `after` with `refresh` for a refresh the visitor pauses
75
+ (`hozu docs machine`); `watch` → a transition's `assign`; DOM libraries (charts, maps) → a client component
63
76
  (`hozu docs components`; `examples/showcase` has a Chart.js one).
64
77
  - **Also:** `ui.html(post.html)` (trusted HTML from query data only, HZ030), `ui.asset(new URL('./x.png',
65
78
  import.meta.url))`, `ui.window({ on })` / `ui.document({ on })`, `ui.embed(OtherView)`.
66
79
 
67
80
  ## Menus, dialogs, counting
68
- - When a query's input changes, the rows stay (`aria-busy` on the parent) and update by key; `pending` shows only
69
- before the first answer.
70
81
  - A link to the address shown gets `aria-current="page"`. Which links mark a section is yours to say: the render's
71
82
  `current(route)` is true on that route's pages, so a menu writes
72
83
  `ui.a({ href: ui.link(orders, null), 'aria-current': current(orders) || current(orderDetail) }, ['Orders'])` and
73
84
  styles `aria-[current]:font-bold`: `true` is written `"page"` on the address itself, `false` writes nothing.
85
+ `current(shop, { category: 'apparel' })` also compares those params (search is ignored). A menu is a constant
86
+ list mapped to links, one line per section:
87
+ `...[[orders, 'Orders'], [customers, 'Customers']].map(([r, label]) => ui.a({ href: ui.link(r, null),
88
+ 'aria-current': current(r) }, [label]))`; `current(a) || current(b)` works there too.
74
89
  - `ui.dialog({ open: is(['editing']), on: { close: ui.send(Cancel, {}) } }, [...])` opens as a modal and closes with
75
90
  the machine; Escape sends `close`. It needs JavaScript: a dialog that must open without it uses the native
76
91
  `commandfor` button (the short form) and closes when the data that shows it changes.