@fabrika/app 0.3.3 → 0.4.1

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 (116) hide show
  1. package/README.md +78 -90
  2. package/package.json +77 -6
  3. package/skill/SKILL.md +319 -0
  4. package/skill/references/app-setup.md +370 -0
  5. package/skill/references/dev.md +90 -0
  6. package/skill/references/endpoint.md +640 -0
  7. package/skill/references/errors.md +108 -0
  8. package/skill/references/files.md +335 -0
  9. package/skill/references/jobs.md +169 -0
  10. package/skill/references/public-intake.md +1495 -0
  11. package/skill/references/spa.md +746 -0
  12. package/skill/references/start.md +179 -0
  13. package/skill/references/table-and-migration.md +264 -0
  14. package/skill/references/tests.md +308 -0
  15. package/skill/references/webhooks-links-connectors.md +190 -0
  16. package/src/agent-skill.ts +77 -0
  17. package/src/build.ts +53 -0
  18. package/src/bun.ts +302 -3
  19. package/src/client/caller.ts +24 -0
  20. package/src/client/contract.ts +331 -0
  21. package/src/client/files.ts +135 -0
  22. package/src/client/index.ts +19 -0
  23. package/src/client/rpc.ts +47 -0
  24. package/src/client/share.ts +14 -0
  25. package/src/{client.ts → client/transport.ts} +58 -61
  26. package/src/cloudflare.ts +139 -3
  27. package/src/contract/artifact-schema.ts +317 -0
  28. package/src/contract/attachment.ts +74 -0
  29. package/src/contract/build.ts +1932 -0
  30. package/src/contract/compile.ts +143 -0
  31. package/src/contract/connect.ts +170 -0
  32. package/src/contract/context.ts +444 -0
  33. package/src/contract/declaration.ts +310 -0
  34. package/src/contract/defaults.ts +161 -0
  35. package/src/contract/define.ts +567 -0
  36. package/src/contract/file-schemas.ts +25 -0
  37. package/src/contract/group.ts +263 -0
  38. package/src/contract/handled.ts +50 -0
  39. package/src/contract/index.ts +34 -0
  40. package/src/contract/kit.ts +607 -0
  41. package/src/contract/platform.ts +113 -0
  42. package/src/contract/problems.ts +30 -0
  43. package/src/contract/schema-from-shape.ts +152 -0
  44. package/src/contract/schema-shape.ts +407 -0
  45. package/src/contract/snapshot-source.ts +168 -0
  46. package/src/contract/vocabulary.ts +50 -0
  47. package/src/disk-assets.ts +25 -0
  48. package/src/fetch.ts +10 -0
  49. package/src/files/archive.ts +292 -0
  50. package/src/files/clamscan.ts +253 -0
  51. package/src/files/collections.ts +669 -0
  52. package/src/files/handle.ts +77 -0
  53. package/src/files/scan.ts +308 -0
  54. package/src/files/sniff.ts +70 -0
  55. package/src/files/treatment.ts +48 -0
  56. package/src/from-row.ts +53 -0
  57. package/src/implement/anonymous.ts +102 -0
  58. package/src/implement/attachment.ts +143 -0
  59. package/src/implement/boot.ts +824 -0
  60. package/src/implement/bounded.ts +72 -0
  61. package/src/implement/browser-config.ts +54 -0
  62. package/src/implement/capability.ts +439 -0
  63. package/src/implement/challenge.ts +120 -0
  64. package/src/implement/connect.ts +386 -0
  65. package/src/implement/dispatch.ts +1095 -0
  66. package/src/implement/environment.ts +47 -0
  67. package/src/implement/failures.ts +116 -0
  68. package/src/implement/faults.ts +46 -0
  69. package/src/implement/form-admission.ts +120 -0
  70. package/src/implement/headers.ts +17 -0
  71. package/src/implement/implement.ts +81 -0
  72. package/src/implement/index.ts +60 -0
  73. package/src/implement/invocation.ts +346 -0
  74. package/src/implement/membership.ts +485 -0
  75. package/src/implement/principal.ts +99 -0
  76. package/src/implement/project.ts +76 -0
  77. package/src/implement/serve.ts +142 -0
  78. package/src/implement/types.ts +215 -0
  79. package/src/implement/webhook.ts +108 -0
  80. package/src/implement/workload.ts +160 -0
  81. package/src/implement/xlsx.ts +275 -0
  82. package/src/index.ts +12 -13
  83. package/src/jobs/report.ts +134 -0
  84. package/src/jobs/runner.ts +760 -0
  85. package/src/jobs/schedule.ts +129 -0
  86. package/src/jobs/types.ts +44 -0
  87. package/src/react/caller.tsx +60 -0
  88. package/src/react/errors.ts +82 -0
  89. package/src/react/hooks.ts +281 -0
  90. package/src/react/index.ts +34 -0
  91. package/src/react/provider.tsx +179 -0
  92. package/src/react/store.ts +215 -0
  93. package/src/react/testing.ts +144 -0
  94. package/src/react/types.ts +40 -0
  95. package/src/rpc/dispatcher.ts +4 -19
  96. package/src/service/define.ts +121 -0
  97. package/src/service/index.ts +21 -0
  98. package/src/service/serve.ts +99 -0
  99. package/src/testing/caller.ts +123 -0
  100. package/src/testing/challenge.ts +10 -0
  101. package/src/testing/dev.ts +99 -0
  102. package/src/testing/endpoint-access.ts +809 -0
  103. package/src/testing/index.ts +36 -0
  104. package/src/testing/isolation.ts +25 -0
  105. package/src/testing/kit.ts +585 -0
  106. package/src/testing/membership.ts +797 -0
  107. package/src/testing/policy-isolation.ts +827 -0
  108. package/src/testing/preload.ts +6 -0
  109. package/src/testing/proxy.ts +87 -0
  110. package/src/testing/runner.ts +61 -0
  111. package/src/testing/status.ts +19 -0
  112. package/src/undumpable.ts +42 -0
  113. package/src/app.ts +0 -76
  114. package/src/middleware.ts +0 -38
  115. package/src/router.ts +0 -149
  116. package/src/testing.ts +0 -119
