create-foldkit-app 0.38.0 → 0.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/dist/templates/base/FOLDKIT.md +3 -1
  2. package/dist/templates/examples/canvas-art/src/main.ts +1 -1
  3. package/dist/templates/examples/charting/src/chartHost.ts +3 -4
  4. package/dist/templates/examples/charting/src/entry.ts +0 -2
  5. package/dist/templates/examples/charting/src/view/chart.ts +101 -36
  6. package/dist/templates/examples/embedding/src/main.ts +3 -3
  7. package/dist/templates/examples/generative-art/src/subscription.ts +1 -1
  8. package/dist/templates/examples/job-application/src/step/education/education.ts +14 -16
  9. package/dist/templates/examples/job-application/src/step/skills/skills.ts +14 -16
  10. package/dist/templates/examples/job-application/src/step/workHistory/workHistory.ts +14 -16
  11. package/dist/templates/examples/map/src/entry.ts +1 -2
  12. package/dist/templates/examples/map/src/main.ts +123 -145
  13. package/dist/templates/examples/map/src/mapHost.ts +10 -12
  14. package/dist/templates/examples/map/src/mount.test.ts +93 -68
  15. package/dist/templates/examples/pixel-art/src/subscription.ts +5 -5
  16. package/dist/templates/examples/routing/src/main.ts +3 -3
  17. package/dist/templates/examples/slow-warnings/src/main.ts +4 -4
  18. package/dist/templates/examples/snake/src/main.ts +4 -3
  19. package/dist/templates/examples/ssg/src/entry.server.ts +2 -0
  20. package/dist/templates/examples/ssg/src/entry.ts +1 -0
  21. package/dist/templates/examples/ssr/package.json +1 -1
  22. package/dist/templates/examples/ssr/src/entry.server.ts +2 -0
  23. package/dist/templates/examples/ssr/src/entry.ts +1 -0
  24. package/dist/templates/examples/stopwatch/src/main.ts +1 -0
  25. package/dist/templates/examples/ui-showcase/src/main.ts +49 -37
  26. package/dist/templates/examples/ui-showcase/src/ui/subscriptions.ts +1 -18
  27. package/dist/templates/examples/ui-showcase/src/ui/update.ts +2 -12
  28. package/dist/templates/examples/ui-showcase/src/ui/view/calendar.ts +9 -7
  29. package/dist/templates/examples/ui-showcase/src/ui/view/checkbox.ts +80 -65
  30. package/dist/templates/examples/ui-showcase/src/ui/view/dialog.ts +102 -123
  31. package/dist/templates/examples/ui-showcase/src/ui/view/disclosure.ts +128 -120
  32. package/dist/templates/examples/ui-showcase/src/ui/view/fieldset.ts +173 -144
  33. package/dist/templates/examples/ui-showcase/src/ui/view/fileDrop.ts +17 -15
  34. package/dist/templates/examples/ui-showcase/src/ui/view/hoverIntent.ts +64 -56
  35. package/dist/templates/examples/ui-showcase/src/ui/view/input.ts +32 -33
  36. package/dist/templates/examples/ui-showcase/src/ui/view/meter.ts +82 -73
  37. package/dist/templates/examples/ui-showcase/src/ui/view/popover.ts +100 -81
  38. package/dist/templates/examples/ui-showcase/src/ui/view/progress.ts +83 -68
  39. package/dist/templates/examples/ui-showcase/src/ui/view/radioGroup.ts +79 -85
  40. package/dist/templates/examples/ui-showcase/src/ui/view/select.ts +63 -67
  41. package/dist/templates/examples/ui-showcase/src/ui/view/slider.ts +72 -72
  42. package/dist/templates/examples/ui-showcase/src/ui/view/switch.ts +25 -26
  43. package/dist/templates/examples/ui-showcase/src/ui/view/tabs.ts +58 -46
  44. package/dist/templates/examples/ui-showcase/src/ui/view/textarea.ts +32 -39
  45. package/dist/templates/examples/ui-showcase/src/ui/view/tooltip.ts +48 -36
  46. package/dist/templates/examples/websocket-chat/src/main.ts +44 -18
  47. package/dist/templates/examples/websocket-chat/src/story.test.ts +33 -6
  48. package/dist/templates/release.json +12 -11
  49. package/dist/templates/rendering/ssg/src/entry.server.ts +2 -0
  50. package/dist/templates/rendering/ssg/src/entry.ts +1 -0
  51. package/dist/templates/rendering/ssg/vite.config.ts +1 -0
  52. package/dist/templates/rendering/ssr/scripts/serve.ts +4 -145
  53. package/dist/templates/rendering/ssr/src/entry.server.ts +2 -0
  54. package/dist/templates/rendering/ssr/src/entry.ts +1 -0
  55. package/dist/templates/rendering/ssr/vite.config.ts +1 -0
  56. package/dist/utils/files.js +2 -0
  57. package/package.json +1 -1
  58. package/dist/templates/examples/charting/src/subscription.ts +0 -70
