@zerotal/arch 1.7.3 → 1.7.5

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 (127) hide show
  1. package/CHANGELOG.md +34 -2
  2. package/README.md +4 -2
  3. package/api-surface.md +4 -4
  4. package/docs/about.md +16 -16
  5. package/docs/admin/actions.md +9 -9
  6. package/docs/admin/auth.md +2 -2
  7. package/docs/admin/dashboard.md +5 -5
  8. package/docs/admin/extending-ui.md +12 -12
  9. package/docs/admin/extending.md +16 -7
  10. package/docs/admin/forms.md +4 -4
  11. package/docs/admin/index.md +2 -2
  12. package/docs/admin/operations.md +9 -9
  13. package/docs/admin/resources.md +3 -3
  14. package/docs/admin/structure.md +6 -6
  15. package/docs/admin/tables.md +8 -8
  16. package/docs/admin/testing.md +4 -4
  17. package/docs/ai.md +118 -11
  18. package/docs/application.md +12 -12
  19. package/docs/arch.md +25 -3
  20. package/docs/assets.md +11 -11
  21. package/docs/audit.md +13 -13
  22. package/docs/authentication.md +39 -39
  23. package/docs/authorization.md +13 -13
  24. package/docs/broadcasting/channels.md +2 -2
  25. package/docs/broadcasting/client.md +2 -2
  26. package/docs/broadcasting/events.md +9 -10
  27. package/docs/broadcasting/index.md +1 -1
  28. package/docs/broadcasting/testing.md +5 -5
  29. package/docs/cache.md +15 -13
  30. package/docs/carbon.md +35 -35
  31. package/docs/changelog.md +165 -1
  32. package/docs/client/index.md +277 -70
  33. package/docs/commands.md +4 -4
  34. package/docs/components.md +116 -115
  35. package/docs/config-system.md +4 -4
  36. package/docs/container.md +27 -27
  37. package/docs/context.md +27 -27
  38. package/docs/contributing.md +46 -0
  39. package/docs/controllers.md +10 -10
  40. package/docs/conventions.md +23 -15
  41. package/docs/cookies.md +3 -3
  42. package/docs/csrf.md +6 -6
  43. package/docs/database.md +22 -17
  44. package/docs/deployment.md +2 -2
  45. package/docs/devtools.md +16 -16
  46. package/docs/email-verification.md +8 -8
  47. package/docs/encryption.md +7 -7
  48. package/docs/errors.md +6 -6
  49. package/docs/events.md +12 -11
  50. package/docs/flow/components.md +32 -32
  51. package/docs/flow/decorators.md +18 -18
  52. package/docs/flow/events.md +16 -16
  53. package/docs/flow/forms.md +17 -17
  54. package/docs/flow/icons.md +199 -0
  55. package/docs/flow/index.md +20 -20
  56. package/docs/flow/layouts.md +35 -35
  57. package/docs/flow/lifecycle.md +16 -16
  58. package/docs/flow/models.md +9 -9
  59. package/docs/flow/pagination.md +8 -8
  60. package/docs/flow/performance.md +7 -7
  61. package/docs/flow/references.md +3 -3
  62. package/docs/flow/routing.md +26 -26
  63. package/docs/flow/testing.md +20 -20
  64. package/docs/getting-started.md +18 -13
  65. package/docs/health.md +17 -8
  66. package/docs/helpers.md +17 -17
  67. package/docs/i18n.md +10 -10
  68. package/docs/inertia/devtools.md +4 -4
  69. package/docs/inertia/index.md +12 -7
  70. package/docs/inertia/props.md +22 -22
  71. package/docs/inertia/rendering.md +86 -7
  72. package/docs/inertia/ssr.md +2 -2
  73. package/docs/lifecycle.md +2 -2
  74. package/docs/lock.md +11 -11
  75. package/docs/logger.md +12 -12
  76. package/docs/media.md +22 -22
  77. package/docs/middleware.md +17 -17
  78. package/docs/migrations.md +13 -13
  79. package/docs/monitor.md +6 -6
  80. package/docs/notifications.md +24 -24
  81. package/docs/orm/casts.md +14 -14
  82. package/docs/orm/factories.md +8 -8
  83. package/docs/orm/index.md +22 -20
  84. package/docs/orm/lifecycle.md +13 -13
  85. package/docs/orm/queries.md +28 -28
  86. package/docs/orm/relationships.md +16 -16
  87. package/docs/orm/serialization.md +11 -11
  88. package/docs/package-development.md +10 -10
  89. package/docs/pagination.md +14 -14
  90. package/docs/password-reset.md +6 -6
  91. package/docs/providers.md +11 -11
  92. package/docs/query-builder.md +18 -18
  93. package/docs/queue.md +14 -12
  94. package/docs/rate-limiting.md +6 -6
  95. package/docs/responses.md +14 -14
  96. package/docs/roles-and-2fa.md +9 -9
  97. package/docs/routing.md +44 -44
  98. package/docs/scaffolding.md +1 -1
  99. package/docs/scheduler.md +10 -10
  100. package/docs/seeding.md +6 -6
  101. package/docs/session.md +16 -14
  102. package/docs/social.md +14 -14
  103. package/docs/storage.md +25 -13
  104. package/docs/structure.md +2 -2
  105. package/docs/support-policy.md +36 -8
  106. package/docs/telemetry.md +12 -12
  107. package/docs/tenancy.md +34 -40
  108. package/docs/testing/browser.md +6 -6
  109. package/docs/testing/console.md +3 -3
  110. package/docs/testing/database.md +10 -10
  111. package/docs/testing/flow-browser.md +6 -6
  112. package/docs/testing/http.md +12 -12
  113. package/docs/testing/index.md +3 -3
  114. package/docs/testing/mocking.md +8 -8
  115. package/docs/upgrade.md +1 -1
  116. package/docs/validator.md +19 -19
  117. package/docs/view.md +14 -14
  118. package/package.json +4 -4
  119. package/src/index.ts +25 -1
  120. package/src/tools/searchDocs.ts +7 -0
  121. package/docs/client/auth.md +0 -113
  122. package/docs/client/errors.md +0 -139
  123. package/docs/client/files.md +0 -118
  124. package/docs/client/references.md +0 -58
  125. package/docs/client/requests.md +0 -131
  126. package/docs/client/resilience.md +0 -141
  127. package/docs/client/testing.md +0 -146