@@ -0,0 +1,746 @@
1
+ # references/spa.md — the SPA: React, the hooks over the typed client, and the component kit
2
+
3
+ The SPA of the paved road is React. `@fabrika/app/react` runs the typed client's calls and caches their answers;
4
+ `@fabrika/ui` holds the screens' components, a router and one theme stylesheet. Build every screen from them. Never
5
+ write a DOM element builder, a router, a cache or a component set of your own: when the kit lacks a piece, compose it
6
+ from the kit's components and the theme's tokens in the application's own stylesheet. `app-setup.md` shows the
7
+ dependencies, `tsconfig.json` (`"jsx": "react-jsx"`) and `build-web.ts`.
8
+
9
+ | Module | Holds |
10
+ | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
11
+ | `@fabrika/app/client` | `createClient(contract, 'api')`: one function per procedure, its answer parsed by the output schema |
12
+ | `@fabrika/app/react` | `FabrikaProvider`, `useQuery`, `useInfiniteQuery`, `useMutation`, `useAction`, `useDownload`, `useInvalidate`, `skip`, `useCaller`, `Can`, `FabrikaError` |
13
+ | `@fabrika/app/react/testing` | `installDom`, `type`, `controlByLabel`, `waitForText`: a screen test's DOM and typing |
14
+ | `@fabrika/ui` | the components below, `createRouter`, and `@fabrika/ui/styles.css` |
15
+
16
+ ## The provider and the routes
17
+
18
+ ```ts web/routes.ts
19
+ import { createRouter } from '@fabrika/ui'
20
+
21
+ export const { Router, Link, href, navigate, useRoute } = createRouter({
22
+ mine: '/',
23
+ project: '/orgs/:orgId/projects/:projectId',
24
+ task: '/tasks/:id',
25
+ })
26
+ ```
27
+
28
+ ```tsx web/start.tsx
29
+ import '@fabrika/ui/styles.css'
30
+ import './app.css'
31
+ import { FabrikaProvider, useCaller } from '@fabrika/app/react'
32
+ import { AppShell, EmptyState, ToastProvider } from '@fabrika/ui'
33
+ import { StrictMode } from 'react'
34
+ import { createRoot } from 'react-dom/client'
35
+ import { api } from './api'
36
+ import { MyTasks } from './mine'
37
+ import { ProjectTasks } from './project'
38
+ import { href, Router, useRoute } from './routes'
39
+ import { TaskView } from './task'
40
+
41
+ function Screen() {
42
+ const route = useRoute()
43
+ switch (route?.name) {
44
+ case 'mine':
45
+ return <MyTasks />
46
+ case 'project':
47
+ return (
48
+ <ProjectTasks
49
+ orgId={route.params.orgId}
50
+ projectId={route.params.projectId}
51
+ />
52
+ )
53
+ case 'task':
54
+ return <TaskView key={route.params.id} id={route.params.id} />
55
+ case undefined:
56
+ return (
57
+ <EmptyState title="Nothing here" text="This address names no screen." />
58
+ )
59
+ }
60
+ }
61
+
62
+ function Shell() {
63
+ const route = useRoute()
64
+ const caller = useCaller()
65
+ return (
66
+ <AppShell
67
+ brand="Tasks"
68
+ nav={[{
69
+ label: 'My tasks',
70
+ href: href('mine'),
71
+ active: route?.name === 'mine',
72
+ }]}
73
+ user={caller?.label ?? caller?.email}
74
+ >
75
+ <Screen />
76
+ </AppShell>
77
+ )
78
+ }
79
+
80
+ const root = document.getElementById('root')
81
+ if (root !== null) {
82
+ createRoot(root).render(
83
+ <StrictMode>
84
+ <FabrikaProvider clients={[api]}>
85
+ <ToastProvider>
86
+ <Router>
87
+ <Shell />
88
+ </Router>
89
+ </ToastProvider>
90
+ </FabrikaProvider>
91
+ </StrictMode>,
92
+ )
93
+ }
94
+ ```
95
+
96
+ - `FabrikaProvider` holds the clients of the mounts the page calls. A hook takes a procedure of a client
97
+ (`api.tasks.get`) as the typed name of the call and runs it on the provider's client of that mount; a test hands the
98
+ provider a client over the test kit instead (below). An SPA mounted under a prefix passes it: `spa="/app"`. Its bare
99
+ prefix (`/app`) redirects to `/app/`, so a link may name either.
100
+ - `createRouter` answers typed paths: `href('task', { id })`, `navigate('project', { orgId, projectId })`, `<Link
101
+ to="task" params={{ id }}>`, and `useRoute()`, the match or `null`. `<Router>` makes every plain `<a href>` under the
102
+ SPA navigate without a reload: the kit's components render links that way. Pass `{ base: '/app' }` for a prefix.
103
+ - `ToastProvider` once at the root; `useToast().show({ tone: 'success', message })` anywhere below it.
104
+
105
+ ## Reading: useQuery and useInfiniteQuery
106
+
107
+ ```ts web/api.ts
108
+ import { createClient } from '@fabrika/app/client'
109
+ import type { PageAnswer, ProcedureResult } from '@fabrika/app/react'
110
+ import { contract } from '../fabrika.contract'
111
+
112
+ export const api = createClient(contract, 'api', { bounceOnAuth: true })
113
+
114
+ type ItemOf<P> = P extends PageAnswer<infer Item> ? Item : never
115
+
116
+ export type Task = ProcedureResult<typeof api.tasks.get>
117
+ export type Comment = ItemOf<ProcedureResult<typeof api.comments.list>>
118
+ ```
119
+
120
+ ```tsx web/mine.tsx
121
+ import { Can, useInfiniteQuery, useMutation } from '@fabrika/app/react'
122
+ import {
123
+ Button,
124
+ type Column,
125
+ DataTable,
126
+ PageHeader,
127
+ StatusPill,
128
+ Tabs,
129
+ useSelection,
130
+ useToast,
131
+ } from '@fabrika/ui'
132
+ import { useState } from 'react'
133
+ import { api, type Task } from './api'
134
+ import { Link } from './routes'
135
+
136
+ type Tab = 'open' | 'doing'
137
+
138
+ const columns: readonly Column<Task>[] = [
139
+ {
140
+ key: 'title',
141
+ header: 'Task',
142
+ grow: true,
143
+ cell: (task) => <Link to="task" params={{ id: task.id }}>{task.title}</Link>,
144
+ },
145
+ { key: 'due', header: 'Due', cell: (task) => task.dueOn ?? '' },
146
+ {
147
+ key: 'status',
148
+ header: 'Status',
149
+ cell: (task) => (
150
+ <StatusPill
151
+ tone={task.overdue
152
+ ? 'danger'
153
+ : task.status === 'doing'
154
+ ? 'info'
155
+ : 'neutral'}
156
+ >
157
+ {task.status}
158
+ </StatusPill>
159
+ ),
160
+ },
161
+ ]
162
+
163
+ export function MyTasks() {
164
+ const mine = useInfiniteQuery(api.tasks.mine, {})
165
+ const start = useMutation(api.tasks.start)
166
+ const toast = useToast()
167
+ const selection = useSelection()
168
+ const [tab, setTab] = useState<Tab>('open')
169
+ const shown = mine.items.filter((task) => task.status === tab)
170
+ const startAll = async (ids: readonly string[]) => {
171
+ for (const task of shown.filter((candidate) => ids.includes(candidate.id))) {
172
+ const outcome = await start.mutate({
173
+ id: task.id,
174
+ expectVersion: task.version,
175
+ })
176
+ if (!outcome.ok) {
177
+ toast.show({ tone: 'danger', message: outcome.error.message })
178
+ }
179
+ }
180
+ selection.clear()
181
+ }
182
+ return (
183
+ <>
184
+ <PageHeader
185
+ title="My tasks"
186
+ lead="Open tasks assigned to you, soonest first."
187
+ />
188
+ <Tabs
189
+ label="Task status"
190
+ items={[
191
+ {
192
+ key: 'open',
193
+ label: 'Open',
194
+ count: mine.items.filter((task) => task.status === 'open').length,
195
+ },
196
+ {
197
+ key: 'doing',
198
+ label: 'In progress',
199
+ count: mine.items.filter((task) => task.status === 'doing').length,
200
+ },
201
+ ]}
202
+ active={tab}
203
+ onChange={setTab}
204
+ />
205
+ <DataTable
206
+ caption="My tasks"
207
+ columns={columns}
208
+ rows={shown}
209
+ rowKey={(task) => task.id}
210
+ status={mine.status === 'error'
211
+ ? 'error'
212
+ : mine.status === 'success'
213
+ ? 'ready'
214
+ : 'loading'}
215
+ error={mine.error?.message}
216
+ onRetry={() => void mine.retry()}
217
+ empty="Nothing assigned to you."
218
+ loadMore={{
219
+ hasMore: mine.hasMore,
220
+ loading: mine.loadingMore,
221
+ onLoadMore: () => void mine.loadMore(),
222
+ }}
223
+ selection={tab === 'open' ? selection : undefined}
224
+ selectRowLabel={(task) => `Select ${task.title}`}
225
+ selectionActions={(ids) => (
226
+ <Can action="tasks.write">
227
+ <Button
228
+ size="sm"
229
+ variant="primary"
230
+ loading={start.pending}
231
+ onClick={() => void startAll(ids)}
232
+ >
233
+ Start
234
+ </Button>
235
+ </Can>
236
+ )}
237
+ />
238
+ </>
239
+ )
240
+ }
241
+ ```
242
+
243
+ - `useQuery(api.tasks.get, { id })` answers `{ status, data, error, fetching, refetch }`: `status` is `'loading'`,
244
+ `'success'` (then `data` is the parsed answer, typed by the contract), `'error'` (then `error` is a `FabrikaError`)
245
+ or `'idle'`. Read `data` only after checking `status`; the compiler insists. A procedure without input takes none:
246
+ `useQuery(api.members.orgs)`.
247
+ - Pass `skip` as the input while a value it needs is not known yet: `useQuery(api.members.list, project ? { orgId } :
248
+ skip)` sends nothing until then.
249
+ - Every view of one call with one input shares one request and one cached answer.
250
+ - `useInfiniteQuery(procedure, input)` is for a list that answers `{ items, next }` and takes `after`: `items` are the
251
+ loaded pages, `hasMore` and `loadMore()` the next page. `{ pages: 'all' }` reads every page at once, for a picker's
252
+ options; use it for small lists only.
253
+ - An offset list that answers `{ items, total }` and takes `limit` and `offset` is `useQuery` with `{ keepPrevious:
254
+ true }`: while the answer of a new `limit` or `offset` loads, `data` stays the previous answer (`status: 'success'`,
255
+ `fetching: true`), so the table keeps its rows. "Load more (n of N)" grows `limit` in the page's state:
256
+ `useQuery(api.tasks.queue, { orgId, limit, offset: 0 }, { keepPrevious: true })` with `loadMore={{ hasMore:
257
+ data.items.length < data.total, loading: queue.fetching, onLoadMore: () => setLimit(limit + 50) }}`; a pager sets
258
+ `offset` instead. A filter change resets `limit` or `offset`.
259
+
260
+ ## Writing: useMutation, forms and their errors
261
+
262
+ ```tsx web/edit-task.tsx
263
+ import { type FabrikaError, useMutation } from '@fabrika/app/react'
264
+ import {
265
+ Button,
266
+ DateInput,
267
+ Dialog,
268
+ Field,
269
+ Form,
270
+ FormActions,
271
+ type FormErrorLike,
272
+ Input,
273
+ } from '@fabrika/ui'
274
+ import type { Task } from './api'
275
+ import { api } from './api'
276
+
277
+ /** What the person reads: the field errors as they are, a stale version and a missing role in plain words. */
278
+ export function shown(error: FabrikaError | null): FormErrorLike | null {
279
+ if (error === null) return null
280
+ if (error.kind === 'conflict') {
281
+ return {
282
+ message: 'Someone changed this task meanwhile. Close and open it again.',
283
+ }
284
+ }
285
+ if (error.kind === 'forbidden') {
286
+ return { message: 'Your role does not allow this.' }
287
+ }
288
+ return error
289
+ }
290
+
291
+ const text = (form: FormData, name: string): string => {
292
+ const value = form.get(name)
293
+ return typeof value === 'string' ? value.trim() : ''
294
+ }
295
+
296
+ export function EditTask(
297
+ { task, open, onClose }: {
298
+ readonly task: Task
299
+ readonly open: boolean
300
+ readonly onClose: () => void
301
+ },
302
+ ) {
303
+ const edit = useMutation(api.tasks.edit)
304
+ const close = () => {
305
+ edit.reset()
306
+ onClose()
307
+ }
308
+ const submit = async (form: FormData) => {
309
+ const due = text(form, 'dueOn')
310
+ const outcome = await edit.mutate({
311
+ id: task.id,
312
+ expectVersion: task.version,
313
+ title: text(form, 'title'),
314
+ dueOn: due === '' ? null : due,
315
+ })
316
+ if (outcome.ok) close()
317
+ }
318
+ return (
319
+ <Dialog open={open} title="Edit task" onClose={close}>
320
+ <Form
321
+ onSubmit={(form) => void submit(form)}
322
+ error={shown(edit.error)}
323
+ aria-label="Edit task"
324
+ >
325
+ <Field label="Title" name="title" required>
326
+ <Input defaultValue={task.title} />
327
+ </Field>
328
+ <Field label="Due on" name="dueOn" hint="Leave empty for no due date.">
329
+ <DateInput defaultValue={task.dueOn ?? ''} />
330
+ </Field>
331
+ <FormActions>
332
+ <Button variant="ghost" onClick={close}>Cancel</Button>
333
+ <Button type="submit" variant="primary" loading={edit.pending}>
334
+ Save
335
+ </Button>
336
+ </FormActions>
337
+ </Form>
338
+ </Dialog>
339
+ )
340
+ }
341
+ ```
342
+
343
+ - `mutate(input)` never throws: it answers `{ ok: true, data }` or `{ ok: false, error }`, and the hook keeps
344
+ `status`, `pending`, `data` and `error`. It first checks the input with the procedure's own schema: a refused input
345
+ is a `validation` error with its field paths, and nothing is sent.
346
+ - After a success, and after a `conflict`, it refetches what its group shows (`api.tasks` for `api.tasks.edit`). Key
347
+ a form that edits a versioned row by its version (`key={task.version}`): after a conflict it reseeds from the
348
+ current row, and the next save sends the current `expectVersion`.
349
+ `useMutation(api.comments.add, { invalidates: [api.comments, api.tasks.get] })` names other procedures or groups;
350
+ `invalidates: []` refetches nothing. `useInvalidate()(api.tasks)` refetches on demand.
351
+ - `FabrikaError` is one union for every refusal: `unauthenticated` (`loginUrl`: the client made with `bounceOnAuth`
352
+ sends the person there itself), `forbidden` (the role lacks the action: tell the person, never loosen the contract),
353
+ `conflict` (a stale `expectVersion`, or a `ConflictError` of the handler), `validation` (`fields`: input path →
354
+ message, also a handler's `BadRequestError` without fields), `not-found`, `other` (`status`; `0` when no answer came).
355
+ - `Form` shows its `error`: a summary on top, and each `fields[name]` under the `Field` of that `name`. `Field` gives
356
+ its control the `name`, an `id`, its label and `aria-describedby`; the control needs nothing else. `Form` hands
357
+ `onSubmit` the `FormData`: read the fields by name, and leave the checking to the procedure's schema.
358
+ - A call that is no procedure — a file collection's `api.files.<name>.upload(owner, file)`, `list`, `download` —
359
+ runs through `useAction(async (input) => …)`, with the same state and error union; it refetches only what
360
+ `invalidates` names.
361
+ - An attachment procedure (`output: attachment(…)`) downloads through `useDownload(api.reports.exportProject)`:
362
+ `mutate(input)` saves the file and answers it, with the same state and error union. It runs on the provider's client,
363
+ so a screen test downloads over the kit; never call `download(api.…)` of the module's client from a screen.
364
+
365
+ ## Who is signed in: useCaller and Can
366
+
367
+ ```tsx web/project.tsx
368
+ import { Can, useCaller, useInfiniteQuery } from '@fabrika/app/react'
369
+ import {
370
+ ButtonLink,
371
+ Card,
372
+ EmptyState,
373
+ Field,
374
+ FilterBar,
375
+ Input,
376
+ PageHeader,
377
+ Select,
378
+ } from '@fabrika/ui'
379
+ import { useState } from 'react'
380
+ import { api } from './api'
381
+ import { href, Link } from './routes'
382
+
383
+ const STATUSES = [{ value: 'open', label: 'Open' }, {
384
+ value: 'doing',
385
+ label: 'In progress',
386
+ }, { value: 'done', label: 'Done' }]
387
+ const isStatus = (value: string): value is 'open' | 'doing' | 'done' =>
388
+ STATUSES.some((status) => status.value === value)
389
+
390
+ export function ProjectTasks(
391
+ { orgId, projectId }: { readonly orgId: string; readonly projectId: string },
392
+ ) {
393
+ const caller = useCaller()
394
+ const [status, setStatus] = useState('')
395
+ const [title, setTitle] = useState('')
396
+ const tasks = useInfiniteQuery(api.tasks.list, {
397
+ projectId,
398
+ ...(isStatus(status) ? { status } : {}),
399
+ ...(title.trim() === '' ? {} : { title: title.trim() }),
400
+ })
401
+ return (
402
+ <>
403
+ <PageHeader
404
+ title="Project tasks"
405
+ back={{ href: href('mine'), label: 'My tasks' }}
406
+ lead={caller === null ? undefined : `Signed in as ${caller.email}`}
407
+ actions={
408
+ <Can action="tasks.write" scope={{ type: 'org', value: orgId }}>
409
+ <ButtonLink variant="primary" href={href('mine')}>New task</ButtonLink>
410
+ </Can>
411
+ }
412
+ />
413
+ <FilterBar
414
+ onReset={() => {
415
+ setStatus('')
416
+ setTitle('')
417
+ }}
418
+ >
419
+ <Field label="Status">
420
+ <Select
421
+ value={status}
422
+ onChange={(event) => setStatus(event.currentTarget.value)}
423
+ placeholder="Any status"
424
+ options={STATUSES}
425
+ />
426
+ </Field>
427
+ <Field label="Title">
428
+ <Input
429
+ type="search"
430
+ value={title}
431
+ onChange={(event) => setTitle(event.currentTarget.value)}
432
+ />
433
+ </Field>
434
+ </FilterBar>
435
+ <Card title="Tasks">
436
+ {tasks.items.length === 0
437
+ ? <EmptyState title="No tasks" text="No task of this project matches." />
438
+ : (
439
+ <ul>
440
+ {tasks.items.map((task) => (
441
+ <li key={task.id}>
442
+ <Link to="task" params={{ id: task.id }}>{task.title}</Link>
443
+ </li>
444
+ ))}
445
+ </ul>
446
+ )}
447
+ </Card>
448
+ </>
449
+ )
450
+ }
451
+ ```
452
+
453
+ - `useCaller()` answers `{ label, email, can(action, scope?) }` from the SPA mount's caller document, or `null` while
454
+ it loads and on a public SPA. `can(action)` is the action held app-wide or in any scope value; `can(action, { type,
455
+ value })` app-wide or in that value. A grant on a container is not seen from a value inside it: ask at the dimension
456
+ the person holds the role in.
457
+ - `<Can action scope fallback>` shows its children only then. **It is information for the page, never a check**: the
458
+ proxy and the endpoint decide every call again, so a hidden button protects nothing, and a shown one may still be
459
+ refused (`forbidden`).
460
+ - A filter goes into the query's input (the server filters, the cache keys by it) when the procedure takes it, as
461
+ `status` and `title` above; filter loaded rows in the page only for what the procedure does not take.
462
+
463
+ ## A detail screen
464
+
465
+ ```tsx web/task.tsx
466
+ import { useInfiniteQuery, useMutation, useQuery } from '@fabrika/app/react'
467
+ import {
468
+ Button,
469
+ Card,
470
+ Columns,
471
+ ConfirmDialog,
472
+ ErrorState,
473
+ FieldList,
474
+ Form,
475
+ FormActions,
476
+ LoadingState,
477
+ PageHeader,
478
+ Section,
479
+ SidePanel,
480
+ StatusPill,
481
+ Textarea,
482
+ Timeline,
483
+ useToast,
484
+ } from '@fabrika/ui'
485
+ import { useState } from 'react'
486
+ import { api, type Task } from './api'
487
+ import { EditTask, shown } from './edit-task'
488
+ import { href } from './routes'
489
+
490
+ function Comments({ task }: { readonly task: Task }) {
491
+ const comments = useInfiniteQuery(api.comments.list, { taskId: task.id }, {
492
+ pages: 'all',
493
+ })
494
+ const add = useMutation(api.comments.add)
495
+ const [formKey, setFormKey] = useState(0)
496
+ return (
497
+ <Section title="Comments">
498
+ <Timeline
499
+ items={comments.items.map((comment) => ({
500
+ id: comment.id,
501
+ at: comment.createdAt.slice(0, 16),
502
+ title: comment.createdBy,
503
+ body: comment.body,
504
+ }))}
505
+ empty="No comments yet."
506
+ />
507
+ <Form
508
+ key={formKey}
509
+ error={shown(add.error)}
510
+ onSubmit={async (form) => {
511
+ const body = form.get('body')
512
+ if (
513
+ (await add.mutate({
514
+ taskId: task.id,
515
+ body: typeof body === 'string' ? body : '',
516
+ })).ok
517
+ ) setFormKey(formKey + 1)
518
+ }}
519
+ >
520
+ <Textarea name="body" rows={2} aria-label="Comment" />
521
+ <FormActions>
522
+ <Button type="submit" loading={add.pending}>Comment</Button>
523
+ </FormActions>
524
+ </Form>
525
+ </Section>
526
+ )
527
+ }
528
+
529
+ function TaskScreen({ task }: { readonly task: Task }) {
530
+ const close = useMutation(api.tasks.close)
531
+ const toast = useToast()
532
+ const [editing, setEditing] = useState(false)
533
+ const [closing, setClosing] = useState(false)
534
+ return (
535
+ <>
536
+ <PageHeader
537
+ title={task.title}
538
+ back={{ href: href('mine'), label: 'My tasks' }}
539
+ meta={
540
+ <StatusPill tone={task.overdue ? 'danger' : 'neutral'}>
541
+ {task.status}
542
+ </StatusPill>
543
+ }
544
+ actions={
545
+ <>
546
+ <Button onClick={() => setEditing(true)}>Edit</Button>
547
+ <Button variant="danger" onClick={() => setClosing(true)}>
548
+ Close task
549
+ </Button>
550
+ </>
551
+ }
552
+ />
553
+ <Columns>
554
+ <Comments task={task} />
555
+ <SidePanel>
556
+ <Card title="Details">
557
+ <FieldList
558
+ items={[{ label: 'Assignee', value: task.assigneeLabel }, {
559
+ label: 'Due on',
560
+ value: task.dueOn,
561
+ }]}
562
+ />
563
+ </Card>
564
+ </SidePanel>
565
+ </Columns>
566
+ <EditTask task={task} open={editing} onClose={() => setEditing(false)} />
567
+ <ConfirmDialog
568
+ open={closing}
569
+ title="Close this task?"
570
+ message="A closed task leaves the lists of open work."
571
+ confirmLabel="Close task"
572
+ tone="danger"
573
+ pending={close.pending}
574
+ onCancel={() => setClosing(false)}
575
+ onConfirm={async () => {
576
+ const outcome = await close.mutate({
577
+ id: task.id,
578
+ expectVersion: task.version,
579
+ })
580
+ setClosing(false)
581
+ toast.show(
582
+ outcome.ok
583
+ ? { tone: 'success', message: 'Task closed' }
584
+ : { tone: 'danger', message: outcome.error.message },
585
+ )
586
+ }}
587
+ />
588
+ </>
589
+ )
590
+ }
591
+
592
+ export function TaskView({ id }: { readonly id: string }) {
593
+ const task = useQuery(api.tasks.get, { id })
594
+ if (task.status === 'success') return <TaskScreen task={task.data} />
595
+ if (task.status === 'error') {
596
+ return (
597
+ <ErrorState
598
+ text={task.error.message}
599
+ onRetry={() => void task.refetch()}
600
+ />
601
+ )
602
+ }
603
+ return <LoadingState />
604
+ }
605
+ ```
606
+
607
+ ## The components
608
+
609
+ All are named exports of `@fabrika/ui`, keyboard operable, and labelled for assistive technology. Text they render
610
+ themselves ("Load more", "Clear selection", "Close", "Retry") is a prop with an English default.
611
+
612
+ | Component | What it is |
613
+ | -------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
614
+ | `AppShell({ brand, nav, user, actions, topBar, navStyle })` | sidebar navigation (`{ label, href, active, count }`), a top bar with the caller (none without `user` or `actions`, or with `topBar={false}`), the main region; `navStyle="filled"` fills the current item |
615
+ | `PageHeader({ title, lead, back, actions, meta })` | the screen's `h1`, a line of purpose, a back link, its buttons; one `primary` button per screen |
616
+ | `Tabs({ label, items, active, onChange, variant })` | tabs with counts; arrow keys, Home and End; `variant="boxed"` frames each tab, its count after a dot |
617
+ | `FilterBar({ onReset })` + `Field` | a row of filters with a reset |
618
+ | `Field({ label, name, hint, error, required })` | label, hint and error around one control; the error comes from the `Form` by `name` |
619
+ | `Input`, `Textarea`, `Select({ options, placeholder })` | the controls; native props pass through |
620
+ | `DateInput`, `DateRange({ from, to, onChange })` | a day, and a from–to pair of days |
621
+ | `Checkbox({ label, checked, onChange, indeterminate })` | one checkbox |
622
+ | `RadioGroup({ label, name, options, value, onChange })` | a fieldset of radios |
623
+ | `Button({ variant, size, loading })`, `ButtonLink` | `primary`, `secondary` (default), `ghost`, `danger`; `loading` disables and shows a spinner |
624
+ | `DataTable({ columns, rows, rowKey, status, selection, selectionActions, loadMore, empty })` | compact rows, loading/error/empty rows, a selection toolbar, a load-more button; a column takes `width` (`'xs'`…`'xl'`) and `wrap` |
625
+ | `useSelection()` | the `selection` a `DataTable` takes, with `clear()` |
626
+ | `StatusPill({ tone, dot })` | `neutral`, `info`, `success`, `warning`, `danger`, `accent`; `dot={false}` leaves the dot out |
627
+ | `FieldList({ items, columns })` | label/value pairs; an empty value shows a dash |
628
+ | `Card({ title, actions, footer })`, `Section({ title, description, actions })` | a framed block, and a titled block without the frame |
629
+ | `Columns` + `SidePanel` | the main column and a side column, stacked on a narrow screen |
630
+ | `Dialog({ open, title, onClose })`, `ConfirmDialog` | modal: focus moves in and is trapped, Escape closes, focus returns |
631
+ | `ToastProvider`, `useToast().show({ tone, message })` | short notices that leave by themselves |
632
+ | `Timeline({ items, empty })` | history and notes: `{ id, at, title, body }` |
633
+ | `EmptyState`, `ErrorState({ text, onRetry })`, `LoadingState`, `Spinner`, `CountBadge` | the states of a region |
634
+ | `Stack({ gap })`, `Inline({ gap, align, justify, wrap })` | layout with the spacing scale |
635
+ | `Form({ onSubmit, error })`, `FormActions` | a form that shows a hook's error: the summary and each field's message |
636
+ | `createRouter(routes, { base })` | `Router`, `Link`, `useRoute`, `href`, `navigate`, typed by the patterns |
637
+
638
+ ## Theming
639
+
640
+ `@fabrika/ui/styles.css` holds the tokens (`theme.css`) and the components' rules, which read the tokens only. The
641
+ application imports it first, then its own stylesheet, which overrides tokens and styles only what the kit lacks:
642
+
643
+ ```css
644
+ /* web/app.css */
645
+ :root {
646
+ --fui-accent: #0f766e;
647
+ --fui-accent-hover: #115e59;
648
+ --fui-accent-soft: #e6f4f2;
649
+ }
650
+
651
+ body {
652
+ margin: 0;
653
+ background: var(--fui-bg);
654
+ }
655
+ ```
656
+
657
+ - The palette is neutral with one accent (`--fui-accent*`), plus tones for status (`--fui-success`, `--fui-warning`,
658
+ `--fui-danger`, `--fui-info`, each with `-soft` and `-text`). Typography (`--fui-font-family`, `--fui-text-*`),
659
+ spacing (`--fui-space-1`…`8`), radii, borders and shadows are tokens too.
660
+ - So are the parts a design changes most: the main region's padding (`--fui-main-padding`, `-narrow` below the
661
+ breakpoint), a field's label (`--fui-label-size`, `--fui-label-weight`, `--fui-label-color`), a table's column heads
662
+ (`--fui-table-head-size`, `-weight`, `-color`, `-transform`, `-tracking`: `uppercase` and `0.04em` for small
663
+ capitals) and the column widths (`--fui-column-width-xs`…`xl`). Change the look through tokens and the components'
664
+ props (`navStyle`, `variant`, `dot`, a column's `width` and `wrap`); never restyle a kit class name (`.fui-…`), which
665
+ is no contract and changes with the kit.
666
+ - Light is the default. `<html data-theme="dark">` switches to the dark token set; the page decides when (a toggle, or
667
+ `matchMedia('(prefers-color-scheme: dark)')`).
668
+ - The kit loads nothing from another origin, so it runs under the default CSP. The font token is a system stack; for
669
+ a typeface, put its files in the SPA's bundle (import its `@font-face` stylesheet from `web/start.tsx`) and
670
+ override `--fui-font-family`, or name the font service's origins in the contract's `csp` (`styleSrc`, `fontSrc`,
671
+ `app-setup.md`).
672
+
673
+ ## Testing a screen
674
+
675
+ A screen test renders the page in happy-dom (`@happy-dom/global-registrator`, a devDependency) over the application
676
+ itself, behind the test kit's proxy decision: the provider gets a client whose `fetch` is the kit's browser for a
677
+ test caller. `installDom` of `@fabrika/app/react/testing` sets the DOM up.
678
+
679
+ ```ts web/__tests__/dom.ts
680
+ import { installDom } from '@fabrika/app/react/testing'
681
+ import { afterAll } from 'bun:test'
682
+
683
+ const dom = installDom({ url: 'http://tasks.test/' })
684
+ afterAll(() => dom.uninstall())
685
+ ```
686
+
687
+ ```tsx web/__tests__/mine.test.tsx
688
+ import './dom'
689
+ import { createClient } from '@fabrika/app/client'
690
+ import { FabrikaProvider } from '@fabrika/app/react'
691
+ import { waitForText } from '@fabrika/app/react/testing'
692
+ import { testKit } from '@fabrika/app/testing'
693
+ import { testDatabase } from '@fabrika/db/testing'
694
+ import { ToastProvider } from '@fabrika/ui'
695
+ import { afterAll, expect, test } from 'bun:test'
696
+ import { createRoot } from 'react-dom/client'
697
+ import { contract } from '../../fabrika.contract'
698
+ import app from '../../src/app'
699
+ import { MyTasks } from '../mine'
700
+
701
+ const postgres = await testDatabase({
702
+ migrations: new URL('../../db/migrations/', import.meta.url),
703
+ label: 'web/mine.test.tsx',
704
+ })
705
+
706
+ if (postgres !== null) {
707
+ afterAll(() => postgres.close())
708
+ const kit = testKit(app, { database: postgres.database })
709
+ const member = kit.testCaller([{
710
+ role: 'member',
711
+ scope: kit.scope('org', 'acme'),
712
+ }])
713
+
714
+ test('a member with nothing assigned sees the empty list, and no start button', async () => {
715
+ const fetch = kit.browser(member)
716
+ const root = createRoot(
717
+ document.body.appendChild(document.createElement('div')),
718
+ )
719
+ root.render(
720
+ <FabrikaProvider
721
+ clients={[createClient(contract, 'api', { fetch })]}
722
+ fetch={fetch}
723
+ >
724
+ <ToastProvider>
725
+ <MyTasks />
726
+ </ToastProvider>
727
+ </FabrikaProvider>,
728
+ )
729
+ await waitForText('Nothing assigned to you.')
730
+ expect(document.body.textContent).toContain('Nothing assigned to you.')
731
+ expect(document.querySelector('[role="toolbar"]')).toBeNull()
732
+ root.unmount()
733
+ })
734
+ }
735
+ ```
736
+
737
+ - `web/__tests__/dom.ts` comes first: imports run in order, and React reads `window` when it loads. `installDom` keeps
738
+ Bun's `fetch`, `AbortController`, `URL`, `Blob`, `File` and `FormData`, because the clients, the kit, a form's
739
+ challenge and a download run on them, and turns navigation off, so a download's link click leaves the page for the
740
+ next test. Run the tests with `bun test --isolate`, which gives each file its own globals.
741
+ - `await type('Title', 'report')` types into the control whose label (or `aria-label`) is `Title` as React reads a
742
+ person typing; a select takes the option's value. `controlByLabel(label)` answers the control, and
743
+ `waitForText(text)` waits until the page shows it.
744
+ - Wait for what the page shows, as a person does; assert on text, roles and ARIA, never on layout (happy-dom lays out
745
+ nothing). A refused twin is a caller without the role: the button is gone, and the endpoint still answers 403.
746
+ - Pure mapping (a label, a tone, a field's value) is a function of `web/` with a plain test.