create-foldkit-app 0.29.0 → 0.29.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 (68) hide show
  1. package/dist/templates/base/AGENTS.md +5 -112
  2. package/dist/templates/base/FOLDKIT.md +144 -0
  3. package/dist/templates/examples/api-cache/src/main.ts +36 -40
  4. package/dist/templates/examples/auth/src/main.ts +16 -15
  5. package/dist/templates/examples/auth/src/page/loggedIn/update.ts +10 -11
  6. package/dist/templates/examples/auth/src/page/loggedOut/page/login.ts +44 -55
  7. package/dist/templates/examples/auth/src/page/loggedOut/update.ts +8 -12
  8. package/dist/templates/examples/auth/src/update.ts +67 -73
  9. package/dist/templates/examples/canvas-art/src/main.ts +32 -31
  10. package/dist/templates/examples/charting/src/init.test.ts +6 -3
  11. package/dist/templates/examples/charting/src/init.ts +4 -4
  12. package/dist/templates/examples/charting/src/update.ts +34 -38
  13. package/dist/templates/examples/counter/src/main.ts +13 -13
  14. package/dist/templates/examples/counters/src/counter.ts +9 -8
  15. package/dist/templates/examples/counters/src/main.ts +20 -26
  16. package/dist/templates/examples/crash-view/src/main.ts +5 -3
  17. package/dist/templates/examples/embedding/src/main.ts +11 -9
  18. package/dist/templates/examples/form/src/main.ts +79 -89
  19. package/dist/templates/examples/generative-art/src/main.ts +6 -4
  20. package/dist/templates/examples/generative-art/src/update.ts +26 -23
  21. package/dist/templates/examples/interrupting-commands/src/main.ts +68 -66
  22. package/dist/templates/examples/job-application/src/main.ts +3 -4
  23. package/dist/templates/examples/job-application/src/step/attachments.ts +16 -19
  24. package/dist/templates/examples/job-application/src/step/coverLetter.ts +5 -5
  25. package/dist/templates/examples/job-application/src/step/education/education.ts +12 -17
  26. package/dist/templates/examples/job-application/src/step/education/entry.ts +29 -42
  27. package/dist/templates/examples/job-application/src/step/personalInfo.ts +35 -44
  28. package/dist/templates/examples/job-application/src/step/skills/entry.ts +15 -21
  29. package/dist/templates/examples/job-application/src/step/skills/skills.ts +12 -17
  30. package/dist/templates/examples/job-application/src/step/workHistory/entry.ts +41 -58
  31. package/dist/templates/examples/job-application/src/step/workHistory/workHistory.ts +12 -17
  32. package/dist/templates/examples/job-application/src/update.ts +26 -29
  33. package/dist/templates/examples/kanban/src/main.ts +3 -4
  34. package/dist/templates/examples/kanban/src/update.ts +31 -31
  35. package/dist/templates/examples/managed-resource-layer/src/main.ts +30 -34
  36. package/dist/templates/examples/map/src/main.ts +48 -56
  37. package/dist/templates/examples/pixel-art/src/comparison.bench.test.ts +34 -13
  38. package/dist/templates/examples/pixel-art/src/main.bench.ts +3 -3
  39. package/dist/templates/examples/pixel-art/src/main.ts +3 -4
  40. package/dist/templates/examples/pixel-art/src/update.ts +109 -100
  41. package/dist/templates/examples/query-sync/src/main.ts +27 -25
  42. package/dist/templates/examples/route-transitions/src/main.ts +46 -43
  43. package/dist/templates/examples/route-transitions/src/story.test.ts +11 -11
  44. package/dist/templates/examples/routing/src/main.ts +16 -14
  45. package/dist/templates/examples/routing/src/page/people.ts +23 -28
  46. package/dist/templates/examples/shopping-cart/src/main.ts +41 -42
  47. package/dist/templates/examples/shopping-cart/src/page/products.ts +28 -35
  48. package/dist/templates/examples/slow-warnings/src/main.ts +23 -32
  49. package/dist/templates/examples/snake/src/main.ts +135 -143
  50. package/dist/templates/examples/ssg/src/main.ts +20 -17
  51. package/dist/templates/examples/ssr/src/main.ts +20 -24
  52. package/dist/templates/examples/state-machine/src/main.ts +24 -11
  53. package/dist/templates/examples/state-machine/src/story.test.ts +2 -2
  54. package/dist/templates/examples/stopwatch/src/main.ts +42 -50
  55. package/dist/templates/examples/todo/src/main.ts +152 -160
  56. package/dist/templates/examples/ui-showcase/src/main.ts +53 -54
  57. package/dist/templates/examples/ui-showcase/src/scene.test.ts +2 -2
  58. package/dist/templates/examples/ui-showcase/src/story.test.ts +2 -2
  59. package/dist/templates/examples/ui-showcase/src/ui/init.ts +6 -7
  60. package/dist/templates/examples/ui-showcase/src/ui/update.ts +265 -336
  61. package/dist/templates/examples/view-transitions/src/main.ts +18 -17
  62. package/dist/templates/examples/weather/src/main.ts +33 -40
  63. package/dist/templates/examples/web-components/src/main.ts +14 -17
  64. package/dist/templates/examples/websocket-chat/src/main.ts +44 -50
  65. package/dist/templates/release.json +8 -8
  66. package/dist/templates/rendering/ssg/src/main.ts +20 -17
  67. package/dist/templates/rendering/ssr/src/main.ts +20 -24
  68. package/package.json +1 -1
@@ -1,120 +1,13 @@
1
1
  # Agent Development Notes
2
2
 
3
- This is a Foldkit app, a framework built on Effect-TS, architected like Elm.
3
+ This is a Foldkit app. Read [`FOLDKIT.md`](./FOLDKIT.md) before writing any code in this project. It covers the architecture, the APIs, and the conventions the project is built on.
4
4
 
5
- ## Getting the canonical references
5
+ Foldkit owns `FOLDKIT.md` and replaces it whole on upgrade. This file is yours. Anything you want an agent to know about this project goes below, where an upgrade won't touch it.
6
6
 
