create-fluxstack 1.21.1 → 1.22.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 (55) hide show
  1. package/app/client/src/App.tsx +9 -11
  2. package/app/client/src/components/AppLayout.tsx +291 -290
  3. package/app/client/src/components/BackButton.tsx +17 -16
  4. package/app/client/src/components/ColorWheel.tsx +1 -0
  5. package/app/client/src/components/DemoPage.tsx +136 -135
  6. package/app/client/src/components/ErrorBoundary.tsx +2 -1
  7. package/app/client/src/components/LiveErrorBoundary.tsx +2 -1
  8. package/app/client/src/components/ThemePicker.tsx +1 -0
  9. package/app/client/src/framework/ClientOnly.tsx +14 -0
  10. package/app/client/src/framework/LivePage.tsx +55 -0
  11. package/app/client/src/framework/RscHomePage.tsx +125 -0
  12. package/app/client/src/framework/RscLink.tsx +31 -0
  13. package/app/client/src/framework/RscNav.tsx +42 -0
  14. package/app/client/src/framework/RscRoot.tsx +54 -0
  15. package/app/client/src/framework/csrf.ts +22 -0
  16. package/app/client/src/framework/entry.browser.tsx +51 -0
  17. package/app/client/src/framework/entry.rsc.tsx +41 -0
  18. package/app/client/src/framework/entry.ssr.tsx +12 -0
  19. package/app/client/src/framework/navigation.ts +29 -0
  20. package/app/client/src/framework/params.tsx +22 -0
  21. package/app/client/src/framework/routes.ts +143 -0
  22. package/app/client/src/framework/vite-rsc.d.ts +21 -0
  23. package/app/client/src/hooks/useThemeClock.ts +16 -1
  24. package/app/client/src/live/AuthDemo.tsx +271 -270
  25. package/app/client/src/live/CounterDemo.tsx +152 -151
  26. package/app/client/src/live/FormDemo.tsx +141 -140
  27. package/app/client/src/live/PingPongDemo.tsx +181 -180
  28. package/app/client/src/live/RoomChatDemo.tsx +398 -397
  29. package/app/client/src/live/SharedCounterDemo.tsx +2 -1
  30. package/app/client/src/pages/ApiTestPage.tsx +1 -0
  31. package/app/client/src/pages/auth.tsx +13 -0
  32. package/app/client/src/pages/blog/[slug].tsx +23 -0
  33. package/app/client/src/pages/counter.tsx +15 -0
  34. package/app/client/src/pages/form.tsx +13 -0
  35. package/app/client/src/pages/index.tsx +9 -0
  36. package/app/client/src/pages/ping-pong.tsx +13 -0
  37. package/app/client/src/pages/room-chat.tsx +13 -0
  38. package/app/client/src/pages/shared-counter.tsx +13 -0
  39. package/app/client/src/pages/sobre.tsx +19 -0
  40. package/app/server/index.ts +5 -1
  41. package/app/server/live/rooms/index.ts +14 -0
  42. package/config/system/plugins.config.ts +12 -2
  43. package/core/cli/commands/dev.ts +9 -1
  44. package/core/plugins/built-in/rsc/index.ts +156 -0
  45. package/core/plugins/built-in/ssr/bun-asset-loader.ts +46 -0
  46. package/core/plugins/built-in/ssr/index.ts +143 -0
  47. package/core/plugins/built-in/ssr/registry.ts +38 -0
  48. package/core/plugins/built-in/vite/index.ts +7 -0
  49. package/core/server/live/websocket-plugin.ts +29 -10
  50. package/core/utils/version.ts +6 -6
  51. package/create-fluxstack.ts +64 -5
  52. package/package.json +112 -108
  53. package/playwright.config.ts +6 -2
  54. package/vite.config.ts +22 -0
  55. package/app/client/.live-stubs/LiveUpload.js +0 -15