@@ -108,7 +108,9 @@ For DOM operations (focus, scroll, modals, scroll lock), Foldkit ships a `Dom` m
108
108
 
109
109
  ### File Organization
110
110
 
111
- 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`.
111
+ Keep the runtime boot separate from the pure definitions. `src/entry.ts` calls `Runtime.makeApplication`, then `Runtime.run` for a client-rendered app or `Runtime.hydrate` for an SSR or SSG app. A client-rendered app references that entry from `index.html`. An SSR or SSG app names it in `ssr.clientEntry` in `vite.config.ts`, imports CSS from it, and exports `renderDocument` from `src/entry.server.ts` to produce the complete document. Its source tree has no `index.html`.
112
+
113
+ The definitions (Model, Messages, init, update, view, Commands) never call `Runtime.run` or `Runtime.hydrate`, so tests and server entries can import them without booting a browser runtime. Keep both calls out of `main.ts`.
112
114
 
113
115
  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.
114
116
 
@@ -159,7 +159,7 @@ export const update = (model: Model, message: Message) =>
159
159
  // SUBSCRIPTION
160
160
 
161
161
  export const subscriptions = Subscription.make<Model, Message>()(_entry => ({
162
- frame: Subscription.animationFrame({
162
+ frame: Subscription.animationFrameEntry({
163
163
  isActive: model => model.isRunning,
164
164
  toMessage: deltaTime => Message.TickedFrame({ deltaTime }),
165
165
  }),
@@ -10,11 +10,10 @@ export const setChart = (hostId: string, chart: EChartsType): void => {
10
10
  export const getChart = (hostId: string): Option.Option<EChartsType> =>
11
11
  Option.fromNullishOr(chartsByHostId.get(hostId))
12
12
 
13
- export const removeChart = (hostId: string): void => {
14
- const maybeChart = getChart(hostId)
13
+ export const removeChart = (hostId: string, chart: EChartsType): void => {
14
+ chart.dispose()
15
15
 
16
- if (Option.isSome(maybeChart)) {
17
- maybeChart.value.dispose()
16
+ if (chartsByHostId.get(hostId) === chart) {
18
17
  chartsByHostId.delete(hostId)
19
18
  }
20
19
  }
@@ -4,7 +4,6 @@ import { registerEcharts } from './echarts'
4
4
  import { init } from './init'
5
5
  import { Message } from './message'
6
6
  import { Model } from './model'
7
- import { subscriptions } from './subscription'
8
7
  import { update } from './update'
9
8
  import { view } from './view/index'
10
9
 
@@ -15,7 +14,6 @@ const application = Runtime.makeApplication({
15
14
  init,
16
15
  update,
17
16
  view,
18
- subscriptions,
19
17
  container: document.getElementById('root'),
20
18
  devTools: {
21
19
  Message,
@@ -1,5 +1,5 @@
1
1
  import * as echarts from 'echarts/core'
2
- import { Effect, Option, Schema } from 'effect'
2
+ import { Effect, Option, Queue, Schema, Stream, pipe } from 'effect'
3
3
  import { Mount } from 'foldkit'
4
4
  import type { Html } from 'foldkit/html'
5
5
  import { HtmlBuilder } from 'foldkit/html'
@@ -13,49 +13,114 @@ import { formatInteger } from './format'
13
13
 
14
14
  export const CHART_HOST_ID = 'charting-chart'
15
15
 
16
- const mountChart = (element: Element, hostId: string) =>
17
- Effect.gen(function* () {
18
- if (!(element instanceof HTMLElement)) {
19
- return Message.FailedMountChart({
20
- reason: 'Chart host is not an HTMLElement.',
21
- })
22
- }
23
-
24
- return yield* Effect.acquireRelease(
25
- Effect.try({
26
- try: () => {
27
- const chart = echarts.init(element, undefined, {
28
- renderer: 'canvas',
29
- })
30
- const resizeObserver = new ResizeObserver(() => chart.resize())
16
+ type MountChartMessage =
17
+ | typeof Message.SucceededMountChart.Type
18
+ | typeof Message.FailedMountChart.Type
19
+ | typeof Message.ClickedChartDatum.Type
20
+
21
+ const toChartMountError = (error: unknown): Error =>
22
+ error instanceof Error ? error : new Error(`Failed to mount chart: ${error}`)
23
+
24
+ const ChartClickEvent = Schema.Struct({
25
+ data: Schema.OptionFromOptional(Schema.Struct({ id: Schema.String })),
26
+ })
27
+
28
+ const datumIdFromChartClickEvent = (event: unknown): Option.Option<string> =>
29
+ pipe(
30
+ event,
31
+ Schema.decodeUnknownOption(ChartClickEvent),
32
+ Option.flatMap(({ data }) => data),
33
+ Option.map(({ id }) => id),
34
+ )
35
+
36
+ const observeChartResizeAndClickEvents = (
37
+ element: HTMLElement,
38
+ chart: ReturnType<typeof echarts.init>,
39
+ queue: Queue.Enqueue<MountChartMessage>,
40
+ ) =>
41
+ Effect.acquireRelease(
42
+ Effect.try({
43
+ try: () => {
44
+ const resizeObserver = new ResizeObserver(() => chart.resize())
45
+ const onWindowResize = () => chart.resize()
46
+ const onClick = (event: unknown) => {
47
+ const maybeDatumId = datumIdFromChartClickEvent(event)
48
+
49
+ if (Option.isSome(maybeDatumId)) {
50
+ Queue.offerUnsafe(
51
+ queue,
52
+ Message.ClickedChartDatum({ datumId: maybeDatumId.value }),
53
+ )
54
+ }
55
+ }
56
+
57
+ try {
31
58
  resizeObserver.observe(element)
32
- const onWindowResize = () => chart.resize()
33
59
  window.addEventListener('resize', onWindowResize)
34
- setChart(hostId, chart)
35
- return { resizeObserver, onWindowResize }
36
- },
37
- catch: error =>
38
- error instanceof Error
39
- ? error
40
- : new Error(`Failed to mount chart: ${error}`),
41
- }),
42
- ({ resizeObserver, onWindowResize }) =>
43
- Effect.sync(() => {
44
- resizeObserver.disconnect()
60
+ chart.on('click', onClick)
61
+ return { resizeObserver, onWindowResize, onClick }
62
+ } catch (error) {
63
+ chart.off('click', onClick)
45
64
  window.removeEventListener('resize', onWindowResize)
46
- removeChart(hostId)
65
+ resizeObserver.disconnect()
66
+ throw error
67
+ }
68
+ },
69
+ catch: toChartMountError,
70
+ }),
71
+ ({ resizeObserver, onWindowResize, onClick }) =>
72
+ Effect.sync(() => {
73
+ chart.off('click', onClick)
74
+ window.removeEventListener('resize', onWindowResize)
75
+ resizeObserver.disconnect()
76
+ }),
77
+ )
78
+
79
+ const mountChart = (element: Element, hostId: string) =>
80
+ Stream.callback<MountChartMessage>(queue =>
81
+ Effect.gen(function* () {
82
+ if (!(element instanceof HTMLElement)) {
83
+ Queue.offerUnsafe(
84
+ queue,
85
+ Message.FailedMountChart({
86
+ reason: 'Chart host is not an HTMLElement.',
87
+ }),
88
+ )
89
+ return yield* Effect.never
90
+ }
91
+
92
+ const chart = yield* Effect.acquireRelease(
93
+ Effect.try({
94
+ try: () => echarts.init(element, undefined, { renderer: 'canvas' }),
95
+ catch: toChartMountError,
47
96
  }),
48
- ).pipe(
49
- Effect.map(() => Message.SucceededMountChart({ hostId })),
97
+ chart => Effect.sync(() => removeChart(hostId, chart)),
98
+ )
99
+ setChart(hostId, chart)
100
+
101
+ yield* observeChartResizeAndClickEvents(element, chart, queue)
102
+
103
+ Queue.offerUnsafe(queue, Message.SucceededMountChart({ hostId }))
104
+ return yield* Effect.never
105
+ }).pipe(
50
106
  Effect.catch(error =>
51
- Effect.succeed(Message.FailedMountChart({ reason: error.message })),
107
+ Effect.sync(() =>
108
+ Queue.offerUnsafe(
109
+ queue,
110
+ Message.FailedMountChart({ reason: error.message }),
111
+ ),
112
+ ),
52
113
  ),
53
- )
54
- })
114
+ ),
115
+ )
55
116
 
56
- export const MountChart = Mount.define('MountChart', {
117
+ export const MountChart = Mount.defineStream('MountChart', {
57
118
  args: { hostId: Schema.String },
58
- messages: [Message.SucceededMountChart, Message.FailedMountChart],
119
+ messages: [
120
+ Message.SucceededMountChart,
121
+ Message.FailedMountChart,
122
+ Message.ClickedChartDatum,
123
+ ],
59
124
  execute: ({ element, hostId }) => mountChart(element, hostId),
60
125
  })
61
126
 
@@ -79,10 +79,10 @@ export const update = (model: Model, message: Message) =>
79
79
  const TICK_INTERVAL = Duration.seconds(1)
80
80
 
81
81
  export const subscriptions = Subscription.make<Model, Message>()(_entry => ({
82
- tick: Subscription.persistent(
83
- Stream.tick(TICK_INTERVAL).pipe(Stream.map(Message.Ticked)),
82
+ tick: Subscription.persistentEntry(
83
+ Stream.tick(TICK_INTERVAL).pipe(Stream.drop(1), Stream.map(Message.Ticked)),
84
84
  ),
85
- hostStep: Port.subscription(ports.inbound.stepChanged, step =>
85
+ hostStep: Port.subscriptionEntry(ports.inbound.stepChanged, step =>
86
86
  Message.ChangedStep({ step }),
87
87
  ),
88
88
  }))
@@ -23,7 +23,7 @@ const noiseScaleSliderSubscriptions = Subscription.lift({
23
23
  })
24
24
 
25
25
  const frameSubscription = Subscription.make<Model, Message>()(_entry => ({
26
- frame: Subscription.animationFrame({
26
+ frame: Subscription.animationFrameEntry({
27
27
  isActive: model => model.isRunning,
28
28
  toMessage: deltaTimeMs => Message.TickedFrame({ deltaTimeMs }),
29
29
  }),
@@ -54,9 +54,7 @@ export const GenerateEntryId = Command.define('GenerateEntryId', {
54
54
 
55
55
  // UPDATE
56
56
 
57
- const foldEntryOutMessage: (
58
- entryId: string,
59
- ) => (outMessage: Entry.OutMessage) => Update.Step<Model, Message> = entryId =>
57
+ const foldEntryOutMessage = (entryId: string) =>
60
58
  Entry.OutMessage.match<Update.Step<Model, Message>>({
61
59
  Removed: () => model => ({
62
60
  model: modifyFields(model, {
@@ -65,18 +63,18 @@ const foldEntryOutMessage: (
65
63
  }),
66
64
  })
67
65
 
68
- const foldEntry = (entryId: string) =>
69
- Update.foldChild({
70
- update: Entry.update,
71
- read: (model: Model) =>
72
- Array.findFirst(model.entries, entry => entry.id === entryId),
73
- write: (model, nextEntry) =>
74
- modifyFields(model, {
75
- entries: Array.map(entry => (entry.id === entryId ? nextEntry : entry)),
76
- }),
77
- toParentMessage: message => Message.GotEntryMessage({ entryId, message }),
78
- foldOutMessage: foldEntryOutMessage(entryId),
79
- })
66
+ const foldEntry = Update.foldChildAt({
67
+ update: Entry.update,
68
+ readAt: (model: Model, entryId: string) =>
69
+ Array.findFirst(model.entries, entry => entry.id === entryId),
70
+ writeAt: (model, entryId, nextEntry) =>
71
+ modifyFields(model, {
72
+ entries: Array.map(entry => (entry.id === entryId ? nextEntry : entry)),
73
+ }),
74
+ toParentMessage: (entryId, message) =>
75
+ Message.GotEntryMessage({ entryId, message }),
76
+ foldOutMessage: foldEntryOutMessage,
77
+ })
80
78
 
81
79
  export const update = (model: Model, message: Message) =>
82
80
  Message.match<Update.Return<Model, Message>>(message, {
@@ -97,7 +95,7 @@ export const update = (model: Model, message: Message) =>
97
95
  }),
98
96
 
99
97
  GotEntryMessage: ({ entryId, message }) =>
100
- foldEntry(entryId)(model, message),
98
+ foldEntry(model, entryId, message),
101
99
  })
102
100
 
103
101
  // VALIDATION SUMMARY
@@ -51,9 +51,7 @@ export const GenerateEntryId = Command.define('GenerateEntryId', {
51
51
 
52
52
  // UPDATE
53
53
 
54
- const foldEntryOutMessage: (
55
- entryId: string,
56
- ) => (outMessage: Entry.OutMessage) => Update.Step<Model, Message> = entryId =>
54
+ const foldEntryOutMessage = (entryId: string) =>
57
55
  Entry.OutMessage.match<Update.Step<Model, Message>>({
58
56
  Removed: () => model => ({
59
57
  model: modifyFields(model, {
@@ -62,18 +60,18 @@ const foldEntryOutMessage: (
62
60
  }),
63
61
  })
64
62
 
65
- const foldEntry = (entryId: string) =>
66
- Update.foldChild({
67
- update: Entry.update,
68
- read: (model: Model) =>
69
- Array.findFirst(model.entries, entry => entry.id === entryId),
70
- write: (model, nextEntry) =>
71
- modifyFields(model, {
72
- entries: Array.map(entry => (entry.id === entryId ? nextEntry : entry)),
73
- }),
74
- toParentMessage: message => Message.GotEntryMessage({ entryId, message }),
75
- foldOutMessage: foldEntryOutMessage(entryId),
76
- })
63
+ const foldEntry = Update.foldChildAt({
64
+ update: Entry.update,
65
+ readAt: (model: Model, entryId: string) =>
66
+ Array.findFirst(model.entries, entry => entry.id === entryId),
67
+ writeAt: (model, entryId, nextEntry) =>
68
+ modifyFields(model, {
69
+ entries: Array.map(entry => (entry.id === entryId ? nextEntry : entry)),
70
+ }),
71
+ toParentMessage: (entryId, message) =>
72
+ Message.GotEntryMessage({ entryId, message }),
73
+ foldOutMessage: foldEntryOutMessage,
74
+ })
77
75
 
78
76
  export const update = (model: Model, message: Message) =>
79
77
  Message.match<Update.Return<Model, Message>>(message, {
@@ -94,7 +92,7 @@ export const update = (model: Model, message: Message) =>
94
92
  }),
95
93
 
96
94
  GotEntryMessage: ({ entryId, message }) =>
97
- foldEntry(entryId)(model, message),
95
+ foldEntry(model, entryId, message),
98
96
  })
99
97
 
100
98
  // VALIDATION SUMMARY
@@ -54,9 +54,7 @@ export const GenerateEntryId = Command.define('GenerateEntryId', {
54
54
 
55
55
  // UPDATE
56
56
 
57
- const foldEntryOutMessage: (
58
- entryId: string,
59
- ) => (outMessage: Entry.OutMessage) => Update.Step<Model, Message> = entryId =>
57
+ const foldEntryOutMessage = (entryId: string) =>
60
58
  Entry.OutMessage.match<Update.Step<Model, Message>>({
61
59
  Removed: () => model => ({
62
60
  model: modifyFields(model, {
@@ -65,18 +63,18 @@ const foldEntryOutMessage: (
65
63
  }),
66
64
  })
67
65
 
68
- const foldEntry = (entryId: string) =>
69
- Update.foldChild({
70
- update: Entry.update,
71
- read: (model: Model) =>
72
- Array.findFirst(model.entries, entry => entry.id === entryId),
73
- write: (model, nextEntry) =>
74
- modifyFields(model, {
75
- entries: Array.map(entry => (entry.id === entryId ? nextEntry : entry)),
76
- }),
77
- toParentMessage: message => Message.GotEntryMessage({ entryId, message }),
78
- foldOutMessage: foldEntryOutMessage(entryId),
79
- })
66
+ const foldEntry = Update.foldChildAt({
67
+ update: Entry.update,
68
+ readAt: (model: Model, entryId: string) =>
69
+ Array.findFirst(model.entries, entry => entry.id === entryId),
70
+ writeAt: (model, entryId, nextEntry) =>
71
+ modifyFields(model, {
72
+ entries: Array.map(entry => (entry.id === entryId ? nextEntry : entry)),
73
+ }),
74
+ toParentMessage: (entryId, message) =>
75
+ Message.GotEntryMessage({ entryId, message }),
76
+ foldOutMessage: foldEntryOutMessage,
77
+ })
80
78
 
81
79
  export const update = (model: Model, message: Message) =>
82
80
  Message.match<Update.Return<Model, Message>>(message, {
@@ -97,7 +95,7 @@ export const update = (model: Model, message: Message) =>
97
95
  }),
98
96
 
99
97
  GotEntryMessage: ({ entryId, message }) =>
100
- foldEntry(entryId)(model, message),
98
+ foldEntry(model, entryId, message),
101
99
  })
102
100
 
103
101
  // VALIDATION SUMMARY
@@ -1,13 +1,12 @@
1
1
  import { Runtime } from 'foldkit'
2
2
 
3
- import { Message, Model, init, subscriptions, update, view } from './main'
3
+ import { Message, Model, init, update, view } from './main'
4
4
 
5
5
  const application = Runtime.makeApplication({
6
6
  Model,
7
7
  init,
8
8
  update,
9
9
  view,
10
- subscriptions,
11
10
  container: document.getElementById('root'),
12
11
  devTools: {
13
12
  Message,