7
- The live Foldkit code is the canonical reference for everything: which APIs exist, what idiomatic apps look like, what the current conventions are. Written docs (including this one) can drift; the code can't.
8
-
9
- If `./repos/foldkit` exists, it contains the full Foldkit repository, vendored in as a git subtree. Read from it directly:
10
-
11
- - `repos/foldkit/examples/`: runnable example apps spanning every complexity tier. Usually your first stop when looking for a precedent for how to write something.
12
- - `repos/foldkit/packages/foldkit/src/`: framework source. Ground truth for API signatures.
13
- - `repos/foldkit/packages/typing-game/client/src/` and `repos/foldkit/packages/website/src/`: production apps built with Foldkit. Highest-fidelity reference for application architecture, Submodels, and OutMessage.
14
-
15
- Treat the subtree as read-only reference: never import from `repos/foldkit/` in your project source. Imports must come from the `foldkit` npm package.
16
-
17
- If `./repos/foldkit` does not exist and the line below says `subtree_prompted: false`, offer to run `git subtree add --prefix=repos/foldkit https://github.com/foldkit/foldkit.git main --squash`. This gives you access to the full source, examples, and docs. Whether the user accepts or declines, update the value to `true` so it isn't asked again.
7
+ `FOLDKIT.md` reads the line below to decide whether it has already offered to vendor the Foldkit source. Leave it in place.
18
8
 
19
9
  subtree_prompted: false
20
10
 
