@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,156 @@
1
+ import { contract, ui } from '@hozu/core'
2
+ import { bookmarkPage, home } from '../../routes.ts'
3
+ import { Badge } from '../../ui/badge.ts'
4
+ import { Button } from '../../ui/button.ts'
5
+ import { Field } from '../../ui/field.ts'
6
+ import { Input } from '../../ui/input.ts'
7
+ import {
8
+ Add,
9
+ addBookmark,
10
+ bookmarksMachine,
11
+ Draft,
12
+ getBookmark,
13
+ isEmpty,
14
+ listBookmarks,
15
+ ToggleRead,
16
+ visible,
17
+ } from './model.ts'
18
+
19
+ const kinds = ['article', 'video', 'podcast'] as const
20
+ const shows = [
21
+ { value: 'all', label: 'All' },
22
+ { value: 'unread', label: 'Unread' },
23
+ ] as const
24
+
25
+ export const Board = ui.view({
26
+ machine: bookmarksMachine,
27
+ route: home,
28
+ render: ({ ctx, search, when }) =>
29
+ ui.main({ class: 'mx-auto max-w-xl space-y-6 px-4 py-12' }, [
30
+ ui.h1({ class: 'text-3xl font-bold' }, ['Bookmarks']),
31
+ ui.form(
32
+ {
33
+ class: 'flex items-start gap-2',
34
+ on: { submit: ui.send(Add, { title: ui.dom.form('title'), kind: ui.dom.form('kind') }) },
35
+ },
36
+ [
37
+ ui.use(Field, {
38
+ props: { for: 'title', label: 'Title', error: ctx.fields.title, errorId: 'title-error' },
39
+ slots: {
40
+ control: ui.use(Input, {
41
+ props: {
42
+ id: 'title',
43
+ name: 'title',
44
+ value: ctx.draft,
45
+ required: true,
46
+ minlength: 2,
47
+ maxlength: 80,
48
+ invalid: ctx.fields.title !== null,
49
+ describedby: 'title-error',
50
+ },
51
+ on: { input: ui.send(Draft, { text: ui.dom.value }) },
52
+ }),
53
+ },
54
+ }),
55
+ ui.select(
56
+ { name: 'kind', 'aria-label': 'Kind', class: 'rounded border px-2 py-2' },
57
+ kinds.map((k) => ui.option({ value: k, selected: ctx.kind === k }, [k])),
58
+ ),
59
+ ui.use(Button, { props: { type: 'submit' } }, ['Add']),
60
+ ],
61
+ ),
62
+ ctx.error !== null && ui.p({ role: 'alert', class: 'text-rose-600' }, [ctx.error]),
63
+ when(
64
+ ['adding'],
65
+ [
66
+ ui.p({ class: 'rounded border px-4 py-3 opacity-50', 'aria-busy': 'true' }, [
67
+ 'Adding ',
68
+ ctx.draft,
69
+ '…',
70
+ ]),
71
+ ],
72
+ ),
73
+ ui.nav(
74
+ { class: 'flex gap-2', 'aria-label': 'Show' },
75
+ shows.map((s) =>
76
+ ui.a(
77
+ {
78
+ href: ui.link(home, null, { show: s.value }),
79
+ 'aria-current': search.show === s.value,
80
+ class:
81
+ 'rounded-full border px-3 py-1 aria-[current=true]:bg-indigo-600 aria-[current=true]:text-white',
82
+ },
83
+ [s.label],
84
+ ),
85
+ ),
86
+ ),
87
+ ui.query(
88
+ listBookmarks,
89
+ {},
90
+ {
91
+ ready: (items) =>
92
+ isEmpty({ items, show: search.show })
93
+ ? ui.p({ class: 'text-slate-500' }, ['No bookmarks'])
94
+ : ui.ul({ class: 'divide-y rounded border' }, [
95
+ ui.each(visible({ items, show: search.show }), 'id', (b) =>
96
+ ui.li({ class: 'flex items-center gap-3 px-4 py-3' }, [
97
+ ui.a({ href: ui.link(bookmarkPage, { id: b.id }), class: 'flex-1 underline' }, [
98
+ b.title,
99
+ ]),
100
+ ui.use(Badge, {}, [b.kind]),
101
+ ui.use(
102
+ Button,
103
+ { variant: { tone: 'quiet' }, on: { press: ui.send(ToggleRead, { id: b.id }) } },
104
+ [b.read ? 'Mark unread' : 'Mark read'],
105
+ ),
106
+ ]),
107
+ ),
108
+ ]),
109
+ pending: ui.p({}, ['Loading…']),
110
+ failed: { Unexpected: () => ui.p({ role: 'alert' }, ['Bookmarks are unavailable']) },
111
+ },
112
+ ),
113
+ ]),
114
+ })
115
+
116
+ export const Detail = ui.view({
117
+ route: bookmarkPage,
118
+ render: ({ params }) =>
119
+ ui.main({ class: 'mx-auto max-w-xl space-y-4 px-4 py-12' }, [
120
+ ui.query(
121
+ getBookmark,
122
+ { id: params.id },
123
+ {
124
+ ready: (b) =>
125
+ ui.article({}, [
126
+ ui.h1({ class: 'text-3xl font-bold' }, [b.title]),
127
+ ui.p({}, ['Kind: ', b.kind]),
128
+ ui.use(Badge, {}, [b.read ? 'Read' : 'Unread']),
129
+ ]),
130
+ pending: null,
131
+ failed: {
132
+ NotFound: () => ui.p({ role: 'alert' }, ['Bookmark not found']),
133
+ Unexpected: () => ui.p({ role: 'alert' }, ['Bookmark unavailable']),
134
+ },
135
+ },
136
+ ),
137
+ ui.a({ href: ui.link(home, null), class: 'underline' }, ['Back']),
138
+ ]),
139
+ })
140
+
141
+ export const addsBookmark = contract(bookmarksMachine, {
142
+ given: { state: 'idle' },
143
+ when: [
144
+ { send: Add, payload: { title: 'Hozu talk', kind: 'podcast' } },
145
+ { send: Draft, payload: { text: 'ignored while adding' } },
146
+ { done: addBookmark, result: { id: 'b3', title: 'Hozu talk', kind: 'podcast', read: false } },
147
+ ],
148
+ expect: {
149
+ state: 'idle',
150
+ changes: { kind: 'podcast' },
151
+ effects: [
152
+ { effect: addBookmark, input: { title: 'Hozu talk', kind: 'podcast' } },
153
+ { navigate: '/bookmarks/b3' },
154
+ ],
155
+ },
156
+ })
@@ -0,0 +1,34 @@
1
+ import { project, ui } from '@hozu/core'
2
+ import { zodAdapter } from '@hozu/schema-zod'
3
+ import { bookmarks } from './features/bookmarks/feature.ts'
4
+ import { getBookmark, listBookmarks } from './features/bookmarks/model.ts'
5
+ import { Board, Detail } from './features/bookmarks/views.ts'
6
+ import { bookmarkPage, home } from './routes.ts'
7
+ import { kit as uiKit } from './ui/kit.ts'
8
+
9
+ export default project({
10
+ schema: zodAdapter,
11
+ app: new URL('./app.ts', import.meta.url),
12
+ previews: new URL('./previews.ts', import.meta.url),
13
+ styles: new URL('./app.css', import.meta.url),
14
+ site: { url: 'http://localhost:3000', name: 'Bookmarks', lang: 'en' },
15
+ routes: { home, bookmarkPage },
16
+ pages: [
17
+ ui.page(home, {
18
+ views: [Board],
19
+ head: { render: () => ({ title: 'Bookmarks', description: 'A shared reading list.' }) },
20
+ }),
21
+ ui.page(bookmarkPage, {
22
+ views: [Detail],
23
+ head: {
24
+ query: getBookmark,
25
+ input: (params) => ({ id: params.id }),
26
+ failed: { NotFound: 404 },
27
+ render: (b) => ({ title: b.title, description: b.title, type: 'article' }),
28
+ },
29
+ entries: { query: listBookmarks, input: {}, params: (b) => ({ id: b.id }) },
30
+ }),
31
+ ],
32
+ kits: [uiKit],
33
+ features: [bookmarks],
34
+ })
@@ -0,0 +1,13 @@
1
+ import { previews } from '@hozu/core/preview'
2
+ import { listBookmarks } from './features/bookmarks/model.ts'
3
+ import { home } from './routes.ts'
4
+ import { Button } from './ui/button.ts'
5
+
6
+ export default previews((p) => [
7
+ p.component(Button, 'Long label', {
8
+ variant: { tone: 'primary' },
9
+ children: 'Add it to the shared reading list',
10
+ }),
11
+ p.page(home, 'Empty list', [p.data(listBookmarks, [])]),
12
+ p.page(home, 'List failed', [p.fail(listBookmarks, 'Unexpected')]),
13
+ ])
@@ -0,0 +1,11 @@
1
+ import { route } from '@hozu/core'
2
+ import { z } from 'zod'
3
+
4
+ export const Show = z.enum(['all', 'unread'])
5
+
6
+ export const home = route({ path: '/', params: null, search: z.object({ show: Show.default('all') }) })
7
+ export const bookmarkPage = route({
8
+ path: '/bookmarks/:id',
9
+ params: z.object({ id: z.string() }),
10
+ search: null,
11
+ })
@@ -0,0 +1,11 @@
1
+ import { ui } from '@hozu/core'
2
+ import { tv } from './tv.ts'
3
+
4
+ const styles = tv({ base: 'rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-600' })
5
+
6
+ export const Badge = ui.component({
7
+ tag: 'span',
8
+ styles,
9
+ children: true,
10
+ render: ({ children }) => ui.span({}, children),
11
+ })
@@ -0,0 +1,23 @@
1
+ import { ui } from '@hozu/core'
2
+ import { z } from 'zod'
3
+ import { tv } from './tv.ts'
4
+
5
+ const styles = tv({
6
+ base: 'inline-flex items-center justify-center gap-2 rounded px-4 py-2 font-medium',
7
+ variants: {
8
+ tone: {
9
+ primary: 'bg-indigo-600 text-white hover:bg-indigo-700',
10
+ quiet: 'px-2 py-1 text-sm text-slate-600 hover:text-slate-900',
11
+ },
12
+ },
13
+ defaultVariants: { tone: 'primary' },
14
+ })
15
+
16
+ export const Button = ui.component({
17
+ tag: 'button',
18
+ styles,
19
+ props: z.object({ type: z.enum(['button', 'submit']).default('button') }),
20
+ children: true,
21
+ events: ['press'],
22
+ render: ({ props, children, on }) => ui.button({ type: props.type, on: { click: on.press } }, children),
23
+ })
@@ -0,0 +1,20 @@
1
+ import { ui } from '@hozu/core'
2
+ import { z } from 'zod'
3
+ import { tv } from './tv.ts'
4
+
5
+ const styles = tv({
6
+ slots: { base: 'flex-1 space-y-1', label: 'sr-only', error: 'text-sm text-rose-600' },
7
+ })
8
+
9
+ export const Field = ui.component({
10
+ tag: 'div',
11
+ styles,
12
+ props: z.object({ for: z.string(), label: z.string(), error: z.string().nullable(), errorId: z.string() }),
13
+ slots: ['control'],
14
+ render: ({ props, slots, classes }) =>
15
+ ui.div({}, [
16
+ ui.label({ for: props.for, class: classes.label }, [props.label]),
17
+ slots.control,
18
+ ui.p({ id: props.errorId, class: classes.error }, [props.error]),
19
+ ]),
20
+ })
@@ -0,0 +1,33 @@
1
+ import { ui } from '@hozu/core'
2
+ import { z } from 'zod'
3
+ import { tv } from './tv.ts'
4
+
5
+ const styles = tv({ base: 'w-full rounded border px-3 py-2 aria-invalid:border-rose-500' })
6
+
7
+ export const Input = ui.component({
8
+ tag: 'input',
9
+ styles,
10
+ props: z.object({
11
+ id: z.string(),
12
+ name: z.string(),
13
+ value: z.string(),
14
+ required: z.boolean().default(false),
15
+ minlength: z.number().optional(),
16
+ maxlength: z.number().optional(),
17
+ invalid: z.boolean().default(false),
18
+ describedby: z.string().optional(),
19
+ }),
20
+ events: ['input'],
21
+ render: ({ props, on }) =>
22
+ ui.input({
23
+ id: props.id,
24
+ name: props.name,
25
+ value: props.value,
26
+ required: props.required,
27
+ minlength: props.minlength,
28
+ maxlength: props.maxlength,
29
+ 'aria-invalid': props.invalid,
30
+ 'aria-describedby': props.describedby,
31
+ on: { input: on.input },
32
+ }),
33
+ })
@@ -0,0 +1,7 @@
1
+ import { ui } from '@hozu/core'
2
+ import * as badge from './badge.ts'
3
+ import * as button from './button.ts'
4
+ import * as field from './field.ts'
5
+ import * as input from './input.ts'
6
+
7
+ export const kit = ui.kit({ id: 'ui', components: [button, input, field, badge] })
@@ -0,0 +1,12 @@
1
+ import { createTV } from '@hozu/variants'
2
+
3
+ // hozu:variants-config ui
4
+ const twMergeConfig = {
5
+ extend: {
6
+ theme: {},
7
+ classGroups: {},
8
+ },
9
+ }
10
+ // /hozu:variants-config
11
+
12
+ export const tv = createTV({ twMergeConfig })
@@ -0,0 +1,56 @@
1
+ # Sign-in, sessions, who may read and change what
2
+
3
+ - **Start:** `hozu add feature notes --page / --with auth` (sign-in page, sign-out, `me`); set `SESSION_SECRET`.
4
+ - `project({ session: z.object({ user: z.string() }) })`. `scope: 'user'` queries and mutations get `session`;
5
+ a mutation calls `setSession(value)` (`null` signs out).
6
+ - **Every server-run user query and mutation says who may run it**, like `runs` (HZ088):
7
+ - `access: 'signedIn'`: any signed-in visitor; the resolver reads that visitor's data by `session`.
8
+ - `access: { owner: { row: (n) => n.owner, session: (s) => s.user } }`: the framework checks the output. One row
9
+ that is not the visitor's is `Forbidden` (a missing owner on either side never matches); a list holding such
10
+ rows is HZ091 (the resolver read too much).
11
+ - On a mutation, `{ owner: { load: getNote, input: (i) => ({ id: i.id }), row: (n) => n.owner, session: (s) =>
12
+ s.user } }` reads the row and checks it before the resolver runs; if `load` fails for any reason (`NotFound`
13
+ too), the answer is `Forbidden` and the resolver does not run.
14
+ - `access: { allow: ({ session, input }) => session.role === 'admin' }`.
15
+ - `access: 'anyone'`: sign-in, a newsletter. On user data it is HZ090.
16
+ - **Refused** is the framework error `Forbidden`, before the resolver runs: optional in `failed` (otherwise
17
+ `Unexpected`). A page answers 403, or maps it: `head: { query: me, …, failed: { Forbidden: login } }`.
18
+ - Check it as two visitors: `hozu call <effect> --session '{"user":"bob"}'`, or in one chain: `hozu browse /
19
+ --as ada --session '{"user":"ada"}' --do 'remember note from li a @href' --as bob --session '{"user":"bob"}'
20
+ --do 'goto $note'` (bob gets 403). `post <path> a=1` forges a native post as the current actor.
21
+
22
+ <!-- more -->
23
+
24
+ ## Details
25
+ - The scaffold writes `features/account` (sign-in page, sign-out, `me`), per-user resolvers, and a redirect to
26
+ `/login` when signed out. Replace the name-only sign-in with real credentials before production; production
27
+ without `SESSION_SECRET` refuses to start.
28
+ - Public queries never receive `session`.
29
+ - Sessions live on the server: the default store is `memorySessions()` (from `@hozu/runtime-server`); the cookie holds
30
+ only an opaque, signed, HttpOnly id, so `setSession(null)` revokes it and the session never reaches browser
31
+ JavaScript. It is per process: a restart signs everyone out, and an edge or multi-instance deployment passes a
32
+ shared store explicitly (`createHandler({ session })`).
33
+ - `setSession` also applies in a failing mutation (expiry: `setSession(null)` then `fail('Expired', …)`).
34
+ - After a sign-in or sign-out the page's queries are re-read with the new session; nothing from the old one stays.
35
+ - A role on top of sign-in: `'signedIn'` plus a declared error (`NotAdmin`) mapped to 403 keeps "signed out → login"
36
+ apart from "not allowed → 403" (`failed: { Forbidden: login, NotAdmin: 403 }`); `{ allow }` answers Forbidden for both.
37
+ - `access` is recorded in `hozu.lock.json`, so a change to it is reviewed like a transition.
38
+ - `owner` needs the row to carry its owner field (HZ088 otherwise): add it to the output, or use `'signedIn'` and
39
+ read only the visitor's rows. In production a list's foreign rows are dropped and logged once.
40
+ - Public queries never see the session, and a browser-run effect is guarded by the API it calls: `access` there is
41
+ HZ089.
42
+ - **A token that expires:** keep it and its expiry in the session, and refresh it in `app({ refreshSession: async
43
+ (session, { env }) => session.expires > Date.now() ? undefined : { ...session, token: await renew(session) } })`.
44
+ It runs once per request before any resolver reads the session (queries stay read-only); the new value replaces
45
+ the old one in place (the cookie stays), `null` signs out, `undefined` keeps it. Requests of one session in one process share one call
46
+ (and its result for ten seconds); a sign-out while it runs wins; a throw keeps the session and reaches `onError`. Do not keep tokens in module variables: a restart or a second
47
+ instance loses them.
48
+ - Calling another API with a token: a token your server holds goes in the session and is read in a `runs: 'server'`
49
+ resolver; a token that lives in the browser (OIDC / SSO, `localStorage`) is read in a `runs: 'browser'` effect
50
+ (`hozu docs fetch`) and never reaches your server.
51
+ - `SESSION_SECRET` is at least 32 characters: `openssl rand -hex 32`. Keep it in a git-ignored `.env` and list
52
+ it in `.env.example` (`npx hozu env --example`).
53
+ - **A refused page** renders the page's views with status 403 (the `<title>` falls back to the site name). For a
54
+ page of its own, map `Forbidden` (or a declared error) to a route: `failed: { Forbidden: login }`.
55
+ - **Order on a mutation:** the input schema first (`Invalid` with field errors), then access (`Forbidden`), then the
56
+ resolver.
@@ -0,0 +1,92 @@
1
+ # Components (shared UI, kits, browser code)
2
+
3
+ A UI piece used by several features is a component in a kit: `hozu add kit ui`, then `hozu add component ui Button`
4
+ (`hozu add component <feature> <Name>` makes one private to that feature).
5
+ ```ts
6
+ // ui/button.ts; ui/kit.ts lists it: ui.kit({ id: 'ui', components: [button, input] })
7
+ const styles = tv({ // tv from ./tv.ts
8
+ base: 'inline-flex gap-2 rounded px-4 py-2 disabled:opacity-50 aria-busy:cursor-wait',
9
+ variants: { tone: { primary: 'bg-indigo-600 text-white', ghost: 'text-slate-700 hover:bg-slate-100' } },
10
+ defaultVariants: { tone: 'primary' },
11
+ })
12
+ export const Button = ui.component({
13
+ tag: 'button', styles, slots: ['icon'], children: true, events: ['press'],
14
+ props: z.object({ type: z.enum(['button', 'submit']).default('button'), busy: z.boolean().default(false) }),
15
+ render: ({ props, slots, children, on }) =>
16
+ ui.button({ type: props.type, 'aria-busy': props.busy, on: { click: on.press } }, [slots.icon, ...children]),
17
+ })
18
+ ```
19
+ ```ts
20
+ ui.use(Button, { variant: { tone: 'ghost' }, props: { busy: ctx.saving }, slots: { icon: ui.span({}, ['+']) },
21
+ on: { press: ui.send(Save, {}) }, class: 'w-full' }, ['Save']) // in a view; its id is ui.Button
22
+ ```
23
+ - **`ui.use` keys** (all optional): `variant` (literals only, HZ071), `props` (anything that changes while the page
24
+ runs), `slots`, `on`, `class`; children only with `children: true`.
25
+ - **Render** reads only `props`, `slots`, `children`, `on` and `classes`; the caller passes sends, links and text
26
+ in (HZ070).
27
+ - **`class`** may only add classes that set none of the component's properties (`w-full`, `md:hidden`); to change
28
+ one, declare a variant (HZ072–HZ077; one-offs: see --more).
29
+ - Browser APIs or DOM libraries: a client component, `hozu add component <kit|feature> <Name> --client` (see --more).
30
+ - `previews.ts` (`project({ previews })`) is for people: named component states and page screens in DevTools
31
+ Assets. It never ships; change it only when asked or when HZ092 names a line (see --more).
32
+
33
+ <!-- more -->
34
+
35
+ ## Details
36
+ - `hozu add kit ui` writes `ui/kit.ts`, `ui/tv.ts` and `project({ kits })`.
37
+ - **Variants vs props:** a variant is a fixed look chosen in the view (HZ071 for data); anything that changes while
38
+ the page runs is a prop. Style a state through the attribute that announces it: `disabled:`, `aria-pressed:`,
39
+ `aria-busy:`, `aria-invalid:`, `aria-expanded:`, `open:`. `toggle` is for states without one.
40
+ - **Render:** `classes` holds the other tv slots (`classes.label`). Hozu puts the root class on the root.
41
+ - **Extension:** `class` may add classes that set none of the component's properties (`w-full`, `relative`,
42
+ `md:hidden`). To change one, declare a variant; a one-off ends with `!` (`rounded-lg!`) (HZ072–HZ077).
43
+ `hozu check` counts the `!` per component; `extend: false` refuses every class.
44
+ - A view fragment that two features inline is a component, not a `part()` (HZ080).
45
+
46
+ ## Client components (browser APIs, DOM libraries)
47
+ `hozu add component <kit|feature> <Name> --client` writes the declaration, the client module, the bundle in `app.ts`
48
+ and the `@hozu/bundle` dependency.
49
+ ```ts
50
+ export const Map = ui.component({ tag: 'div', props: z.object({ lat: z.number(), lng: z.number() }),
51
+ emits: { picked: z.object({ id: z.string() }) }, client: new URL('./map.client.ts', import.meta.url),
52
+ load: 'visible', render: () => ui.div({}, []) }) // 'eager' | 'visible' | 'idle'
53
+ ui.use(Map, { props: { lat: ctx.lat, lng: ctx.lng }, on: { picked: (d) => ui.send(Pick, { id: d.id }) },
54
+ class: 'h-96 w-full' })
55
+ ```
56
+ ```ts
57
+ // map.client.ts: a type-only import of the declaration
58
+ import { implement } from '@hozu/core/component'
59
+ import type { Map } from './components.ts'
60
+ export default implement<typeof Map>(({ el, props, emit, signal }) => {
61
+ const map = createMap(el, props) // any DOM library
62
+ map.on('pick', (id) => emit('picked', { id }))
63
+ return { update(next) { map.move(next) }, destroy() { map.remove() } }
64
+ })
65
+ ```
66
+ - The render is the server HTML the module takes over; with `children: true` the children stay as the no-JS
67
+ fallback. The root takes no attributes or `on`: put a role or label on a wrapping element.
68
+ - `app.ts` passes `components: bundleComponents` (`@hozu/bundle`) to `app()` (HZ045 without it). A library's CSS
69
+ goes in `app.css`; a map or chart host needs a height class.
70
+ - `hozu browse /` lists each one as mounted / failed / not mounted with its size and canvases; a mounted host has
71
+ `data-hozu-component="<id>"` and `data-hozu-component-state="mounted"`.
72
+
73
+ ## Look them up
74
+ - `hozu docs components` (this topic, then the app's list), `hozu inspect ui.Button` (variants, props, owned
75
+ classes, every use), `hozu why ui.Button`.
76
+ - `hozu render ui.Button --variant tone=ghost --props '{"busy":true}' --slot icon=+` renders it alone: HTML, root
77
+ class, owned properties, diagnostics.
78
+
79
+ ## Previews (for people, never shipped)
80
+ - `project({ previews: new URL('./previews.ts', import.meta.url) })`; only `hozu dev` and `hozu check` load it. DevTools **Assets** shows every component × variant (props from the schema) plus these.
81
+ - ```ts
82
+ import { previews } from '@hozu/core/preview'
83
+ export default previews((p) => [
84
+ p.component(Button, 'Long label', { variant: { tone: 'primary' }, children: 'Save every note' }),
85
+ p.page(home, 'No notes', [p.data(me, { name: 'ada' }), p.data(listNotes, [])]),
86
+ p.page(home, 'Failed', [p.fail(listNotes, 'Unexpected')]),
87
+ ])
88
+ ```
89
+ - A page preview answers those queries under `hozu dev` only (Layers → Previews, or Assets → Screens), on every
90
+ page while it is on; other queries and mutations run as usual. HZ092: data off the output schema, an undeclared error, a route without a page, a use that
91
+ does not build.
92
+
@@ -0,0 +1,37 @@
1
+ # Markdown, images, share images, fonts, preview, offline
2
+
3
+ - **Markdown:** `npm install @hozu/content` (not in the scaffold), then in `app.ts`
4
+ `const posts = await loadCollection({ dir: new URL('./content/posts/', import.meta.url), schema })`: one
5
+ `{ slug, data, html, headings }` per `.md` file. Return them from query resolvers and render `ui.html(post.html)`.
6
+ - **Images:** `ui.img({ src: ui.asset(new URL('./hero.jpg', import.meta.url)), alt, width, height })` (HZ028 without
7
+ dimensions).
8
+
9
+ <!-- more -->
10
+
11
+ - **Collections in detail:**
12
+ - `slug` is the file name without `.md`.
13
+ - Only the folder's own files are read, not sub-folders; entries come in file-name order (sort them in the
14
+ resolver).
15
+ - `data` is the front matter parsed as YAML 1.2 and checked against `schema`. An unquoted date such as
16
+ `2026-09-12` stays a string, so `z.iso.date()` fits.
17
+ - A front matter error or a schema mismatch throws with the file name at startup.
18
+ - There are no conventional fields: a draft flag or an excerpt is a field in your schema.
19
+ - `html` is GFM; headings get ids, listed in `headings`.
20
+ - Style it with your own CSS for the container (the `prose` class needs the Tailwind typography plugin in
21
+ `app.css`, otherwise HZ026).
22
+
23
+ - **Images:** with `@hozu/image`, `hozu build` adds WebP `srcset` widths.
24
+ - **Share images:** `head.render → image: ui.og({ title, subtitle })` (needs `app({ og: ogImage })` with `ogImage`
25
+ from `@hozu/image`); on a static host use `image: ui.asset(new URL('./share.png', import.meta.url))`.
26
+ - **Fonts:** prefer local font files: a local `@font-face` gets a size-matched fallback automatically. A remote font
27
+ is `@import url('https://fonts.googleapis.com/css2?family=…');` in `app.css` (moved to the top of the output) plus
28
+ its CSP sources: `app({ csp: { style: ['https://fonts.googleapis.com'], font: ['https://fonts.gstatic.com'] } })`.
29
+ - **Page transitions:** links cross-fade (CSS view transitions, no JS); turn off with
30
+ `@view-transition { navigation: none; }` in `app.css`.
31
+ - **Preview:** `app({ preview: { secret } })`; `/_hozu/preview?secret=…&path=/posts/a` turns it on; resolvers
32
+ read `ctx.preview`; preview responses are never cached.
33
+ - **Offline:** `site.offline: route` (a static page) makes a service worker (HZ043).
34
+ - **One collection per language:** a folder per locale (`content/en`, `content/de`), loaded by the query for the
35
+ page's locale (`head.input`'s second argument).
36
+ - **Images named in front matter** (`cover: night.png`): serve them with a GET endpoint (`output: 'response'`) that
37
+ reads only the files some entry lists. There is no `public/` folder served at the root.
@@ -0,0 +1,36 @@
1
+ # Contracts
2
+
3
+ A transition that **decides** (a guard, a `navigate`, a `fn()`, a comparison or a computing operator in its values)
4
+ needs a contract; HZ016 prints each missing one, ready to paste:
5
+ `contract(m, { given: { state }, when: [{ send: Event, payload }], expect: { state, changes, effects } })`
6
+ (full example: see --more). Export it from `views.ts`. After a behaviour change, run `hozu check --update-lock`
7
+ and list the accepted `now:` lines in your summary. When a contract fails (HZ015), decide which is intended before
8
+ changing either.
9
+
10
+ <!-- more -->
11
+
12
+ - Computing operators: `+ - ?? ?: .length .includes`. Transitions that only copy values need no contract (a contract
13
+ there is HZ058).
14
+ - `hozu.lock.json` records every transition readably and must equal the computed lock: any difference is HZ057
15
+ until `hozu check --update-lock` accepts it.
16
+ - A deciding change also needs a contract that fails against the old behaviour (HZ018); renaming or copying a
17
+ contract does not count.
18
+ - A transition that stops deciding (its guard or `navigate` removed) needs only the lock: `hozu check
19
+ --update-lock`, then delete the contracts HZ058 names.
20
+ - A transition to `'previous'` needs the state it returns to: `given: { state: 'adding', previous: 'editing' }`.
21
+ - Contracts may be exported from any module the feature lists. When one fails, the choice is between the machine
22
+ and the contract.
23
+ ```ts
24
+ export const addsValid = contract(m, {
25
+ given: { state: 'idle' }, // context: initialContext; { touring: true } overrides fields
26
+ when: [
27
+ { send: Add, payload: { title: 'Milk' } },
28
+ { done: addItem, result: { id: 'i9', title: 'Milk', done: false } },
29
+ ], // or { failed: addItem, error: 'Duplicate', data } / { elapse: ms }
30
+ expect: {
31
+ state: 'idle',
32
+ changes: { draft: '' }, // only what changes; nested objects are patches
33
+ effects: [{ effect: addItem, input: { title: 'Milk' } }, { navigate: '/items/i9' }], // default: none
34
+ },
35
+ })
36
+ ```
@@ -0,0 +1,75 @@
1
+ # Data: queries, mutations, tags, fn, resolvers
2
+
3
+ **First: whose data is it?** It decides `runs`, `scope` and where it is stored. When the request does not say, ask.
4
+
5
+ | The data | `runs` / `scope` | Stored in |
6
+ |---|---|---|
7
+ | the visitor's own, no sign-in (a watchlist, favourites, settings) | `'browser'` / `'user'` | `localStorage`, in `fetch.ts` (`hozu docs recipes`) |
8
+ | a signed-in user's, on every device | `'server'` / `'user'` + `access` | the app's database |
9
+ | everyone's (posts, a shared board) | `'server'` / `'public'` + a deliberate `access` | the app's database |
10
+ | a public third-party API (quotes, weather) | `'either'` / `'public'` | nowhere: read it |
11
+
12
+ The arrays in Hozu's examples and scaffolds are stand-ins that keep them short: one list for every visitor, gone on
13
+ restart. Never ship one; replace it with the store above.
14
+
15
+ ```ts
16
+ export const itemsTag = tag({ param: null }) // tag({ param: z.string() }) → itemTag(id)
17
+ export const listItems = query({
18
+ input: z.object({}), output: z.array(Item),
19
+ scope: 'public', // 'user' = the session's data (needs project({ session }))
20
+ freshness: 'static', // | 'request' | { revalidate: s } | { swr: s } | 'live' | { poll: s }
21
+ tags: () => [itemsTag()], // optional; (input) => [...]
22
+ runs: 'server', // where the implementation lives: 'server' | 'browser' | 'either' (required); hozu docs fetch
23
+ })
24
+ export const getItem = query({ input: Key, output: Item, errors: { NotFound: Key }, scope: 'public',
25
+ freshness: 'static', tags: (k) => [itemTag(k.id)], runs: 'server' })
26
+ export const addItem = mutation({
27
+ input: z.object({ title: z.string().min(2, 'Use at least 2 characters') }), output: Item,
28
+ errors: { Duplicate: z.object({ title: z.string() }) }, // optional: declared failures
29
+ invalidates: () => [itemsTag()], // refreshes queries with these tags
30
+ runs: 'server',
31
+ access: 'anyone', // who may run it (required on the server; user queries too): hozu docs auth
32
+ })
33
+ export const visible = fn({ // computation: pure JS; may call const/function helpers of this module
34
+ input: z.object({ items: z.array(Item), show: Show }), output: z.array(Item),
35
+ impl: ({ items, show }) => items.filter((i) => show === 'all' || !i.done),
36
+ })
37
+ ```
38
+ ```ts
39
+ export default app({ resolvers: resolvers(project, (implement) => [
40
+ implement(listItems, () => db.items.list()), // db: the app's database client
41
+ implement(getItem, async ({ id }, { fail }) => (await db.items.get(id)) ?? fail('NotFound', { id })),
42
+ implement(addItem, ({ title }, { fail, session }) => /* … */ ),
43
+ ]) })
44
+ ```
45
+ - `runs` is required on every query and mutation. `'server'` resolvers live in `app.ts` (or `features/<name>/server.ts`)
46
+ and get the schema-parsed input; `'browser'` / `'either'` live in `fetch.ts` (`hozu docs fetch`).
47
+ - **Query resolvers only read;** writes happen in mutation and endpoint resolvers (see --more).
48
+ - User data (`scope: 'user'`) is `freshness: 'request'`, `'live'` or `{ poll }` only (HZ049); `'live'` needs tags (HZ050).
49
+ - **Changes on its own** (quotes, a feed): `freshness: { poll: 30 }` reads it again every 30 s (5 to 86400) while a page
50
+ shows it, also from the browser. `'live'` is for data your own mutations change. A refresh the visitor controls
51
+ (a button, Pause / Resume) is `refresh: () => [tag()]` on a machine transition (`hozu docs machine`).
52
+ - Call a `fn` from views or machines: `ui.each(visible({ items, show: ctx.show }), 'id', …)`.
53
+
54
+ <!-- more -->
55
+
56
+ ## Details
57
+ - A `fn` body may call functions and JSON constants declared in the same module; they are sent to the browser with
58
+ it. Imported names and `let` state are not (HZ047): pass them as input.
59
+ - Rendering is derived: `scope` and `freshness` decide static, ISR, SWR, streamed or client rendering;
60
+ `scope: 'user'` data never reaches a cached page (HZ022). A mutation's tags can read only its input.
61
+ - Freshness describes how the data changes, not where it is read; choose it per query. `'static'` (with tags) is
62
+ for data only your own declared writers change; `'request'` reads every time it is needed (on the server per
63
+ request, in the browser on mount and on tags; a public one makes its page uncacheable). `'live'` is only for push
64
+ updates.
65
+ - `invalidates` drives the refresh: after a mutation or endpoint, cached pages and entries with those tags are
66
+ dropped and the page's queries with those tags are re-read. `endpoint({ …, invalidates: (input) => [tag()] })`
67
+ applies when it succeeds (use POST; a GET write is HZ062).
68
+ - Writes from outside (a webhook, a job): `await server.revalidate([itemsTag()])` → `{ entries, pages }`.
69
+ - **Why queries only read:** a query that creates a row on read runs again on every request, on prefetch and after a
70
+ delete (the account comes back). Keep two helpers: `listOf(user)` returns the stored list or `[]` for queries;
71
+ `ownListOf(user)` creates it, for mutations only.
72
+ - Resolvers in `features/<name>/server.ts` (from the scaffold) are spread into `app.ts`; the input they get has
73
+ defaults and transforms applied.
74
+ - Every mutation also has `Invalid` = `{ message, fields }` (input failing its schema, or
75
+ `fail('Invalid', { message, fields: { title: 'Taken' } })`); never declare `Invalid` or `Unexpected` yourself.