@@ -11,7 +11,7 @@ Flow components communicate by dispatching named events. Listeners registered wi
11
11
 
12
12
  Call `this.dispatch()` inside any `@expose`d action. The event is delivered to all matching `@on` listeners on the page after the action completes:
13
13
 
14
- ```typescript
14
+ ```typescript fragment
15
15
  @expose async save(): Promise<void> {
16
16
  const post = await Post.create({
17
17
  title: this.title,
@@ -27,7 +27,7 @@ Call `this.dispatch()` inside any `@expose`d action. The event is delivered to a
27
27
 
28
28
  The second argument is the payload — any JSON-serialisable value. Omit it for events that carry no data:
29
29
 
30
- ```typescript
30
+ ```typescript fragment
31
31
  this.dispatch("cart-cleared");
32
32
  ```
33
33
 
@@ -35,7 +35,7 @@ this.dispatch("cart-cleared");
35
35
 
36
36
  By default, `dispatch()` notifies every `@on` listener on the page. Use `dispatchTo` or `dispatchSelf` to narrow the target:
37
37
 
38
- ```typescript
38
+ ```typescript fragment
39
39
  // Default: all @on("post-created") listeners on the page
40
40
  this.dispatch("post-created", { id });
41
41
 
@@ -52,7 +52,7 @@ this.dispatchSelf("refresh");
52
52
 
53
53
  Register a method as an event listener with `@on`. The method is **implicitly exposed** — no `@expose` needed:
54
54
 
55
- ```typescript
55
+ ```typescript fragment
56
56
  import { on } from "@zerotal/flow";
57
57
 
58
58
  export class PostList extends Component {
@@ -95,7 +95,7 @@ A component can have as many `@on` listeners as it needs. Each fires independent
95
95
 
96
96
  Several components on the same page can all listen for the same event. Each component is updated independently — Flow sends a separate patch frame to each listener:
97
97
 
98
- ```typescript
98
+ ```typescript fragment
99
99
  // PostList.tsx
100
100
  @on("post-created")
101
101
  async onPostCreated(data: { id: number }) {
@@ -121,7 +121,7 @@ All three components update after a single `this.dispatch("post-created", ...)`
121
121
 
122
122
  The same `dispatch`, `dispatchTo`, and `dispatchSelf` methods work inside **client expressions** — no server round-trip needed to start the dispatch. The `@on` listeners still run server-side when they're notified:
123
123
 
124
- ```tsx
124
+ ```tsx fragment
125
125
  {
126
126
  /* Notify all listeners without a preceding server action */
127
127
  }
@@ -158,7 +158,7 @@ declare module "@zerotal/flow" {
158
158
 
159
159
  Every `dispatch` / `dispatchTo` / `dispatchSelf` site is now checked against it — in server actions **and** client expressions, since they call the same typed method:
160
160
 
161
- ```ts
161
+ ```ts fragment
162
162
  this.dispatch("post-created", { id: post.id, title: post.title }); // ✓
163
163
  this.dispatch("post-created", { id: post.id }); // ✗ missing `title`
164
164
  this.dispatch("post-created"); // ✗ payload required
@@ -168,7 +168,7 @@ this.dispatch("cart-cleared", { anything: 1 }); // ✗ void takes no payload
168
168
 
169
169
  On the listener side, `@on` autocompletes to the known event names, and you annotate the handler's parameter with `EventPayload<K>` to type the payload against the same contract:
170
170
 
171
- ```ts
171
+ ```ts fragment
172
172
  import { on } from "@zerotal/flow";
173
173
  import type { EventPayload } from "@zerotal/flow";
174
174
 
@@ -235,7 +235,7 @@ Listen for server-broadcast events over WebSockets with `@on("socket:…")`. Whe
235
235
 
236
236
  The socket client is bundled into the Flow runtime and created the first time a page declares one of these listeners, so there is no script to add and nothing to publish on `window`. An app that needs a configured client — a different host, its own auth endpoint — assigns `window.Socket` before the runtime loads and that one is used instead. Pages with no such listener open no broadcast connection at all.
237
237
 
238
- ```typescript
238
+ ```typescript fragment
239
239
  export class OrderDashboard extends Component {
240
240
  @locked orderCount: number = 0;
241
241
  @locked recentOrders: Order[] = [];
@@ -299,7 +299,7 @@ Pass a resolver instead. It is called with the component when the snapshot is bu
299
299
  [`@presence`](#presence--whos-here-multiplayer) and [`@shared`](#shared-state--everyone-converges-multiplayer)
300
300
  resolve theirs:
301
301
 
302
- ```typescript
302
+ ```typescript fragment
303
303
  export class IssuePage extends Component {
304
304
  @locked issue!: Issue;
305
305
  @locked comments: Comment[] = [];
@@ -326,7 +326,7 @@ Broadcasting requires a global `window.Socket` client configured by your applica
326
326
 
327
327
  If `window.Socket` is not present, all `socket:` listeners are silently inert — no errors, no subscriptions attempted.
328
328
 
329
- ```typescript
329
+ ```typescript fragment
330
330
  // In your frontend bootstrap (app.ts or similar):
331
331
  import { Socket } from "@zerotal/client";
332
332
 
@@ -339,7 +339,7 @@ window.Socket = new Socket();
339
339
 
340
340
  `@presence` binds a property to a broadcast **presence channel** and keeps it filled with the live member list — the framework joins the channel, seeds the list, and refreshes it as people join and leave. No event classes, no manual `@on` wiring:
341
341
 
342
- ```tsx
342
+ ```tsx fragment
343
343
  import { Component, presence } from "@zerotal/flow";
344
344
  import type { PresenceMember } from "@zerotal/flow";
345
345
 
@@ -362,7 +362,7 @@ export class Board extends Component {
362
362
 
363
363
  The channel is resolved on the server from the component (so it can't be forged from the client) and carried, signed, in the snapshot. `who` is server-controlled (like `@locked`): it lives in the snapshot and the client can't write it. Authorize the channel — and shape the member data — in `routes/channels.ts`:
364
364
 
365
- ```ts
365
+ ```ts fragment
366
366
  Broadcast.channel("board.[boardId]", (user, boardId) =>
367
367
  user.canView(boardId) ? { id: user.id, name: user.name } : null,
368
368
  );
@@ -385,7 +385,7 @@ Like all `socket:` features, presence needs a `window.Socket` client configured
385
385
 
386
386
  Where `@presence` answers _who's here_, `@shared` answers _what do we all see_. It binds a property to convergent, **server-authoritative** state on a channel: mutate it in an action and the framework writes it to a per-channel **room store** and broadcasts to the channel, so every other subscriber re-reads and converges. No store to wire, no events, no dispatch:
387
387
 
388
- ```tsx
388
+ ```tsx fragment
389
389
  import { Component, presence, shared, expose } from "@zerotal/flow";
390
390
 
391
391
  export class Board extends Component {
@@ -416,7 +416,7 @@ Broadcasting is an **optional peer**. With `window.Socket` and `BroadcastProvide
416
416
 
417
417
  Calling `this.refresh()` inside an action inserts `onMount()` back into the WebSocket round-trip cycle:
418
418
 
419
- ```typescript
419
+ ```typescript fragment
420
420
  @expose async syncOrders(): Promise<void> {
421
421
  await OrderSync.run();
422
422
  this.refresh(); // onMount() re-runs → this.orders is freshly loaded
@@ -434,7 +434,7 @@ Calling `this.refresh()` inside an action inserts `onMount()` back into the WebS
434
434
 
435
435
  In the browser, `this.refresh()` sends a lightweight re-render request with no data change — useful for polling or a manual reload button:
436
436
 
437
- ```tsx
437
+ ```tsx fragment
438
438
  {
439
439
  /* Manual reload button */
440
440
  }
@@ -11,7 +11,7 @@ Validate input, bind form fields to component state with two-way binding, encaps
11
11
 
12
12
  Attach rules with `@validate` on individual fields, or pass them explicitly to `this.validate()`:
13
13
 
14
- ```typescript
14
+ ```typescript fragment
15
15
  import { expose, validate } from "@zerotal/flow";
16
16
 
17
17
  @expose @validate((rule) => rule.required().email()) email: string = "";
@@ -29,7 +29,7 @@ builder (`rule.number()`, …). The same `@validate` rule also powers
29
29
 
30
30
  Call `this.validate()` at the start of your action. It reads the `@validate` rules and throws a `ValidationError` if any fail — Flow catches it, populates `this.errors`, and re-renders:
31
31
 
32
- ```typescript
32
+ ```typescript fragment
33
33
  @expose async register(): Promise<void> {
34
34
  await this.validate(); // uses @validate rules declared on the class
35
35
 
@@ -46,7 +46,7 @@ Call `this.validate()` at the start of your action. It reads the `@validate` rul
46
46
 
47
47
  Pass explicit rules to override or extend `@validate`:
48
48
 
49
- ```typescript
49
+ ```typescript fragment
50
50
  @expose async update(): Promise<void> {
51
51
  await this.validate({
52
52
  email: (rule) => rule.required().email(),
@@ -83,7 +83,7 @@ Pass explicit rules to override or extend `@validate`:
83
83
 
84
84
  Pass a field off `this.errors` to the `error` prop. It renders the first message for that field and hides itself when the field is valid — no manual show/hide logic:
85
85
 
86
- ```tsx
86
+ ```tsx fragment
87
87
  <input value={this.email} />
88
88
  <span error={this.errors.email} class="text-sm text-red-500" />
89
89
 
@@ -93,7 +93,7 @@ Pass a field off `this.errors` to the `error` prop. It renders the first message
93
93
 
94
94
  Use the `<Field>` component for accessible label + error wiring:
95
95
 
96
- ```tsx
96
+ ```tsx fragment
97
97
  import { Field } from "@zerotal/flow";
98
98
 
99
99
  <Field label="Email" error={this.errors.email}>
@@ -107,7 +107,7 @@ import { Field } from "@zerotal/flow";
107
107
 
108
108
  Show all errors at once with `<Errors>`:
109
109
 
110
- ```tsx
110
+ ```tsx fragment
111
111
  import { Errors } from "@zerotal/flow";
112
112
 
113
113
  <Errors /> {/* all current errors */}
@@ -116,7 +116,7 @@ import { Errors } from "@zerotal/flow";
116
116
 
117
117
  ### Checking errors in server code
118
118
 
119
- ```typescript
119
+ ```typescript fragment
120
120
  // Check if any errors exist
121
121
  if (this.errors.any()) {
122
122
  return;
@@ -131,7 +131,7 @@ if (this.errors.has("email")) {
131
131
 
132
132
  ### Manual errors
133
133
 
134
- ```typescript
134
+ ```typescript fragment
135
135
  this.addError("email", "That email address is already taken.");
136
136
  this.addError("username", "Username must be unique.");
137
137
 
@@ -143,7 +143,7 @@ this.resetValidation("email"); // clear one field
143
143
 
144
144
  Pass state to `value` (or `checked`). Flow makes it two-way for `@expose` properties and read-only for `@locked` ones — no helper needed:
145
145
 
146
- ```tsx
146
+ ```tsx fragment
147
147
  {/* Text inputs */}
148
148
  <input value={this.name} />
149
149
  <input value={this.email} />
@@ -194,7 +194,7 @@ export class LoginForm extends Form {
194
194
 
195
195
  Mount the form on a component:
196
196
 
197
- ```typescript
197
+ ```typescript fragment
198
198
  export class LoginPage extends Component {
199
199
  @expose form = new LoginForm();
200
200
 
@@ -219,7 +219,7 @@ export class LoginPage extends Component {
219
219
 
220
220
  Bind fields with nested `value={this.form.email}`:
221
221
 
222
- ```tsx
222
+ ```tsx fragment
223
223
  <form onSubmit={this.login} class="space-y-4">
224
224
  <Field label="Email" error={this.errors.email}>
225
225
  <input value={this.form.email} type="email" live class="input" />
@@ -242,7 +242,7 @@ Bind fields with nested `value={this.form.email}`:
242
242
 
243
243
  ### Form helpers
244
244
 
245
- ```typescript
245
+ ```typescript fragment
246
246
  // Get all field values as a plain object
247
247
  const data = this.form.data();
248
248
  // { email: "alice@example.com", password: "…", remember: true }
@@ -273,7 +273,7 @@ See [Pagination](/docs/flow/pagination) for the full guide — `paginate()`, `Pa
273
273
 
274
274
  Quick reference for in-memory pagination:
275
275
 
276
- ```typescript
276
+ ```typescript fragment
277
277
  import { paginate } from "@zerotal/flow";
278
278
 
279
279
  export class PostsPage extends Component {
@@ -342,7 +342,7 @@ export class PostsPage extends Component {
342
342
 
343
343
  Compose `Pagination` to get the page state and navigation methods automatically — no boilerplate:
344
344
 
345
- ```tsx
345
+ ```tsx fragment
346
346
  import { Component, Pagination } from "@zerotal/flow";
347
347
 
348
348
  export class PostsPage extends Component.using(Pagination) {
@@ -404,7 +404,7 @@ export class PostsPage extends Component.using(Pagination) {
404
404
 
405
405
  Reset the page when a filter changes to avoid showing an empty page:
406
406
 
407
- ```typescript
407
+ ```typescript fragment
408
408
  @expose async applyFilter(status: string): Promise<void> {
409
409
  this.filter = status;
410
410
  this.resetPage(); // go back to page 1
@@ -413,7 +413,7 @@ Reset the page when a filter changes to avoid showing an empty page:
413
413
 
414
414
  Compose with other mixins:
415
415
 
416
- ```typescript
416
+ ```typescript fragment
417
417
  // `Sorting` here is a mixin you author yourself (see Layouts & Composition);
418
418
  // `Pagination` is the one shipped by Flow.
419
419
  export class PostsPage extends Component.using(Sorting, Pagination) {
@@ -425,7 +425,7 @@ export class PostsPage extends Component.using(Sorting, Pagination) {
425
425
 
426
426
  For database queries, skip the in-memory `paginate()` and use the ORM query builder directly. Combine with `@url page`:
427
427
 
428
- ```typescript
428
+ ```typescript fragment
429
429
  export class PostsPage extends Component {
430
430
  @url page = 1;
431
431
  @locked posts: Post[] = [];
@@ -0,0 +1,199 @@
1
+ ---
2
+ title: Icons
3
+ description: 2,060 icons bundled with Flow's component library — typed by name, rendered on the server, nothing to install.
4
+ ---
5
+
6
+ # Icons
7
+
8
+ `<Icon>` draws an icon by name. The set ships inside `@zerotal/flow-ui`, so this
9
+ works in a new app with nothing installed and nothing configured:
10
+
11
+ ```tsx fragment
12
+ import { Icon } from "@zerotal/flow-ui";
13
+
14
+ <Icon name="inbox" />
15
+ <Icon name="chevron-right" />
16
+ <Icon name="trash-2" class="size-5 text-red-600" />
17
+ ```
18
+
19
+ The name is a union of every bundled icon, so a typo is a compile error rather
20
+ than a blank space nobody notices until it is in front of a user:
21
+
22
+ ```text
23
+ Type '"inbxo"' is not assignable to type 'IconName'. Did you mean '"inbox"'?
24
+ ```
25
+
26
+ That works on install — there is no generator to run first. The icons belong to
27
+ the framework, so the names are known before your app exists.
28
+
29
+ ## Props
30
+
31
+ `IconProps` — anything else you pass lands on the rendered `<svg>`.
32
+
33
+ | Prop | Type | Description |
34
+ | ------- | ---------- | ------------------------------------------------------------------------------ |
35
+ | `name` | `IconName` | Which icon. Checked at compile time against the bundled and registered names. |
36
+ | `label` | `string` | Accessible name. Omit for decoration — the icon is hidden from screen readers. |
37
+ | `class` | `string` | Merged with the defaults rather than replacing them. |
38
+
39
+ ## Sizing and colour
40
+
41
+ An icon is `1em` square and painted in `currentColor`, so by default it matches
42
+ the text it sits beside — size, weight of colour, and all. Override with classes
43
+ rather than attributes:
44
+
45
+ ```tsx fragment
46
+ <p class="text-sm text-slate-600">
47
+ <Icon name="info" /> Saved a moment ago
48
+ </p>
49
+
50
+ <Icon name="triangle-alert" class="size-8 text-amber-500" />
51
+ ```
52
+
53
+ Sizing through CSS is what lets an icon line up with a label without either being
54
+ measured. `class="size-5"` sets both dimensions; `text-red-600` on the icon — or
55
+ on anything above it — colours it.
56
+
57
+ ## Labelling
58
+
59
+ An icon is decoration by default and hidden from screen readers, which is right
60
+ when it sits next to text that already says the same thing. Announcing it there
61
+ would read the meaning out twice.
62
+
63
+ An icon that is the **only** content of a control is not decoration. Without a
64
+ label, that button has no accessible name at all:
65
+
66
+ ```tsx fragment
67
+ <button onClick={this.remove}>
68
+ <Icon name="trash-2" label="Delete order" />
69
+ </button>
70
+ ```
71
+
72
+ ## A name that isn't known until runtime
73
+
74
+ A name from a database column or a URL segment is not a literal, so it does not
75
+ satisfy the union. `isIconName()` narrows it:
76
+
77
+ ```tsx fragment
78
+ import { Icon, isIconName } from "@zerotal/flow-ui";
79
+
80
+ override async render() {
81
+ const glyph = this.status.icon; // string, from a row
82
+ return isIconName(glyph) ? <Icon name={glyph} /> : <Icon name="circle-help" />;
83
+ }
84
+ ```
85
+
86
+ It is a shape check, not an existence check — it says the string could name an
87
+ icon, not that anything answers to it. An icon that resolves to nothing renders
88
+ nothing rather than throwing, because taking a page down over a missing glyph is
89
+ the worse failure.
90
+
91
+ ## Drawn for the gaps
92
+
93
+ Four names are drawn here rather than coming from the set, because the flows they
94
+ label are ones Zerotal ships and the set has no icon for as a concept:
95
+
96
+ | Name | For |
97
+ | ------------ | -------------------------------------------------------------- |
98
+ | `passkey` | WebAuthn sign-in — a fingerprint that ends in a key |
99
+ | `two-factor` | TOTP — a second device that has to agree |
100
+ | `otp` | An emailed one-time code — the separate slots it is typed into |
101
+ | `magic-link` | Passwordless sign-in by link |
102
+
103
+ The set has `key-round`, `fingerprint` and `shield-check` — the parts — and a login
104
+ page needs the whole. They are drawn on the same 24×24 stroke grid, so they sit
105
+ beside the other 2,060 without announcing themselves.
106
+
107
+ Nearly everything else that looked missing was there under a name that reads
108
+ differently: `git-branch` not `branch`, `file-json` not `json`, `paperclip` not
109
+ `attachment`, `venetian-mask` for impersonation. Search before you draw.
110
+
111
+ ## Brand marks
112
+
113
+ Three sign-in providers ship as brand marks, because `@zerotal/auth` has a code
114
+ path for each and a sign-in button wants the provider's actual logo:
115
+
116
+ ```tsx fragment
117
+ <button><Icon name="brand-google" /> Continue with Google</button>
118
+ <button><Icon name="brand-github" /> Continue with GitHub</button>
119
+ <button><Icon name="brand-apple" /> Continue with Apple</button>
120
+ ```
121
+
122
+ They come from [Simple Icons](https://simpleicons.org) (**CC0-1.0**, public
123
+ domain), so the paths are the real ones rather than approximations — an
124
+ approximated logo reads as a forgery, not as an icon.
125
+
126
+ The `brand-` prefix is deliberate: the bundled set has its own stroke-style
127
+ `github` and `apple`, and prefixing means neither silently shadows the other, so a
128
+ page picks a style rather than inheriting one. There is no plain `google` — the
129
+ set never had one, which is what made this worth doing.
130
+
131
+ Unlike the rest, brand marks are **solid**: each body carries its own
132
+ `fill="currentColor"`, so it still takes its colour from the text around it.
133
+
134
+ > **CC0 covers copyright, not trademark.** The marks belong to their owners.
135
+ > Labelling a sign-in button with one is nominative use and what brand guidelines
136
+ > contemplate; using one as your own logo is not. For a provider not listed here,
137
+ > `registerIcons()` keeps that decision — and its licence — yours.
138
+
139
+ ## Your own icons
140
+
141
+ A wordmark, a product glyph, a shape nobody has drawn: register it once, from a
142
+ provider's `register()`, and it is available everywhere `<Icon>` is.
143
+
144
+ ```ts
145
+ import { registerIcons } from "@zerotal/flow-ui";
146
+
147
+ registerIcons({
148
+ "acme-wordmark": {
149
+ body: '<path fill="currentColor" d="M4 4h16v16H4z"/>',
150
+ },
151
+ });
152
+ ```
153
+
154
+ Each entry is an `IconBody` — the markup that goes **inside** the `<svg>`, plus an
155
+ optional `width`/`height` when it was drawn against a box other than 24×24. A name
156
+ you register shadows a bundled one, which is how you substitute your own drawing
157
+ without renaming every call site.
158
+
159
+ Registering supplies the body; the compiler needs telling separately. Declare the
160
+ names on `CustomIconRegistry` and they join the same union as the bundled ones —
161
+ `IconName` widens, and `CustomIconName` is the set you added:
162
+
163
+ ```ts
164
+ declare module "@zerotal/flow-ui" {
165
+ interface CustomIconRegistry {
166
+ "acme-wordmark": true;
167
+ }
168
+ }
169
+ ```
170
+
171
+ > **The body is inserted as markup, not text.** Register only SVG you control.
172
+ > A body built from user input is the same hole as any other unescaped HTML.
173
+
174
+ ### Matching the set
175
+
176
+ Icons drawn to a different grid look wrong beside ones that aren't. The bundled
177
+ set is 24×24 **stroke**: no fills, `stroke="currentColor"`, `stroke-width="2"`,
178
+ round caps and joins. Copy the shape of an existing icon rather than exporting
179
+ from a design tool, which will hand you absolute fills on a half-pixel grid.
180
+
181
+ ## What ships, and why it can
182
+
183
+ The bundled set is [Lucide](https://lucide.dev), which is ISC-licensed — the
184
+ reason it can be shipped inside the package at all. Redistributing it carries a
185
+ notice (`LICENSE-ICONS.md` in `@zerotal/flow-ui`) and asks nothing of your
186
+ application's UI.
187
+
188
+ Most sets are not so simple. Font Awesome Free is CC BY 4.0 — usable, and only
189
+ with attribution _you_ would have to display — and Font Awesome Pro may not be
190
+ redistributed at any price. Bundling either would relicense someone else's artwork
191
+ on behalf of every app that installed Flow. If you are entitled to a set we cannot
192
+ ship, `registerIcons()` is how you bring it: your artwork, your licence.
193
+
194
+ ## Cost
195
+
196
+ None on the client. Flow renders on the server, so an icon reaches the browser as
197
+ markup that is already in the page — no icon font, no sprite sheet, no request per
198
+ glyph, and nothing for a strict [Content Security Policy](/docs/flow/performance)
199
+ to block. The set is read once per process and never sent.
@@ -93,7 +93,7 @@ middleware is a `_middleware.ts` in the page tree. It stacks from the root down,
93
93
  covers every page beneath it, and — being route middleware — re-runs on every
94
94
  action with nothing else to configure:
95
95
 
96
- ```typescript
96
+ ```typescript fragment
97
97
  // app/flow/pages/_middleware.ts
98
98
  import { TenantMiddleware } from "../../middleware/TenantMiddleware.ts";
99
99
 
@@ -107,7 +107,7 @@ guards the pages inside it without changing their URLs. See
107
107
  Reach for `persistentMiddleware` only when the middleware is genuinely **global** —
108
108
  registered app-wide with `Application.use()` because non-Flow routes need it too:
109
109
 
110
- ```typescript
110
+ ```typescript fragment
111
111
  // app/providers/AppServiceProvider.ts — inside onRegister():
112
112
  FlowProvider.persistMiddleware(TenantMiddleware);
113
113
  ```
@@ -172,7 +172,7 @@ The distinction is the syntax: a named method reference is always a server actio
172
172
 
173
173
  Register the route:
174
174
 
175
- ```typescript
175
+ ```typescript fragment
176
176
  // routes/web.ts
177
177
  import { Router } from "zerotal";
178
178
  import { CounterPage } from "./components/CounterPage.tsx";
@@ -239,7 +239,7 @@ State that the server should never see — a hover flag, which accordion section
239
239
 
240
240
  A list with per-row actions needs to tell the server _which_ row. Write the call as you would expect, and Flow compiles the arguments into the markup:
241
241
 
242
- ```tsx
242
+ ```tsx fragment
243
243
  {
244
244
  this.enquiries.map((row) => (
245
245
  <tr>
@@ -254,7 +254,7 @@ A list with per-row actions needs to tell the server _which_ row. Write the call
254
254
 
255
255
  The arguments are evaluated **on the server, during the render** — where `row` exists — and travel with the action as `data-args`. Your action receives them as ordinary parameters:
256
256
 
257
- ```ts
257
+ ```ts fragment
258
258
  @expose async archive(id: number) {
259
259
  await Enquiry.findOrFail(id).archive();
260
260
  }
@@ -262,7 +262,7 @@ The arguments are evaluated **on the server, during the render** — where `row`
262
262
 
263
263
  You can also write `data-args` yourself, which is useful when the handler is built dynamically:
264
264
 
265
- ```tsx
265
+ ```tsx fragment
266
266
  <button onClick={this.archive} data-args={JSON.stringify([row.id])}>
267
267
  Archive
268
268
  </button>
@@ -280,7 +280,7 @@ By contrast, a handler pointing at a method you forgot to `@expose` **is** a har
280
280
 
281
281
  Bind an input by passing state straight to `value` (or `checked`). Flow wires up two-way binding when the property is `@expose`, and read-only reflection when it's `@locked`:
282
282
 
283
- ```tsx
283
+ ```tsx fragment
284
284
  <input value={this.name} /> {/* @expose → two-way */}
285
285
  <input type="checkbox" checked={this.agree} /> {/* @expose → two-way */}
286
286
  <input value={this.ownerName} /> {/* @locked → read-only */}
@@ -288,7 +288,7 @@ Bind an input by passing state straight to `value` (or `checked`). Flow wires up
288
288
 
289
289
  A **radio group** is bound as a unit rather than per input, because every option writes the same property. Pass the option's own value as a second argument to `bind()`:
290
290
 
291
- ```tsx
291
+ ```tsx fragment
292
292
  {
293
293
  ["CUSTOM", "ROUTE", "TEAMS"].map((t) => (
294
294
  <label>
@@ -302,7 +302,7 @@ Each option renders with the shared `flow:model="type"`, its own `value`, and `c
302
302
 
303
303
  By default the value stays **local** — it updates the DOM instantly and is flushed to the server with your next action. Add `live` to sync to the server as you type, or `blur` to sync when the input loses focus:
304
304
 
305
- ```tsx
305
+ ```tsx fragment
306
306
  <input value={this.draft} /> {/* local; flushed with the next action */}
307
307
  <input value={this.search} live /> {/* syncs to the server as you type */}
308
308
  <input value={this.title} blur /> {/* syncs to the server on blur */}
@@ -312,20 +312,20 @@ By default the value stays **local** — it updates the DOM instantly and is flu
312
312
 
313
313
  Two more modifiers clean the value at the edge, so the server never sees a numeric string or stray whitespace and you write no coercion in `onUpdated`:
314
314
 
315
- ```tsx
315
+ ```tsx fragment
316
316
  <input type="number" value={this.age} number /> {/* the bound value is a real number, not "42" */}
317
317
  <input value={this.name} trim /> {/* whitespace stripped before it syncs */}
318
318
  ```
319
319
 
320
320
  Add `draft="key"` to keep an unsubmitted value across a reload or crash — it mirrors to `localStorage` and restores on mount (only when the field is empty, so server content always wins), then clears itself once the server empties the field after a successful submit. It's a client-side safety net; the server snapshot stays the authority:
321
321
 
322
- ```tsx
322
+ ```tsx fragment
323
323
  <textarea value={this.body} draft="post-body" /> {/* survives a refresh until you submit */}
324
324
  ```
325
325
 
326
326
  And two focus helpers close the loop after a re-render, where focus is easily lost:
327
327
 
328
- ```tsx
328
+ ```tsx fragment
329
329
  <input value={this.email} autoFocus /> {/* focus on mount (won't steal focus you've placed) */}
330
330
  <input value={this.email} focusOnError /> {/* after a failed submit, focus jumps to the first invalid field */}
331
331
  ```
@@ -336,7 +336,7 @@ And two focus helpers close the loop after a re-render, where focus is easily lo
336
336
 
337
337
  Pass a field off `this.errors` to the `error` prop and Flow renders that field's first validation message reactively — it appears when the field is invalid and clears when it's fixed:
338
338
 
339
- ```tsx
339
+ ```tsx fragment
340
340
  <input value={this.email} />
341
341
  <span error={this.errors.email} class="text-red-500" />
342
342
  ```
@@ -347,7 +347,7 @@ No `errors.has(...)` checks, no manual show/hide.
347
347
 
348
348
  A `className`/`class` (or `style`, `href`, …) that depends on `@expose` or `@locked` state compiles to a reactive client binding, so it updates without a round-trip:
349
349
 
350
- ```tsx
350
+ ```tsx fragment
351
351
  <span className={this.count > 10 ? "text-emerald-400" : "text-white"}>
352
352
  {this.count}
353
353
  </span>
@@ -361,7 +361,7 @@ A `className`/`class` (or `style`, `href`, …) that depends on `@expose` or `@l
361
361
 
362
362
  Use normal TypeScript control flow in `render()` — `.map()`, ternaries, and `&&` are all supported:
363
363
 
364
- ```tsx
364
+ ```tsx fragment
365
365
  override async render() {
366
366
  return (
367
367
  <div>
@@ -389,7 +389,7 @@ Always provide a `key` when mapping over items — the morph algorithm uses it t
389
389
 
390
390
  Common interaction states are first-class props:
391
391
 
392
- ```tsx
392
+ ```tsx fragment
393
393
  {/* Disable the button while the action is in flight */}
394
394
  <button onClick={this.save} loadingAttr="disabled">Save</button>
395
395
 
@@ -420,7 +420,7 @@ Common interaction states are first-class props:
420
420
 
421
421
  Add `transition` to animate the show/hide instead of an instant flip — a single prop covers **both** enter and leave (the leave half plain `show=` can't do, since the element would otherwise vanish before any animation runs):
422
422
 
423
- ```tsx
423
+ ```tsx fragment
424
424
  <div show={this.modal} transition class="modal">…</div> {/* default: fade */}
425
425
  <div show={this.menu} transition="scale">…</div> {/* preset */}
426
426
  <aside show={this.drawer} transition="slide-right">…</aside> {/* directional */}
@@ -446,7 +446,7 @@ The `flow:*` directives (`flow:click`, `flow:model`, `flow:show`, …) that the
446
446
 
447
447
  A handful of client-only helpers — writing a prop and syncing it, toggling a boolean, calling a parent action, optimistic list mutations — don't belong to any one component and never run on the server. Rather than crowd the component class (and reserve common names like `set`, `on`, `watch`, `parent` that you might want for your own methods), they all live on a single global object, `$flow`. Framework helpers wear a `$`; the bare names stay yours.
448
448
 
449
- ```tsx
449
+ ```tsx fragment
450
450
  <button onClick={() => $flow.set("open", true)}>Open</button> {/* write + sync an @expose prop */}
451
451
  <button onClick={() => $flow.toggle("open")}>Toggle</button>
452
452
  <button onClick={() => $flow.parent.save()}>Save</button> {/* call a parent action */}
@@ -508,7 +508,7 @@ A page that _reads_ `$flow.store` in a binding must be AOT-compilable — the co
508
508
 
509
509
  ### Full page example
510
510
 
511
- ```tsx
511
+ ```tsx fragment
512
512
  import { Component, expose, locked, validate, url } from "@zerotal/flow";
513
513
  import type { Post } from "#app/models/Post.ts";
514
514
 
@@ -576,4 +576,4 @@ Flow is a large surface. Each section below is its own page.
576
576
  - [Session](/docs/session) — the store behind `@session` and `SessionMiddleware`.
577
577
  - [Broadcasting](/docs/broadcasting) — drive `@on("socket:…")` real-time updates from the server.
578
578
  - [Storage](/docs/storage) — configure the disks that file uploads write to.
579
- - [Testing](/docs/testing/index) — patterns for the `FlowTest` harness and the rest of the suite.
579
+ - [Testing](/docs/testing) — patterns for the `FlowTest` harness and the rest of the suite.