21
- If `foldkit-skills` is installed as a Claude Code plugin, the `generate-program` and `audit-program` skills carry snapshot architecture and conventions guides synced from the live code.
22
-
23
- ## Project Conventions
24
-
25
- - Foldkit is tightly coupled to the Effect ecosystem. Do not suggest solutions outside of Effect-TS.
26
- - Model fields must be Schema types (the model is a schema). Plain TypeScript types are fine elsewhere (function return types, local variables, etc.).
27
- - Use full names like `Message` (not `Msg`), and `withReturnType` (not `as const` or type casting).
28
- - Use `defineMessageUnion()` for Message unions, `ts()` for tagged structs (Model states, field validation), and `r()` for route schemas.
29
- - Push back on any direction that violates Elm Architecture principles: unidirectional data flow, messages as facts (not commands), model as single source of truth, side effects confined to commands. If a prompt suggests mutating state, imperative event handlers, or two-way bindings, flag the issue and propose the idiomatic Foldkit approach.
30
- - Never use `NoOp`. Every message must describe what happened. A command's result message is named from the command, not from the fact it reports, whether or not it carries a payload: `LockScroll` → `CompletedLockScroll`, `DetermineStartTime` → `CompletedDetermineStartTime` (never `DeterminedStartTime`).
31
-
32
- ## Foldkit Patterns
33
-
34
- ### Update
35
-
36
- `init` and `update` both return `[Model, ReadonlyArray<Command<Message>>]`:
37
-
38
- ```ts
39
- type UpdateReturn = readonly [Model, ReadonlyArray<Command<Message>>]
40
-
41
- const update = (model: Model, message: Message) =>
42
- Message.match<UpdateReturn>(message, {
43
- ClickedIncrement: () => [evo(model, { count: count => count + 1 }), []],
44
- })
45
- ```
46
-
47
- Use `evo()` from `foldkit/struct` for immutable model updates. Never spread or `Object.assign`.
48
-
49
- ### View
50
-
51
- Every view receives `h`, the typed Html builder, as its last parameter (`view: (model, h) => ...`; `Submodel.defineView` passes the child's own). Never construct a builder; reach for `h.div`, `h.OnClick`, etc. off the parameter, and give extracted view helpers an `h: HtmlBuilder<Message>` last parameter that callers thread through. Only where no builder is in scope, typically module scope, use `inertHtml` from `foldkit/html`, aliased `ih` so the two builders stay distinguishable. Use `h.empty` (not `null`) for conditional rendering, `M.value().pipe(M.tagsExhaustive({...}))` for discriminated unions, and `Array.match` for lists that may be empty.
52
-
53
- Keys are for mapped list items only: key each row by a stable Model identifier (`h.keyed('li')(item.id, [], [...])`), never by array position, and never derive a key from displayed data. Never key branches; the build gives each view function's output its own identity, so branch switches replace DOM automatically. When switching an inline same-tag ternary must reset DOM state, extract each arm into its own named view function.
54
-
55
- Omit the children argument when an element has none: `h.div([h.Class('divider')])`, never `h.div([h.Class('divider')], [])`. The same holds for `keyed`: `h.keyed('li')(key, [attrs])`, never `h.keyed('li')(key, [attrs], [])`. Attributes stay required on element builders, so `h.div([])` is an element with neither. Sibling elements that end up at different arities are expected and fine; void elements like `h.img` have always read that way.
56
-
57
- ### Commands
58
-
59
- Define a Command with `Command.define(name, { args, messages, execute })`; omit `args` when the Command takes none. Assign definitions to PascalCase constants. Never inline in pipe chains. Name the effect `execute` performs, not the later Model transition caused when update handles its result: a timer that only waits before update starts a dismissal is `WaitBeforeDismissal`, not `DismissAfter`. Commands catch all errors via `Effect.catch(() => Effect.succeed(Message.FailedX(...)))` so side effects never crash the app. Definitions live colocated with the update function that returns them.
60
-
61
- For the with-args shape, see `repos/foldkit/examples/weather/src/main.ts` or `repos/foldkit/examples/kanban/src/command.ts`. For an argless DOM-side-effect Command, the argless form in `kanban/src/command.ts` (`FocusAddCardInput`) is the canonical reference.
62
-
63
- For DOM operations (focus, scroll, modals, scroll lock), Foldkit ships a `Dom` module. For time, randomness, UUIDs, and delays, use Effect's built-ins directly (`Clock`, `Random`, `Effect.uuid`, `Effect.sleep`). Don't reach for raw `document.querySelector`, `setTimeout`, `Date.now()`, or `Math.random()`.
64
-
65
- ### File Organization
66
-
67
- The invariant: keep the runtime boot separate from the pure definitions. `src/entry.ts` calls `Runtime.makeApplication` and `Runtime.run`, and `index.html` references it. The definitions (Model, Messages, init, update, view, Commands) never call `Runtime.run`, so they stay importable from tests without booting a runtime as a side effect. Never call `Runtime.run` from `main.ts`.
68
-
69
- For a small app the definitions all fit in one `src/main.ts`. Split a unit into its own file when it has _both_ a distinct reason to change _and_ a name you'd give it unprompted: the pure domain core into `timer.ts` or `domain.ts`, the view into `view.ts` (or a `components/` directory), a Command's owned resource into its own module. Split on that revealed seam, not on line count alone. A file that has grown large is _evidence_ a seam has formed, so treat its size as a prompt to re-check for one. Two splits are forced: extract Messages to `message.ts` when Commands need the constructors (this breaks the cycle between `command.ts` and `main.ts`), and colocate Commands with the update that returns them. Exemplars: counter and stopwatch are a single `main.ts`; kanban splits `domain` / `command` / `message` / `model`; typing-game splits views by page.
70
-
71
- Use uppercase section headers (`// MODEL`, `// MESSAGE`, `// INIT`, `// UPDATE`, `// COMMAND`, `// VIEW`) for wayfinding.
72
-
73
- ### Testing
74
-
75
- Test update functions with `foldkit/test`. Since update is pure, tests run without a runtime, DOM, or side effects. Use `story` for update-level tests (send Messages, assert on Model and Commands) and `scene` for feature-level testing through the view with accessible locators.
76
-
77
- Import the steps as named imports from `foldkit/story` or `foldkit/scene`: `import { Command, given, message, model, story } from 'foldkit/story'`. A test file needs only one of the two modules. If a single file ever tests both, import the namespaces instead (`import { Scene, Story } from 'foldkit'`) so `Story.given` and `Scene.given` stay distinguishable.
78
-
79
- Name each test file for its test style, beside the code under test: `story.test.ts` for the Story tests (which drive `update`) and `scene.test.ts` for the Scene tests (which drive the rendered view). The name describes how the test works, not a source file, so it stays correct whether `update` and `view` live in `main.ts` or in their own files. A test file lives in the folder that holds the code it drives, so in a multi-page app most of them sit in a page folder rather than at the root. When one folder holds more than one test of a kind (sibling pages, component variants), prefix with the subject: `login.story.test.ts`.
80
-
81
- Scene runs at any level, since a page's own `update`/`view` pair drops into `scene` unmodified. Put a `scene.test.ts` in the page folder for behavior that page owns, which covers view states awkward to reach through the root Model, and keep a root-level `scene.test.ts` for flows that cross pages, which covers how the parent folds an OutMessage, a Command the parent lifts, a route change, and view inputs the parent computes. If the `repos/foldkit` subtree is available, study the `story.test.ts` and `scene.test.ts` files in `repos/foldkit/examples/`. `repos/foldkit/examples/auth` is the multi-page shape: a root `src/scene.test.ts` for the cross-page login flow alongside `src/page/loggedOut/page/login.scene.test.ts` and `login.story.test.ts` driving that page's own pair.
82
-
83
- ## Code Style
84
-
85
- - Encode state in discriminated unions, not booleans or nullable fields. `Idle | Loading | Error | Ok`, not `isLoading: boolean`. Make impossible states unrepresentable.
86
- - Use `Option` instead of `null` or `undefined`. Prefix Option-typed values with `maybe*`. Match with `Option.match`; don't unwrap with `Option.map(...)` + `Option.getOrElse(...)` when you can just match.
87
- - Use Effect modules over native methods in `pipe` chains (`Array.map`, `String.startsWith`, `Array.findFirst`). Native methods are fine when calling directly on a named variable.
88
- - Never cast Schema values with `as Type`. Use the callable constructor: `Message.SucceededLogin({ sessionId })`, not `{ _tag: 'SucceededLogin', sessionId } as Message`.
89
- - Always `Array.isArrayEmpty` / `Array.isArrayNonEmpty` (not `.length === 0` / `.length > 0`). Use `Array.match` when handling both empty and non-empty cases.
90
- - Never use `for` loops or `let` for iteration. Reach for `Array.map`, `Array.filterMap`, `Array.makeBy`, `Array.reduce`.
91
- - Never use `T[]`. Always `Array<T>` or `ReadonlyArray<T>`.
92
- - Use `Message.match` for exhaustive Message matching. Use Effect `Match` for other tagged unions, partial matching, fallbacks, and one handler shared across multiple tags. Never use `switch`.
93
- - Always use braces for control flow: `if (foo) { return true }`.
94
- - Don't add inline comments to explain code. Use better names instead. Reserve `// NOTE:` for behavior that would mislead a careful reader.
95
-
96
- ## Message Layout
97
-
98
- Declare the whole Message union with `defineMessageUnion()`, then put `type Message = typeof Message.Type` on the next line:
99
-
100
- ```ts
101
- const Message = defineMessageUnion({
102
- ClickedSubmit: {},
103
- UpdatedEmail: { value: S.String },
104
- })
105
- type Message = typeof Message.Type
106
- ```
107
-
108
- Keep the `defineMessageUnion()` declaration and `type Message` alias adjacent. Construct values through the namespace (`Message.ClickedSubmit()`) and handle the union with `Message.match`. Never destructure constructors from `Message` or `OutMessage`; the owning namespace stays visible at every call site.
109
-
110
- Keep each case's payload object on one line when it fits. Let Prettier wrap payloads that need more space, so the declaration remains easy to scan as one variant per line.
111
-
112
- Messages are verb-first past-tense. Common prefixes: `Clicked*`, `Updated*` (input changes and external state updates), `Submitted*`, `Pressed*`, `Selected*`, `Succeeded*` / `Failed*` (paired async results), `Completed*` (every other Command result), `Got*` (child OutMessage in the Submodel pattern).
113
-
114
- ## Debugging
115
-
116
- This project ships with `@foldkit/devtools-mcp` pre-wired. When the dev server is running and the app is open in a browser, `foldkit_*` MCP tools let you inspect Model, Message history, and time-travel. Reach for them before adding `console.log` whenever the question is about state or Message flow.
117
-
118
- ## Going Deeper
11
+ ## Project Notes
119
12
 
120
- For Submodels and OutMessage, Subscriptions, Mount / ManagedResource / CustomElement, field validation, routing, accessibility, and the full convention set, read the live Foldkit code in `repos/foldkit/`. The `examples/` directory and the production apps (`packages/typing-game/`, `packages/website/`) are the highest-fidelity references for any specific pattern. The `foldkit-skills` plugin's `generate-program` and `audit-program` skills carry written snapshot guides if you want a structured walkthrough.
13
+ Domain vocabulary, deployment steps, local conventions that differ from Foldkit's defaults: write them here.
@@ -0,0 +1,144 @@
1
+ # Foldkit Conventions
2
+
3
+ Foldkit owns this file. `create-foldkit-app` writes it and upgrades replace it whole, so anything added here is lost. Project-specific instructions go in `AGENTS.md`.
4
+
5
+ This is a Foldkit app, a framework built on Effect-TS, architected like Elm.
6
+
7
+ ## Getting the canonical references
8
+
9
+ The live Foldkit code is the canonical reference for everything: which APIs exist, what idiomatic apps look like, what the current conventions are. Written docs (including this one) can drift; the code can't.
10
+
11
+ If `./repos/foldkit` exists, it contains the full Foldkit repository, vendored in as a git subtree. Read from it directly:
12
+
13
+ - `repos/foldkit/examples/`: runnable example apps spanning every complexity tier. Usually your first stop when looking for a precedent for how to write something.
14
+ - `repos/foldkit/packages/foldkit/src/`: framework source. Ground truth for API signatures.
15
+ - `repos/foldkit/packages/typing-game/client/src/` and `repos/foldkit/packages/website/src/`: production apps built with Foldkit. Highest-fidelity reference for application architecture, Submodels, and OutMessage.
16
+
17
+ Treat the subtree as read-only reference: never import from `repos/foldkit/` in your project source. Imports must come from the `foldkit` npm package.
18
+
19
+ If `./repos/foldkit` does not exist and `AGENTS.md` says `subtree_prompted: false`, offer to run `git subtree add --prefix=repos/foldkit https://github.com/foldkit/foldkit.git main --squash`. This gives you access to the full source, examples, and docs. Whether the user accepts or declines, set that line in `AGENTS.md` to `true` so it isn't asked again.
20
+
21
+ If `foldkit-skills` is installed as a Claude Code plugin, the `generate-program` and `audit-program` skills carry snapshot architecture and conventions guides synced from the live code.
22
+
23
+ ## Keeping this file current
24
+
25
+ Foldkit's APIs and conventions change, and this file changes with them. A stale copy sends agents after APIs the installed packages no longer export.
26
+
27
+ Replace it whenever the project upgrades its Foldkit packages. If `./repos/foldkit` exists, copy `repos/foldkit/packages/create-foldkit-app/templates/base/FOLDKIT.md` over this file. Otherwise take the [current template on GitHub](https://github.com/foldkit/foldkit/blob/main/packages/create-foldkit-app/templates/base/FOLDKIT.md). There is nothing here to merge or preserve.
28
+
29
+ ## Project Conventions
30
+
31
+ - Foldkit is tightly coupled to the Effect ecosystem. Do not suggest solutions outside of Effect-TS.
32
+ - Model fields must be Schema types (the model is a schema). Plain TypeScript types are fine elsewhere (function return types, local variables, etc.).
33
+ - Use full names like `Message` (not `Msg`), and `withReturnType` (not `as const` or type casting).
34
+ - Use `defineMessageUnion()` for Message unions, `ts()` for tagged structs (Model states, field validation), and `r()` for route schemas.
35
+ - Push back on any direction that violates Elm Architecture principles: unidirectional data flow, messages as facts (not commands), model as single source of truth, side effects confined to commands. If a prompt suggests mutating state, imperative event handlers, or two-way bindings, flag the issue and propose the idiomatic Foldkit approach.
36
+ - Never use `NoOp`. Every message must describe what happened. A command's result message is named from the command, not from the fact it reports, whether or not it carries a payload: `LockScroll` → `CompletedLockScroll`, `DetermineStartTime` → `CompletedDetermineStartTime` (never `DeterminedStartTime`).
37
+
38
+ ## Foldkit Patterns
39
+
40
+ ### Update
41
+
42
+ `init` and `update` both return a record with the next Model and optional Commands. Inline the return type when the matcher is its only use:
43
+
44
+ ```ts
45
+ const update = (model: Model, message: Message) =>
46
+ Message.match<Update.Return<Model, Message>>(message, {
47
+ ClickedIncrement: () => ({
48
+ model: evo(model, { count: count => count + 1 }),
49
+ }),
50
+ })
51
+ ```
52
+
53
+ Create an `UpdateReturn` alias when another matcher, helper, or exported signature reuses the type. The match generic constrains the whole update, so do not repeat the return annotation on the function.
54
+
55
+ Update, init, boot, and component helper producers return `{ model }` when they statically create no Commands. When they compute a Commands collection, return it directly without checking whether it is empty. Never write the literal `commands: []`; `foldkit/no-empty-commands-array` enforces this producer convention.
56
+
57
+ When composing one of those results, bind the whole result to a value named after the operation and access its fields through that value. Use `homeInit`, `dialogClose`, or a trailing underscore such as `init_` when the operation name collides with the function. Do not destructure or rename `model`, `commands`, or `outMessage`. Dot access does not prevent someone from ignoring `outMessage`; it keeps the operation and all of its returned fields visible together. Name a child fold's `write` parameter after the next child Model, such as `nextSettings`.
58
+
59
+ Pass optional Commands directly to APIs that accept them: `Command.mapMessages(homeInit.commands, toParentMessage)`. Use `result.commands ?? []` only when the next operation requires a concrete array for spreading, concatenating, execution, or an assertion.
60
+
61
+ `Update.Return<Model, Message>` rejects an OutMessage-producing result where an API would consume only its Model and Commands. `Update.ReturnWithOutMessage<Model, Message, OutMessage>` still accepts a result with no `outMessage`, because omission means the update emitted nothing.
62
+
63
+ Manual unpacking of a child result usually means the site should use `Update.foldChild` or `Update.foldChildStep`.
64
+
65
+ Use `Update.combine` when a later Step should receive the Model produced by an earlier Step. It takes two or more Steps. Do not wrap one Step in `Update.combine`; call that operation directly. Name an inline Step parameter `stepModel`; it contains the Model produced by the preceding Step.
66
+
67
+ When the OutMessage is already known while constructing a new result, include it directly: `{ model, commands, outMessage }`. Use `Update.withOutMessage` when attaching an OutMessage to an existing plain return or when the value has the type `OutMessage | undefined`. Pipe an existing return into the helper: `pipe(dialogClose, Update.withOutMessage(outMessage))`. When constructing the plain return in the same expression, pass it first: `Update.withOutMessage({ model, commands }, outMessage)`.
68
+
69
+ Add `toParentOutMessage` only when at least one child OutMessage is forwarded from the current Submodel to its parent. Omit it when no variant is forwarded. Local handling through `foldOutMessage` is independent, so a forwarded variant may also update the current parent. Never write `toParentOutMessage: () => undefined`.
70
+
71
+ Use `evo()` from `foldkit/struct` for immutable model updates. Never spread or `Object.assign`.
72
+
73
+ ### View
74
+
75
+ Every view receives `h`, the typed Html builder, as its last parameter (`view: (model, h) => ...`; `Submodel.defineView` passes the child's own). Never construct a builder; reach for `h.div`, `h.OnClick`, etc. off the parameter, and give extracted view helpers an `h: HtmlBuilder<Message>` last parameter that callers thread through. Only where no builder is in scope, typically module scope, use `inertHtml` from `foldkit/html`, aliased `ih` so the two builders stay distinguishable. Use `h.empty` (not `null`) for conditional rendering, `M.value().pipe(M.tagsExhaustive({...}))` for discriminated unions, and `Array.match` for lists that may be empty.
76
+
77
+ Keys are for mapped list items only: key each row by a stable Model identifier (`h.keyed('li')(item.id, [], [...])`), never by array position, and never derive a key from displayed data. Never key branches; the build gives each view function's output its own identity, so branch switches replace DOM automatically. When switching an inline same-tag ternary must reset DOM state, extract each arm into its own named view function.
78
+
79
+ Omit the children argument when an element has none: `h.div([h.Class('divider')])`, never `h.div([h.Class('divider')], [])`. The same holds for `keyed`: `h.keyed('li')(key, [attrs])`, never `h.keyed('li')(key, [attrs], [])`. Attributes stay required on element builders, so `h.div([])` is an element with neither. Sibling elements that end up at different arities are expected and fine; void elements like `h.img` have always read that way.
80
+
81
+ ### Commands
82
+
83
+ Define a Command with `Command.define(name, { args, messages, execute })`; omit `args` when the Command takes none. Assign definitions to PascalCase constants. Never inline in pipe chains. Name the effect `execute` performs, not the later Model transition caused when update handles its result: a timer that only waits before update starts a dismissal is `WaitBeforeDismissal`, not `DismissAfter`. Commands catch all errors via `Effect.catch(() => Effect.succeed(Message.FailedX(...)))` so side effects never crash the app. Definitions live colocated with the update function that returns them.
84
+
85
+ For the with-args shape, see `repos/foldkit/examples/weather/src/main.ts` or `repos/foldkit/examples/kanban/src/command.ts`. For an argless DOM-side-effect Command, the argless form in `kanban/src/command.ts` (`FocusAddCardInput`) is the canonical reference.
86
+
87
+ For DOM operations (focus, scroll, modals, scroll lock), Foldkit ships a `Dom` module. For time, randomness, UUIDs, and delays, use Effect's built-ins directly (`Clock`, `Random`, `Effect.uuid`, `Effect.sleep`). Don't reach for raw `document.querySelector`, `setTimeout`, `Date.now()`, or `Math.random()`.
88
+
89
+ ### File Organization
90
+
91
+ The invariant: keep the runtime boot separate from the pure definitions. `src/entry.ts` calls `Runtime.makeApplication` and `Runtime.run`, and `index.html` references it. The definitions (Model, Messages, init, update, view, Commands) never call `Runtime.run`, so they stay importable from tests without booting a runtime as a side effect. Never call `Runtime.run` from `main.ts`.
92
+
93
+ For a small app the definitions all fit in one `src/main.ts`. Split a unit into its own file when it has _both_ a distinct reason to change _and_ a name you'd give it unprompted: the pure domain core into `timer.ts` or `domain.ts`, the view into `view.ts` (or a `components/` directory), a Command's owned resource into its own module. Split on that revealed seam, not on line count alone. A file that has grown large is _evidence_ a seam has formed, so treat its size as a prompt to re-check for one. Two splits are forced: extract Messages to `message.ts` when Commands need the constructors (this breaks the cycle between `command.ts` and `main.ts`), and colocate Commands with the update that returns them. Exemplars: counter and stopwatch are a single `main.ts`; kanban splits `domain` / `command` / `message` / `model`; typing-game splits views by page.
94
+
95
+ Use uppercase section headers (`// MODEL`, `// MESSAGE`, `// INIT`, `// UPDATE`, `// COMMAND`, `// VIEW`) for wayfinding.
96
+
97
+ ### Testing
98
+
99
+ Test update functions with `foldkit/test`. Since update is pure, tests run without a runtime, DOM, or side effects. Use `story` for update-level tests (send Messages, assert on Model and Commands) and `scene` for feature-level testing through the view with accessible locators.
100
+
101
+ Import the steps as named imports from `foldkit/story` or `foldkit/scene`: `import { Command, given, message, model, story } from 'foldkit/story'`. A test file needs only one of the two modules. If a single file ever tests both, import the namespaces instead (`import { Scene, Story } from 'foldkit'`) so `Story.given` and `Scene.given` stay distinguishable.
102
+
103
+ Name each test file for its test style, beside the code under test: `story.test.ts` for the Story tests (which drive `update`) and `scene.test.ts` for the Scene tests (which drive the rendered view). The name describes how the test works, not a source file, so it stays correct whether `update` and `view` live in `main.ts` or in their own files. A test file lives in the folder that holds the code it drives, so in a multi-page app most of them sit in a page folder rather than at the root. When one folder holds more than one test of a kind (sibling pages, component variants), prefix with the subject: `login.story.test.ts`.
104
+
105
+ Scene runs at any level, since a page's own `update`/`view` pair drops into `scene` unmodified. Put a `scene.test.ts` in the page folder for behavior that page owns, which covers view states awkward to reach through the root Model, and keep a root-level `scene.test.ts` for flows that cross pages, which covers how the parent folds an OutMessage, a Command the parent lifts, a route change, and view inputs the parent computes. If the `repos/foldkit` subtree is available, study the `story.test.ts` and `scene.test.ts` files in `repos/foldkit/examples/`. `repos/foldkit/examples/auth` is the multi-page shape: a root `src/scene.test.ts` for the cross-page login flow alongside `src/page/loggedOut/page/login.scene.test.ts` and `login.story.test.ts` driving that page's own pair.
106
+
107
+ ## Code Style
108
+
109
+ - Encode state in discriminated unions, not booleans or nullable fields. `Idle | Loading | Error | Ok`, not `isLoading: boolean`. Make impossible states unrepresentable.
110
+ - Use `Option` for absence in the Model and domain values instead of `null` or `undefined`. Foldkit return records are the exception: omit `commands` and `outMessage` when absent. A partial `toParentOutMessage` mapper returns `undefined` for each named child variant that stops at the current parent. Prefix Option-typed values with `maybe*`. Match with `Option.match`; don't unwrap with `Option.map(...)` + `Option.getOrElse(...)` when you can just match.
111
+ - Use Effect modules over native methods in `pipe` chains (`Array.map`, `String.startsWith`, `Array.findFirst`). Native methods are fine when calling directly on a named variable.
112
+ - Never cast Schema values with `as Type`. Use the callable constructor: `Message.SucceededLogin({ sessionId })`, not `{ _tag: 'SucceededLogin', sessionId } as Message`.
113
+ - Always `Array.isArrayEmpty` / `Array.isArrayNonEmpty` (not `.length === 0` / `.length > 0`). Use `Array.match` when handling both empty and non-empty cases.
114
+ - Never use `for` loops or `let` for iteration. Reach for `Array.map`, `Array.filterMap`, `Array.makeBy`, `Array.reduce`.
115
+ - Never use `T[]`. Always `Array<T>` or `ReadonlyArray<T>`.
116
+ - Use `Message.match` for exhaustive Message matching. Use Effect `Match` for other tagged unions, partial matching, fallbacks, and one handler shared across multiple tags. Never use `switch`.
117
+ - Always use braces for control flow: `if (foo) { return true }`.
118
+ - Don't add inline comments to explain code. Use better names instead. Reserve `// NOTE:` for behavior that would mislead a careful reader.
119
+
120
+ ## Message Layout
121
+
122
+ Declare the whole Message union with `defineMessageUnion()`, then put `type Message = typeof Message.Type` on the next line:
123
+
124
+ ```ts
125
+ const Message = defineMessageUnion({
126
+ ClickedSubmit: {},
127
+ UpdatedEmail: { value: S.String },
128
+ })
129
+ type Message = typeof Message.Type
130
+ ```
131
+
132
+ Keep the `defineMessageUnion()` declaration and `type Message` alias adjacent. Construct values through the namespace (`Message.ClickedSubmit()`) and handle the union with `Message.match`. Never destructure constructors from `Message` or `OutMessage`; the owning namespace stays visible at every call site.
133
+
134
+ Keep each case's payload object on one line when it fits. Let Prettier wrap payloads that need more space, so the declaration remains easy to scan as one variant per line.
135
+
136
+ Messages are verb-first past-tense. Common prefixes: `Clicked*`, `Updated*` (input changes and external state updates), `Submitted*`, `Pressed*`, `Selected*`, `Succeeded*` / `Failed*` (paired async results), `Completed*` (every other Command result), `Got*` (child OutMessage in the Submodel pattern).
137
+
138
+ ## Debugging
139
+
140
+ This project ships with `@foldkit/devtools-mcp` pre-wired. When the dev server is running and the app is open in a browser, `foldkit_*` MCP tools let you inspect Model, Message history, and time-travel. Reach for them before adding `console.log` whenever the question is about state or Message flow.
141
+
142
+ ## Going Deeper
143
+
144
+ For Submodels and OutMessage, Subscriptions, Mount / ManagedResource / CustomElement, field validation, routing, accessibility, and the full convention set, read the live Foldkit code in `repos/foldkit/`. The `examples/` directory and the production apps (`packages/typing-game/`, `packages/website/`) are the highest-fidelity references for any specific pattern. The `foldkit-skills` plugin's `generate-program` and `audit-program` skills carry written snapshot guides if you want a structured walkthrough.
@@ -90,18 +90,18 @@ export type Message = typeof Message.Type
90
90
 
91
91
  // UPDATE
92
92
 
93
- type UpdateReturn = readonly [Model, ReadonlyArray<Command.Command<Message>>]
93
+ type UpdateReturn = Update.Return<Model, Message>
94
94
 
95
95
  const applyPostsTransition = (
96
96
  model: Model,
97
97
  maybeNextPosts: Option.Option<PostsData>,
98
98
  ): UpdateReturn =>
99
99
  Option.match(maybeNextPosts, {
100
- onNone: () => [model, []],
101
- onSome: nextPosts => [
102
- evo(model, { posts: () => nextPosts }),
103
- [FetchPosts()],
104
- ],
100
+ onNone: () => ({ model }),
101
+ onSome: nextPosts => ({
102
+ model: evo(model, { posts: () => nextPosts }),
103
+ commands: [FetchPosts()],
104
+ }),
105
105
  })
106
106
 
107
107
  const applyStatsTransition = (
@@ -109,11 +109,11 @@ const applyStatsTransition = (
109
109
  maybeNextStats: Option.Option<StatsData>,
110
110
  ): UpdateReturn =>
111
111
  Option.match(maybeNextStats, {
112
- onNone: () => [model, []],
113
- onSome: nextStats => [
114
- evo(model, { stats: () => nextStats }),
115
- [FetchStats()],
116
- ],
112
+ onNone: () => ({ model }),
113
+ onSome: nextStats => ({
114
+ model: evo(model, { stats: () => nextStats }),
115
+ commands: [FetchStats()],
116
+ }),
117
117
  })
118
118
 
119
119
  const setPostDetail = (postId: string, postDetail: PostDetailData) =>
@@ -168,20 +168,19 @@ export const update = (model: Model, message: Message) =>
168
168
  })
169
169
 
170
170
  return Option.match(HashMap.get(model.postDetailById, postId), {
171
- onNone: () => [
172
- evo(selectedModel, {
171
+ onNone: () => ({
172
+ model: evo(selectedModel, {
173
173
  postDetailById: setPostDetail(postId, PostDetailData.Loading()),
174
174
  }),
175
- [FetchPostDetail({ postId })],
176
- ],
177
- onSome: () => [selectedModel, []],
175
+ commands: [FetchPostDetail({ postId })],
176
+ }),
177
+ onSome: () => ({ model: selectedModel }),
178
178
  })
179
179
  },
180
180
 
181
- ClickedBackToPosts: () => [
182
- evo(model, { maybeSelectedPostId: () => Option.none() }),
183
- [],
184
- ],
181
+ ClickedBackToPosts: () => ({
182
+ model: evo(model, { maybeSelectedPostId: () => Option.none() }),
183
+ }),
185
184
 
186
185
  ClickedInvalidatePosts: () =>
187
186
  applyPostsTransition(model, AsyncData.revalidateOrLoad(model.posts)),
@@ -189,12 +188,12 @@ export const update = (model: Model, message: Message) =>
189
188
  ClickedRetryPosts: () =>
190
189
  applyPostsTransition(model, AsyncData.revalidateOrLoad(model.posts)),
191
190
 
192
- ClickedRetryPostDetail: ({ postId }) => [
193
- evo(model, {
191
+ ClickedRetryPostDetail: ({ postId }) => ({
192
+ model: evo(model, {
194
193
  postDetailById: setPostDetail(postId, PostDetailData.Loading()),
195
194
  }),
196
- [FetchPostDetail({ postId })],
197
- ],
195
+ commands: [FetchPostDetail({ postId })],
196
+ }),
198
197
 
199
198
  ClickedRefreshStats: () =>
200
199
  applyStatsTransition(model, AsyncData.revalidateOrLoad(model.stats)),
@@ -205,28 +204,25 @@ export const update = (model: Model, message: Message) =>
205
204
  TickedRevalidateStats: () =>
206
205
  applyStatsTransition(model, AsyncData.revalidate(model.stats)),
207
206
 
208
- SettledFetchPosts: ({ result }) => [
209
- evo(model, { posts: AsyncData.settle(result) }),
210
- [],
211
- ],
207
+ SettledFetchPosts: ({ result }) => ({
208
+ model: evo(model, { posts: AsyncData.settle(result) }),
209
+ }),
212
210
 
213
- SettledFetchPostDetail: ({ postId, result }) => [
214
- evo(model, {
211
+ SettledFetchPostDetail: ({ postId, result }) => ({
212
+ model: evo(model, {
215
213
  postDetailById: HashMap.modify(postId, AsyncData.settle(result)),
216
214
  }),
217
- [],
218
- ],
215
+ }),
219
216
 
220
- SettledFetchStats: ({ result }) => [
221
- evo(model, { stats: AsyncData.settle(result) }),
222
- [],
223
- ],
217
+ SettledFetchStats: ({ result }) => ({
218
+ model: evo(model, { stats: AsyncData.settle(result) }),
219
+ }),
224
220
  })
225
221
 
226
222
  // INIT
227
223
 
228
- export const init: Runtime.ApplicationInit<Model, Message> = () => [
229
- {
224
+ export const init: Runtime.ApplicationInit<Model, Message> = () => ({
225
+ model: {
230
226
  tabs: Tabs.init({ id: TABS_ID }),
231
227
  activeTab: 'Posts',
232
228
  posts: PostsData.Loading(),
@@ -234,8 +230,8 @@ export const init: Runtime.ApplicationInit<Model, Message> = () => [
234
230
  maybeSelectedPostId: Option.none(),
235
231
  stats: StatsData.Idle(),
236
232
  },
237
- [FetchPosts()],
238
- ]
233
+ commands: [FetchPosts()],
234
+ })
239
235
 
240
236
  // COMMAND
241
237
 
@@ -1,6 +1,6 @@
1
1
  import { Effect, Match as M, Option, Schema as S } from 'effect'
2
2
  import { KeyValueStore } from 'effect/unstable/persistence'
3
- import { Command, Runtime } from 'foldkit'
3
+ import { Runtime, type Update } from 'foldkit'
4
4
  import { Url } from 'foldkit/url'
5
5
 
6
6
  import { BrowserKeyValueStore } from '@effect/platform-browser'
@@ -39,7 +39,7 @@ export type Flags = typeof Flags.Type
39
39
 
40
40
  // INIT
41
41
 
42
- type InitReturn = [Model, ReadonlyArray<Command.Command<Message>>]
42
+ type InitReturn = Update.Return<Model, Message>
43
43
  const withInitReturn = M.withReturnType<InitReturn>()
44
44
 
45
45
  export const init: Runtime.RoutingApplicationInit<Model, Message, Flags> = (
@@ -52,24 +52,25 @@ export const init: Runtime.RoutingApplicationInit<Model, Message, Flags> = (
52
52
  onNone: () =>
53
53
  M.value(route).pipe(
54
54
  withInitReturn,
55
- M.tag('Home', 'Login', 'NotFound', route => [
56
- LoggedOut.init(route),
57
- [],
58
- ]),
59
- M.orElse(() => [LoggedOut.init(LoginRoute()), [RedirectToLogin()]]),
55
+ M.tag('Home', 'Login', 'NotFound', route => ({
56
+ model: LoggedOut.init(route),
57
+ })),
58
+ M.orElse(() => ({
59
+ model: LoggedOut.init(LoginRoute()),
60
+ commands: [RedirectToLogin()],
61
+ })),
60
62
  ),
61
63
 
62
64
  onSome: session =>
63
65
  M.value(route).pipe(
64
66
  withInitReturn,
65
- M.tag('Dashboard', 'Settings', 'NotFound', route => [
66
- LoggedIn.init(route, session),
67
- [],
68
- ]),
69
- M.orElse(() => [
70
- LoggedIn.init(DashboardRoute(), session),
71
- [RedirectToDashboard()],
72
- ]),
67
+ M.tag('Dashboard', 'Settings', 'NotFound', route => ({
68
+ model: LoggedIn.init(route, session),
69
+ })),
70
+ M.orElse(() => ({
71
+ model: LoggedIn.init(DashboardRoute(), session),
72
+ commands: [RedirectToDashboard()],
73
+ })),
73
74
  ),
74
75
  })
75
76
  }
@@ -1,16 +1,15 @@
1
- import { Option } from 'effect'
2
- import { Command } from 'foldkit'
1
+ import { type Update } from 'foldkit'
3
2
 
4
3
  import { Message, OutMessage } from './message'
5
4
  import { Model } from './model'
6
5
 
7
- type UpdateReturn = readonly [
8
- Model,
9
- ReadonlyArray<Command.Command<Message>>,
10
- Option.Option<OutMessage>,
11
- ]
12
-
13
6
  export const update = (model: Model, message: Message) =>
14
- Message.match<UpdateReturn>(message, {
15
- ClickedLogout: () => [model, [], Option.some(OutMessage.RequestedLogout())],
16
- })
7
+ Message.match<Update.ReturnWithOutMessage<Model, Message, OutMessage>>(
8
+ message,
9
+ {
10
+ ClickedLogout: () => ({
11
+ model,
12
+ outMessage: OutMessage.RequestedLogout(),
13
+ }),
14
+ },
15
+ )
@@ -9,7 +9,7 @@ import {
9
9
  String,
10
10
  pipe,
11
11
  } from 'effect'
12
- import { Command, Submodel } from 'foldkit'
12
+ import { Command, Submodel, type Update } from 'foldkit'
13
13
  import {
14
14
  Field,
15
15
  Invalid,
@@ -86,12 +86,6 @@ const isFormValid = (model: Model): boolean =>
86
86
 
87
87
  // UPDATE
88
88
 
89
- type UpdateReturn = readonly [
90
- Model,
91
- ReadonlyArray<Command.Command<Message>>,
92
- Option.Option<OutMessage>,
93
- ]
94
-
95
89
  export const SimulateAuthRequest = Command.define('SimulateAuthRequest', {
96
90
  args: { email: S.String, password: S.String },
97
91
  messages: [
@@ -122,59 +116,54 @@ export const SimulateAuthRequest = Command.define('SimulateAuthRequest', {
122
116
  })
123
117
 
124
118
  export const update = (model: Model, message: Message) =>
125
- Message.match<UpdateReturn>(message, {
126
- ChangedEmail: ({ value }) => [
127
- evo(model, { email: () => validateEmail(value) }),
128
- [],
129
- Option.none(),
130
- ],
131
-
132
- ChangedPassword: ({ value }) => [
133
- evo(model, { password: () => validatePassword(value) }),
134
- [],
135
- Option.none(),
136
- ],
137
-
138
- SubmittedForm: () => {
139
- if (model.isSubmitting) {
140
- return [model, [], Option.none()]
141
- }
119
+ Message.match<Update.ReturnWithOutMessage<Model, Message, OutMessage>>(
120
+ message,
121
+ {
122
+ ChangedEmail: ({ value }) => ({
123
+ model: evo(model, { email: () => validateEmail(value) }),
124
+ }),
142
125
 
143
- if (!isFormValid(model)) {
144
- return [model, [], Option.none()]
145
- }
126
+ ChangedPassword: ({ value }) => ({
127
+ model: evo(model, { password: () => validatePassword(value) }),
128
+ }),
146
129
 
147
- return [
148
- evo(model, { isSubmitting: () => true }),
149
- [
150
- SimulateAuthRequest({
151
- email: model.email.value,
152
- password: model.password.value,
153
- }),
154
- ],
155
- Option.none(),
156
- ]
157
- },
130
+ SubmittedForm: () => {
131
+ if (model.isSubmitting) {
132
+ return { model }
133
+ }
134
+
135
+ if (!isFormValid(model)) {
136
+ return { model }
137
+ }
138
+
139
+ return {
140
+ model: evo(model, { isSubmitting: () => true }),
141
+ commands: [
142
+ SimulateAuthRequest({
143
+ email: model.email.value,
144
+ password: model.password.value,
145
+ }),
146
+ ],
147
+ }
148
+ },
158
149
 
159
- SucceededSimulateAuthRequest: ({ session }) => [
160
- model,
161
- [],
162
- Option.some(OutMessage.SucceededLogin({ session })),
163
- ],
150
+ SucceededSimulateAuthRequest: ({ session }) => ({
151
+ model,
152
+ outMessage: OutMessage.SucceededLogin({ session }),
153
+ }),
164
154
 
165
- FailedSimulateAuthRequest: ({ error }) => [
166
- evo(model, {
167
- password: () =>
168
- Invalid({
169
- value: model.password.value,
170
- errors: [error],
171
- }),
172
- isSubmitting: () => false,
155
+ FailedSimulateAuthRequest: ({ error }) => ({
156
+ model: evo(model, {
157
+ password: () =>
158
+ Invalid({
159
+ value: model.password.value,
160
+ errors: [error],
161
+ }),
162
+ isSubmitting: () => false,
163
+ }),
173
164
  }),
174
- [],
175
- Option.none(),
176
- ],
177
- })
165
+ },
166
+ )
178
167
 
179
168
  // VIEW
180
169