@@ -1,151 +1,152 @@
1
- import { useMemo } from 'react'
2
- import { Live } from '@/core/client'
3
- import { LiveCounter } from '@server/live/LiveCounter'
4
- import { LiveLocalCounter } from '@server/live/LiveLocalCounter'
5
- import { FaMinus, FaPlus, FaRotateRight, FaUsers } from 'react-icons/fa6'
6
-
7
- type CounterProxy = ReturnType<typeof Live.use>
8
-
9
- function ConnectionPill({ connected }: { connected: boolean }) {
10
- return (
11
- <span className={`inline-flex items-center gap-2 rounded-full border px-2.5 py-1 text-xs ${
12
- connected
13
- ? 'border-emerald-400/25 bg-emerald-400/10 text-emerald-200'
14
- : 'border-red-400/25 bg-red-400/10 text-red-200'
15
- }`}>
16
- <span className={`h-1.5 w-1.5 rounded-full ${connected ? 'bg-emerald-300' : 'bg-red-300'}`} />
17
- {connected ? 'Connected' : 'Offline'}
18
- </span>
19
- )
20
- }
21
-
22
- function CounterCard({
23
- title,
24
- description,
25
- mode,
26
- counter,
27
- accent = 'theme',
28
- }: {
29
- title: string
30
- description: string
31
- mode: string
32
- counter: CounterProxy
33
- accent?: 'theme' | 'warm'
34
- }) {
35
- const valueClass = accent === 'warm'
36
- ? 'bg-gradient-to-r from-amber-300 via-orange-300 to-rose-300'
37
- : 'bg-theme-gradient'
38
-
39
- return (
40
- <article className="flex min-h-[430px] w-full flex-col rounded-lg border border-white/10 bg-[#07070b]/85 p-5 shadow-2xl shadow-black/20">
41
- <div className="flex items-start justify-between gap-4">
42
- <div>
43
- <span className="rounded-full border border-white/10 bg-white/[0.03] px-2.5 py-1 text-xs text-gray-400">
44
- {mode}
45
- </span>
46
- <h2 className="mt-4 text-xl font-semibold tracking-tight text-white">{title}</h2>
47
- <p className="mt-2 text-sm leading-6 text-gray-500">{description}</p>
48
- </div>
49
- {'$connected' in counter && <ConnectionPill connected={counter.$connected} />}
50
- </div>
51
-
52
- <div className="flex flex-1 items-center justify-center py-8">
53
- <div className={`bg-clip-text text-7xl font-semibold tabular-nums tracking-tight text-transparent sm:text-8xl ${valueClass}`}>
54
- {counter.$state.count}
55
- </div>
56
- </div>
57
-
58
- {'connectedUsers' in counter.$state && (
59
- <div className="mb-5 flex items-center justify-between rounded-lg border border-white/10 bg-white/[0.025] px-3 py-2 text-xs text-gray-400">
60
- <span className="inline-flex items-center gap-2">
61
- <FaUsers className="text-theme" />
62
- Users in room
63
- </span>
64
- <span className="font-mono text-white">{counter.$state.connectedUsers}</span>
65
- </div>
66
- )}
67
-
68
- {counter.$state.lastUpdatedBy && (
69
- <p className="mb-5 truncate text-xs text-gray-500">
70
- Last update: <span className="text-gray-300">{counter.$state.lastUpdatedBy}</span>
71
- </p>
72
- )}
73
-
74
- <div className="grid grid-cols-[56px_1fr_56px] gap-3">
75
- <button
76
- onClick={() => counter.decrement()}
77
- disabled={counter.$loading}
78
- className="flex h-12 items-center justify-center rounded-lg border border-white/10 bg-white/[0.03] text-gray-200 transition hover:bg-white/[0.07] disabled:opacity-50"
79
- aria-label={`Decrease ${title}`}
80
- >
81
- <FaMinus />
82
- </button>
83
- <button
84
- onClick={() => counter.reset()}
85
- disabled={counter.$loading}
86
- className="inline-flex h-12 items-center justify-center gap-2 rounded-lg border border-theme-active bg-theme-muted px-4 text-sm font-semibold text-theme transition hover:shadow-theme disabled:opacity-50"
87
- >
88
- <FaRotateRight className="h-3.5 w-3.5" />
89
- Reset
90
- </button>
91
- <button
92
- onClick={() => counter.increment()}
93
- disabled={counter.$loading}
94
- className="flex h-12 items-center justify-center rounded-lg bg-white text-black transition hover:bg-gray-200 disabled:opacity-50"
95
- aria-label={`Increase ${title}`}
96
- >
97
- <FaPlus />
98
- </button>
99
- </div>
100
- </article>
101
- )
102
- }
103
-
104
- export function CounterDemo() {
105
- const isolatedRoom = useMemo(() => {
106
- if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
107
- return `local-${crypto.randomUUID()}`
108
- }
109
- return `local-${Math.random().toString(36).slice(2)}`
110
- }, [])
111
-
112
- const localCounter = Live.use(LiveLocalCounter, {
113
- initialState: LiveLocalCounter.defaultState,
114
- persistState: false,
115
- })
116
-
117
- const isolatedCounter = Live.use(LiveCounter, {
118
- room: isolatedRoom,
119
- initialState: LiveCounter.defaultState,
120
- persistState: false,
121
- })
122
-
123
- const sharedCounter = Live.use(LiveCounter, {
124
- room: 'global-counter',
125
- initialState: LiveCounter.defaultState,
126
- })
127
-
128
- return (
129
- <div className="grid w-full gap-4 lg:grid-cols-3">
130
- <CounterCard
131
- title="Local state"
132
- description="A component-local counter for quick state updates without a shared room."
133
- mode="No room"
134
- counter={localCounter}
135
- accent="warm"
136
- />
137
- <CounterCard
138
- title="Isolated room"
139
- description="A private room per tab. The server owns the state, but the session is isolated."
140
- mode="Private room"
141
- counter={isolatedCounter}
142
- />
143
- <CounterCard
144
- title="Shared room"
145
- description="One global room. Open another tab and every client sees the same counter."
146
- mode="Global room"
147
- counter={sharedCounter}
148
- />
149
- </div>
150
- )
151
- }
1
+ 'use client'
2
+ import { useMemo } from 'react'
3
+ import { Live } from '@/core/client'
4
+ import { LiveCounter } from '@server/live/LiveCounter'
5
+ import { LiveLocalCounter } from '@server/live/LiveLocalCounter'
6
+ import { FaMinus, FaPlus, FaRotateRight, FaUsers } from 'react-icons/fa6'
7
+
8
+ type CounterProxy = ReturnType<typeof Live.use>
9
+
10
+ function ConnectionPill({ connected }: { connected: boolean }) {
11
+ return (
12
+ <span className={`inline-flex items-center gap-2 rounded-full border px-2.5 py-1 text-xs ${
13
+ connected
14
+ ? 'border-emerald-400/25 bg-emerald-400/10 text-emerald-200'
15
+ : 'border-red-400/25 bg-red-400/10 text-red-200'
16
+ }`}>
17
+ <span className={`h-1.5 w-1.5 rounded-full ${connected ? 'bg-emerald-300' : 'bg-red-300'}`} />
18
+ {connected ? 'Connected' : 'Offline'}
19
+ </span>
20
+ )
21
+ }
22
+
23
+ function CounterCard({
24
+ title,
25
+ description,
26
+ mode,
27
+ counter,
28
+ accent = 'theme',
29
+ }: {
30
+ title: string
31
+ description: string
32
+ mode: string
33
+ counter: CounterProxy
34
+ accent?: 'theme' | 'warm'
35
+ }) {
36
+ const valueClass = accent === 'warm'
37
+ ? 'bg-gradient-to-r from-amber-300 via-orange-300 to-rose-300'
38
+ : 'bg-theme-gradient'
39
+
40
+ return (
41
+ <article className="flex min-h-[430px] w-full flex-col rounded-lg border border-white/10 bg-[#07070b]/85 p-5 shadow-2xl shadow-black/20">
42
+ <div className="flex items-start justify-between gap-4">
43
+ <div>
44
+ <span className="rounded-full border border-white/10 bg-white/[0.03] px-2.5 py-1 text-xs text-gray-400">
45
+ {mode}
46
+ </span>
47
+ <h2 className="mt-4 text-xl font-semibold tracking-tight text-white">{title}</h2>
48
+ <p className="mt-2 text-sm leading-6 text-gray-500">{description}</p>
49
+ </div>
50
+ {'$connected' in counter && <ConnectionPill connected={counter.$connected} />}
51
+ </div>
52
+
53
+ <div className="flex flex-1 items-center justify-center py-8">
54
+ <div className={`bg-clip-text text-7xl font-semibold tabular-nums tracking-tight text-transparent sm:text-8xl ${valueClass}`}>
55
+ {counter.$state.count}
56
+ </div>
57
+ </div>
58
+
59
+ {'connectedUsers' in counter.$state && (
60
+ <div className="mb-5 flex items-center justify-between rounded-lg border border-white/10 bg-white/[0.025] px-3 py-2 text-xs text-gray-400">
61
+ <span className="inline-flex items-center gap-2">
62
+ <FaUsers className="text-theme" />
63
+ Users in room
64
+ </span>
65
+ <span className="font-mono text-white">{counter.$state.connectedUsers}</span>
66
+ </div>
67
+ )}
68
+
69
+ {counter.$state.lastUpdatedBy && (
70
+ <p className="mb-5 truncate text-xs text-gray-500">
71
+ Last update: <span className="text-gray-300">{counter.$state.lastUpdatedBy}</span>
72
+ </p>
73
+ )}
74
+
75
+ <div className="grid grid-cols-[56px_1fr_56px] gap-3">
76
+ <button
77
+ onClick={() => counter.decrement()}
78
+ disabled={counter.$loading}
79
+ className="flex h-12 items-center justify-center rounded-lg border border-white/10 bg-white/[0.03] text-gray-200 transition hover:bg-white/[0.07] disabled:opacity-50"
80
+ aria-label={`Decrease ${title}`}
81
+ >
82
+ <FaMinus />
83
+ </button>
84
+ <button
85
+ onClick={() => counter.reset()}
86
+ disabled={counter.$loading}
87
+ className="inline-flex h-12 items-center justify-center gap-2 rounded-lg border border-theme-active bg-theme-muted px-4 text-sm font-semibold text-theme transition hover:shadow-theme disabled:opacity-50"
88
+ >
89
+ <FaRotateRight className="h-3.5 w-3.5" />
90
+ Reset
91
+ </button>
92
+ <button
93
+ onClick={() => counter.increment()}
94
+ disabled={counter.$loading}
95
+ className="flex h-12 items-center justify-center rounded-lg bg-white text-black transition hover:bg-gray-200 disabled:opacity-50"
96
+ aria-label={`Increase ${title}`}
97
+ >
98
+ <FaPlus />
99
+ </button>
100
+ </div>
101
+ </article>
102
+ )
103
+ }
104
+
105
+ export function CounterDemo() {
106
+ const isolatedRoom = useMemo(() => {
107
+ if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
108
+ return `local-${crypto.randomUUID()}`
109
+ }
110
+ return `local-${Math.random().toString(36).slice(2)}`
111
+ }, [])
112
+
113
+ const localCounter = Live.use(LiveLocalCounter, {
114
+ initialState: LiveLocalCounter.defaultState,
115
+ persistState: false,
116
+ })
117
+
118
+ const isolatedCounter = Live.use(LiveCounter, {
119
+ room: isolatedRoom,
120
+ initialState: LiveCounter.defaultState,
121
+ persistState: false,
122
+ })
123
+
124
+ const sharedCounter = Live.use(LiveCounter, {
125
+ room: 'global-counter',
126
+ initialState: LiveCounter.defaultState,
127
+ })
128
+
129
+ return (
130
+ <div className="grid w-full gap-4 lg:grid-cols-3">
131
+ <CounterCard
132
+ title="Local state"
133
+ description="A component-local counter for quick state updates without a shared room."
134
+ mode="No room"
135
+ counter={localCounter}
136
+ accent="warm"
137
+ />
138
+ <CounterCard
139
+ title="Isolated room"
140
+ description="A private room per tab. The server owns the state, but the session is isolated."
141
+ mode="Private room"
142
+ counter={isolatedCounter}
143
+ />
144
+ <CounterCard
145
+ title="Shared room"
146
+ description="One global room. Open another tab and every client sees the same counter."
147
+ mode="Global room"
148
+ counter={sharedCounter}
149
+ />
150
+ </div>
151
+ )
152
+ }
@@ -1,140 +1,141 @@
1
- import { Live } from '@/core/client'
2
- import { LiveForm } from '@server/live/LiveForm'
3
- import { FaCheck, FaCode, FaEnvelope, FaRegMessage, FaUser } from 'react-icons/fa6'
4
-
5
- function FieldHint({ children }: { children: string }) {
6
- return (
7
- <span className="rounded-full border border-white/10 bg-white/[0.03] px-2 py-0.5 text-[11px] text-gray-500">
8
- {children}
9
- </span>
10
- )
11
- }
12
-
13
- export function FormDemo() {
14
- const form = Live.use(LiveForm)
15
-
16
- if (form.submitted) {
17
- return (
18
- <div className="w-full max-w-2xl rounded-lg border border-emerald-400/20 bg-emerald-400/10 p-6 text-center shadow-2xl shadow-black/20">
19
- <div className="mx-auto mb-5 flex h-12 w-12 items-center justify-center rounded-lg bg-emerald-400 text-black">
20
- <FaCheck />
21
- </div>
22
- <h2 className="text-2xl font-semibold text-white">Message received</h2>
23
- <p className="mt-2 text-sm leading-6 text-gray-300">
24
- Thanks, <span className="text-emerald-200">{form.name || 'there'}</span>. The server state was updated and submitted.
25
- </p>
26
- <p className="mt-3 text-xs text-gray-500">
27
- Submitted at {form.submittedAt ? new Date(form.submittedAt).toLocaleString() : '-'}
28
- </p>
29
- <button onClick={() => form.reset()} className="mt-6 h-11 rounded-lg bg-white px-5 text-sm font-semibold text-black transition hover:bg-gray-200">
30
- Start another form
31
- </button>
32
- </div>
33
- )
34
- }
35
-
36
- return (
37
- <div className="grid w-full max-w-5xl gap-4 lg:grid-cols-[1fr_360px]">
38
- <section className="rounded-lg border border-white/10 bg-[#07070b]/85 p-5 shadow-2xl shadow-black/20 sm:p-6">
39
- <div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
40
- <div>
41
- <h2 className="text-2xl font-semibold tracking-tight text-white">Contact workflow</h2>
42
- <p className="mt-2 text-sm leading-6 text-gray-500">
43
- Fields sync with the server using different strategies so the UI stays responsive.
44
- </p>
45
- </div>
46
- <span className={`inline-flex w-fit items-center gap-2 rounded-full border px-3 py-1 text-xs ${
47
- form.$connected
48
- ? 'border-emerald-400/25 bg-emerald-400/10 text-emerald-200'
49
- : 'border-red-400/25 bg-red-400/10 text-red-200'
50
- }`}>
51
- <span className={`h-1.5 w-1.5 rounded-full ${form.$connected ? 'bg-emerald-300' : 'bg-red-300'}`} />
52
- {form.$connected ? 'Connected' : 'Offline'}
53
- </span>
54
- </div>
55
-
56
- <div className="space-y-4">
57
- <label className="block">
58
- <div className="mb-2 flex items-center justify-between gap-3">
59
- <span className="inline-flex items-center gap-2 text-sm font-medium text-gray-200">
60
- <FaUser className="text-theme" />
61
- Name
62
- </span>
63
- <FieldHint>sync on blur</FieldHint>
64
- </div>
65
- <input
66
- {...form.$field('name', { syncOn: 'blur' })}
67
- placeholder="Ada Lovelace"
68
- className="w-full input-theme"
69
- />
70
- </label>
71
-
72
- <label className="block">
73
- <div className="mb-2 flex items-center justify-between gap-3">
74
- <span className="inline-flex items-center gap-2 text-sm font-medium text-gray-200">
75
- <FaEnvelope className="text-theme-secondary" />
76
- Email
77
- </span>
78
- <FieldHint>debounce 500ms</FieldHint>
79
- </div>
80
- <input
81
- {...form.$field('email', { syncOn: 'change', debounce: 500 })}
82
- type="email"
83
- placeholder="ada@company.dev"
84
- className="w-full input-theme"
85
- />
86
- </label>
87
-
88
- <label className="block">
89
- <div className="mb-2 flex items-center justify-between gap-3">
90
- <span className="inline-flex items-center gap-2 text-sm font-medium text-gray-200">
91
- <FaRegMessage className="text-theme" />
92
- Message
93
- </span>
94
- <FieldHint>sync on blur</FieldHint>
95
- </div>
96
- <textarea
97
- {...form.$field('message', { syncOn: 'blur' })}
98
- rows={5}
99
- placeholder="Tell us what you want to build..."
100
- className="w-full resize-none input-theme"
101
- />
102
- </label>
103
-
104
- <div className="flex flex-col gap-3 pt-2 sm:flex-row">
105
- <button
106
- onClick={async () => {
107
- try {
108
- await form.$sync()
109
- await form.submit()
110
- } catch (err: any) {
111
- alert(err.message || 'Submit failed')
112
- }
113
- }}
114
- disabled={!form.$connected || form.$loading}
115
- className="h-11 flex-1 rounded-lg bg-white px-5 text-sm font-semibold text-black transition hover:bg-gray-200 disabled:opacity-50"
116
- >
117
- {form.$loading ? 'Sending...' : 'Submit'}
118
- </button>
119
- <button
120
- onClick={() => form.reset()}
121
- className="h-11 rounded-lg border border-white/10 bg-white/[0.03] px-5 text-sm font-semibold text-white transition hover:bg-white/[0.06]"
122
- >
123
- Clear
124
- </button>
125
- </div>
126
- </div>
127
- </section>
128
-
129
- <aside className="rounded-lg border border-white/10 bg-black/30 p-5">
130
- <div className="mb-4 flex items-center gap-2 text-sm font-semibold text-white">
131
- <FaCode className="text-theme" />
132
- Server state
133
- </div>
134
- <pre className="max-h-[460px] overflow-auto rounded-lg border border-white/10 bg-black/50 p-4 text-xs leading-6 text-emerald-300">
135
- <code>{JSON.stringify(form.$state, null, 2)}</code>
136
- </pre>
137
- </aside>
138
- </div>
139
- )
140
- }
1
+ 'use client'
2
+ import { Live } from '@/core/client'
3
+ import { LiveForm } from '@server/live/LiveForm'
4
+ import { FaCheck, FaCode, FaEnvelope, FaRegMessage, FaUser } from 'react-icons/fa6'
5
+
6
+ function FieldHint({ children }: { children: string }) {
7
+ return (
8
+ <span className="rounded-full border border-white/10 bg-white/[0.03] px-2 py-0.5 text-[11px] text-gray-500">
9
+ {children}
10
+ </span>
11
+ )
12
+ }
13
+
14
+ export function FormDemo() {
15
+ const form = Live.use(LiveForm)
16
+
17
+ if (form.submitted) {
18
+ return (
19
+ <div className="w-full max-w-2xl rounded-lg border border-emerald-400/20 bg-emerald-400/10 p-6 text-center shadow-2xl shadow-black/20">
20
+ <div className="mx-auto mb-5 flex h-12 w-12 items-center justify-center rounded-lg bg-emerald-400 text-black">
21
+ <FaCheck />
22
+ </div>
23
+ <h2 className="text-2xl font-semibold text-white">Message received</h2>
24
+ <p className="mt-2 text-sm leading-6 text-gray-300">
25
+ Thanks, <span className="text-emerald-200">{form.name || 'there'}</span>. The server state was updated and submitted.
26
+ </p>
27
+ <p className="mt-3 text-xs text-gray-500">
28
+ Submitted at {form.submittedAt ? new Date(form.submittedAt).toLocaleString() : '-'}
29
+ </p>
30
+ <button onClick={() => form.reset()} className="mt-6 h-11 rounded-lg bg-white px-5 text-sm font-semibold text-black transition hover:bg-gray-200">
31
+ Start another form
32
+ </button>
33
+ </div>
34
+ )
35
+ }
36
+
37
+ return (
38
+ <div className="grid w-full max-w-5xl gap-4 lg:grid-cols-[1fr_360px]">
39
+ <section className="rounded-lg border border-white/10 bg-[#07070b]/85 p-5 shadow-2xl shadow-black/20 sm:p-6">
40
+ <div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
41
+ <div>
42
+ <h2 className="text-2xl font-semibold tracking-tight text-white">Contact workflow</h2>
43
+ <p className="mt-2 text-sm leading-6 text-gray-500">
44
+ Fields sync with the server using different strategies so the UI stays responsive.
45
+ </p>
46
+ </div>
47
+ <span className={`inline-flex w-fit items-center gap-2 rounded-full border px-3 py-1 text-xs ${
48
+ form.$connected
49
+ ? 'border-emerald-400/25 bg-emerald-400/10 text-emerald-200'
50
+ : 'border-red-400/25 bg-red-400/10 text-red-200'
51
+ }`}>
52
+ <span className={`h-1.5 w-1.5 rounded-full ${form.$connected ? 'bg-emerald-300' : 'bg-red-300'}`} />
53
+ {form.$connected ? 'Connected' : 'Offline'}
54
+ </span>
55
+ </div>
56
+
57
+ <div className="space-y-4">
58
+ <label className="block">
59
+ <div className="mb-2 flex items-center justify-between gap-3">
60
+ <span className="inline-flex items-center gap-2 text-sm font-medium text-gray-200">
61
+ <FaUser className="text-theme" />
62
+ Name
63
+ </span>
64
+ <FieldHint>sync on blur</FieldHint>
65
+ </div>
66
+ <input
67
+ {...form.$field('name', { syncOn: 'blur' })}
68
+ placeholder="Ada Lovelace"
69
+ className="w-full input-theme"
70
+ />
71
+ </label>
72
+
73
+ <label className="block">
74
+ <div className="mb-2 flex items-center justify-between gap-3">
75
+ <span className="inline-flex items-center gap-2 text-sm font-medium text-gray-200">
76
+ <FaEnvelope className="text-theme-secondary" />
77
+ Email
78
+ </span>
79
+ <FieldHint>debounce 500ms</FieldHint>
80
+ </div>
81
+ <input
82
+ {...form.$field('email', { syncOn: 'change', debounce: 500 })}
83
+ type="email"
84
+ placeholder="ada@company.dev"
85
+ className="w-full input-theme"
86
+ />
87
+ </label>
88
+
89
+ <label className="block">
90
+ <div className="mb-2 flex items-center justify-between gap-3">
91
+ <span className="inline-flex items-center gap-2 text-sm font-medium text-gray-200">
92
+ <FaRegMessage className="text-theme" />
93
+ Message
94
+ </span>
95
+ <FieldHint>sync on blur</FieldHint>
96
+ </div>
97
+ <textarea
98
+ {...form.$field('message', { syncOn: 'blur' })}
99
+ rows={5}
100
+ placeholder="Tell us what you want to build..."
101
+ className="w-full resize-none input-theme"
102
+ />
103
+ </label>
104
+
105
+ <div className="flex flex-col gap-3 pt-2 sm:flex-row">
106
+ <button
107
+ onClick={async () => {
108
+ try {
109
+ await form.$sync()
110
+ await form.submit()
111
+ } catch (err: any) {
112
+ alert(err.message || 'Submit failed')
113
+ }
114
+ }}
115
+ disabled={!form.$connected || form.$loading}
116
+ className="h-11 flex-1 rounded-lg bg-white px-5 text-sm font-semibold text-black transition hover:bg-gray-200 disabled:opacity-50"
117
+ >
118
+ {form.$loading ? 'Sending...' : 'Submit'}
119
+ </button>
120
+ <button
121
+ onClick={() => form.reset()}
122
+ className="h-11 rounded-lg border border-white/10 bg-white/[0.03] px-5 text-sm font-semibold text-white transition hover:bg-white/[0.06]"
123
+ >
124
+ Clear
125
+ </button>
126
+ </div>
127
+ </div>
128
+ </section>
129
+
130
+ <aside className="rounded-lg border border-white/10 bg-black/30 p-5">
131
+ <div className="mb-4 flex items-center gap-2 text-sm font-semibold text-white">
132
+ <FaCode className="text-theme" />
133
+ Server state
134
+ </div>
135
+ <pre className="max-h-[460px] overflow-auto rounded-lg border border-white/10 bg-black/50 p-4 text-xs leading-6 text-emerald-300">
136
+ <code>{JSON.stringify(form.$state, null, 2)}</code>
137
+ </pre>
138
+ </aside>
139
+ </div>
140
+ )
141
+ }