create-hyper-effect 0.0.0-stage → 0.1.0-alpha.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.
@@ -0,0 +1,76 @@
1
+ import { chromium } from 'playwright'
2
+
3
+ /* Browser check: two people on the task list, in Chromium, against a running
4
+ server (npm start). Extend it with every feature you add; unit tests do
5
+ not see what only a browser does (morphs, focus, typing while the page
6
+ updates).
7
+
8
+ Usage: node scripts/check.mjs (URL=… for another server) */
9
+
10
+ const base = process.env.URL ?? 'http://localhost:4000'
11
+ const results = []
12
+ const check = (name, ok, detail = '') => {
13
+ results.push({ name, ok })
14
+ console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` (${detail})` : ''}`)
15
+ }
16
+ const until = (page, predicate, arg) =>
17
+ page.waitForFunction(predicate, arg, { timeout: 5000 }).then(() => true, () => false)
18
+
19
+ const browser = await chromium.launch()
20
+ const signIn = async name => {
21
+ const page = await (await browser.newContext()).newPage()
22
+ const errors = []
23
+ page.on('pageerror', error => errors.push(String(error)))
24
+ page.on('console', message => {
25
+ if (message.type() === 'error') errors.push(message.text())
26
+ })
27
+ await page.goto(`${base}/`)
28
+ await page.getByLabel('Your name').fill(name)
29
+ const stream = page.waitForRequest(request => request.url().includes('/stream'))
30
+ await page.getByRole('button', { name: 'Sign in' }).click()
31
+ await stream
32
+ return { page, errors }
33
+ }
34
+
35
+ try {
36
+ const ada = await signIn('Ada')
37
+ const bob = await signIn('Bob')
38
+ check('signing in shows the task list', (await ada.page.locator('#task-list li').count()) >= 1)
39
+
40
+ // Type and press Enter at once: the typed text travels with the submit.
41
+ const title = `Check task ${Date.now() % 10000}`
42
+ await ada.page.getByLabel('New task').pressSequentially(title, { delay: 5 })
43
+ await ada.page.getByLabel('New task').press('Enter')
44
+ check('a task typed and submitted at once is added', await until(ada.page, text => document.querySelector('#task-list')?.textContent?.includes(text), title))
45
+ check('the form is cleared after adding', await until(ada.page, () => document.querySelector('#task-title')?.value === ''))
46
+ check("the other person's page shows it live", await until(bob.page, text => document.querySelector('#task-list')?.textContent?.includes(text), title))
47
+
48
+ // A page-wide shortcut: N focuses the new-task input (a key binding, then
49
+ // a forwarded focus).
50
+ await ada.page.locator('body').click({ position: { x: 5, y: 300 } })
51
+ await ada.page.keyboard.press('n')
52
+ check('N focuses the new-task input', await until(ada.page, () => document.activeElement?.id === 'task-title'))
53
+ await ada.page.keyboard.type('x')
54
+ check('typing there does not trigger the shortcut again', (await ada.page.getByLabel('New task').inputValue()) === 'x')
55
+ await ada.page.getByLabel('New task').fill('')
56
+
57
+ await ada.page.getByRole('button', { name: 'Add', exact: true }).click()
58
+ check('an empty title is refused with a message', await until(ada.page, () => document.querySelector('#new-task')?.textContent?.includes('Describe the task.')))
59
+
60
+ await bob.page.getByRole('button', { name: `Done: ${title}` }).click()
61
+ check('marking it done shows on both pages', await until(ada.page, text => document.querySelector(`[aria-label="Done: ${text}"]`)?.getAttribute('aria-pressed') === 'true', title))
62
+
63
+ await ada.page.getByRole('button', { name: 'Done', exact: true }).click()
64
+ check('the Done filter shows done tasks only', await until(ada.page, () => [...document.querySelectorAll('#task-list li')].every(li => li.textContent.includes('✓'))))
65
+ await ada.page.getByRole('button', { name: 'All', exact: true }).click()
66
+
67
+ await ada.page.getByRole('button', { name: `Remove ${title}` }).click()
68
+ check('removing it removes it everywhere', await until(bob.page, text => !document.querySelector('#task-list')?.textContent?.includes(text), title))
69
+ check('no page errors', ada.errors.length + bob.errors.length === 0, [...ada.errors, ...bob.errors].slice(0, 2).join(' | '))
70
+ } finally {
71
+ await browser.close()
72
+ }
73
+
74
+ const failed = results.filter(result => !result.ok).length
75
+ console.log(`\n${results.length - failed}/${results.length} checks passed`)
76
+ process.exit(failed === 0 ? 0 : 1)
@@ -0,0 +1,132 @@
1
+ import { createHmac, timingSafeEqual } from 'node:crypto'
2
+
3
+ import { Effect, Layer } from 'effect'
4
+ import { HttpRouter, HttpServerRequest, HttpServerResponse } from 'effect/http'
5
+
6
+ import { PageRedirect, type PageRequest, type RequestContext, secretFromEnvironment } from '@hyper-effect/core'
7
+
8
+ /* Demo sign-in: any name, no password. The name travels in an HMAC-signed,
9
+ HttpOnly cookie:
10
+ - Flags read it (`requireUser`), so a signed-out visit goes to /login;
11
+ - `principal` binds every session to it, so signing out ends the page.
12
+ A real app verifies its own session or token here instead; keep the same
13
+ three exports. */
14
+
15
+ const USER_COOKIE = '{{name}}-user'
16
+ const SECRET = secretFromEnvironment('APP_SECRET', 'dev-secret, set APP_SECRET')
17
+ const MAX_AGE = 60 * 60 * 12
18
+
19
+ const signature = (name: string) => createHmac('sha256', SECRET).update(name).digest()
20
+
21
+ /** The cookie value for a signed-in user (exported for tests). */
22
+ export const cookieValue = (name: string) =>
23
+ `${Buffer.from(name, 'utf8').toString('base64url')}.${signature(name).toString('base64url')}`
24
+
25
+ /** The signed-in user's name, if the cookie is valid. */
26
+ export const signedInUser = (cookies: RequestContext['cookies']): string | undefined => {
27
+ const [encodedName, encodedSignature] = (cookies[USER_COOKIE] ?? '').split('.')
28
+ if (encodedName === undefined || encodedSignature === undefined) return undefined
29
+ const name = Buffer.from(encodedName, 'base64url').toString('utf8')
30
+ const expected = signature(name)
31
+ const actual = Buffer.from(encodedSignature, 'base64url')
32
+ return actual.length === expected.length && timingSafeEqual(actual, expected) ? name : undefined
33
+ }
34
+
35
+ /** For `Hyper.layer`: sessions belong to the signed-in user. */
36
+ export const principal = ({ cookies }: RequestContext) => Effect.succeed(signedInUser(cookies))
37
+
38
+ /** For Flags: the signed-in user, or a redirect to /login and back. */
39
+ export const requireUser = ({ cookies, url }: PageRequest) => {
40
+ const user = signedInUser(cookies)
41
+ return user === undefined
42
+ ? Effect.fail(new PageRedirect({ location: `/login?next=${encodeURIComponent(url.pathname + url.search)}` }))
43
+ : Effect.succeed(user)
44
+ }
45
+
46
+ export const USER_COOKIE_NAME = USER_COOKIE
47
+
48
+ const safeNext = (next: string | null): string =>
49
+ next !== null && next.startsWith('/') && !next.startsWith('//') && !next.startsWith('/\\') ? next : '/'
50
+
51
+ const escapeHtml = (value: string) =>
52
+ value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
53
+
54
+ const loginPage = (next: string, error: string | undefined) => `<!doctype html>
55
+ <html lang="en">
56
+ <head>
57
+ <meta charset="utf-8">
58
+ <meta name="viewport" content="width=device-width, initial-scale=1">
59
+ <title>Sign in · {{title}}</title>
60
+ <link rel="stylesheet" href="/styles.css">
61
+ </head>
62
+ <body class="flex min-h-screen items-center justify-center bg-muted/40 p-4">
63
+ <form method="post" action="/login" class="flex w-full max-w-sm flex-col gap-4 rounded-xl border bg-card p-6 text-card-foreground shadow-sm">
64
+ <h1 class="text-lg font-semibold">{{title}}</h1>
65
+ <input type="hidden" name="next" value="${escapeHtml(next)}">
66
+ <label for="name" class="text-sm font-medium">Your name</label>
67
+ <input id="name" name="name" required maxlength="32" autocomplete="username" autofocus class="h-9 rounded-md border bg-transparent px-3 text-sm shadow-xs outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50">
68
+ ${error === undefined ? '' : `<p class="text-sm text-destructive">${escapeHtml(error)}</p>`}
69
+ <button type="submit" class="h-9 rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground hover:bg-primary/90">Sign in</button>
70
+ </form>
71
+ </body>
72
+ </html>`
73
+
74
+ const html = (body: string, status = 200) =>
75
+ HttpServerResponse.text(body, {
76
+ status,
77
+ contentType: 'text/html',
78
+ headers: { 'content-security-policy': "default-src 'self'; frame-ancestors 'none'; form-action 'self'" },
79
+ })
80
+
81
+ /** Sign-in and sign-out posts must come from this site. */
82
+ const isSameOrigin = (request: HttpServerRequest.HttpServerRequest) => {
83
+ const origin = request.headers['origin']
84
+ if (origin === undefined) return request.headers['sec-fetch-site'] !== 'cross-site'
85
+ try {
86
+ return new URL(origin).host === request.headers['host']
87
+ } catch {
88
+ return false
89
+ }
90
+ }
91
+
92
+ const cookie = (request: HttpServerRequest.HttpServerRequest, value: string, maxAge: number) =>
93
+ `${USER_COOKIE}=${value}; Path=/; Max-Age=${maxAge}; SameSite=Lax; HttpOnly${
94
+ request.headers['x-forwarded-proto'] === 'https' ? '; Secure' : ''
95
+ }`
96
+
97
+ export const routes = Layer.mergeAll(
98
+ HttpRouter.add(
99
+ 'GET',
100
+ '/login',
101
+ Effect.gen(function* () {
102
+ const request = yield* HttpServerRequest.HttpServerRequest
103
+ return html(loginPage(safeNext(new URL(request.url, 'http://localhost').searchParams.get('next')), undefined))
104
+ }),
105
+ ),
106
+ HttpRouter.add(
107
+ 'POST',
108
+ '/login',
109
+ Effect.gen(function* () {
110
+ const request = yield* HttpServerRequest.HttpServerRequest
111
+ if (!isSameOrigin(request)) return HttpServerResponse.empty({ status: 403 })
112
+ const form = new URLSearchParams(yield* request.text)
113
+ const name = (form.get('name') ?? '').trim()
114
+ const next = safeNext(form.get('next'))
115
+ if (name.length === 0 || name.length > 32) return html(loginPage(next, 'Enter a name of up to 32 characters.'), 400)
116
+ return HttpServerResponse.redirect(next, { status: 303 }).pipe(
117
+ HttpServerResponse.setHeader('set-cookie', cookie(request, cookieValue(name), MAX_AGE)),
118
+ )
119
+ }).pipe(Effect.catch(() => Effect.succeed(HttpServerResponse.empty({ status: 400 })))),
120
+ ),
121
+ HttpRouter.add(
122
+ 'POST',
123
+ '/logout',
124
+ Effect.gen(function* () {
125
+ const request = yield* HttpServerRequest.HttpServerRequest
126
+ if (!isSameOrigin(request)) return HttpServerResponse.empty({ status: 403 })
127
+ return HttpServerResponse.redirect('/login', { status: 303 }).pipe(
128
+ HttpServerResponse.setHeader('set-cookie', cookie(request, '', 0)),
129
+ )
130
+ }),
131
+ ),
132
+ )
@@ -0,0 +1,408 @@
1
+ import { Effect, Match as M, Schema as S, SchemaIssue, Stream } from 'effect'
2
+ import { Command, Subscription, type Update } from 'foldkit'
3
+ import * as Dom from 'foldkit/dom'
4
+ import type { Document, Html, HtmlBuilder } from 'foldkit/html'
5
+ import { defineMessageUnion } from 'foldkit/message'
6
+
7
+ import { type Program, keyBindings, memoizeLast, region } from '@hyper-effect/core'
8
+
9
+ import { cn } from '@/lib/utils'
10
+ import { button } from '@/ui/button'
11
+ import { card, cardContent, cardHeader, cardTitle } from '@/ui/card'
12
+ import { input } from '@/ui/input'
13
+
14
+ import { requireUser } from '../auth'
15
+ import { Task, Tasks } from './store'
16
+
17
+ /* The task list: a Foldkit program whose loop runs on the server.
18
+
19
+ - Model, Message, update and view are plain Foldkit; the browser only
20
+ receives morphs and posts events.
21
+ - Every page shows the shared list live, through a Subscription on the
22
+ store's change feed.
23
+ - Regions: each part of the page re-renders and travels only when its
24
+ deps change; a change to one task sends one row. */
25
+
26
+ // MODEL
27
+
28
+ export const Filter = S.Literals(['all', 'open', 'done'])
29
+ export type Filter = typeof Filter.Type
30
+
31
+ export const Model = S.Struct({
32
+ user: S.String,
33
+ tasks: S.Array(Task),
34
+ filter: Filter,
35
+ /** What the user is typing; the input is client-owned (bound with
36
+ * onInput), so the morph never overwrites it while they type. */
37
+ draft: S.String,
38
+ draftError: S.NullOr(S.String),
39
+ isAdding: S.Boolean,
40
+ /** The last change that failed, such as a task someone else deleted. */
41
+ notice: S.NullOr(S.String),
42
+ })
43
+ export type Model = typeof Model.Type
44
+
45
+ // MESSAGE
46
+
47
+ export const Message = defineMessageUnion({
48
+ ChangedDraft: { title: S.String },
49
+ SubmittedDraft: {},
50
+ ClickedToggle: { id: S.String },
51
+ ClickedRemove: { id: S.String },
52
+ ClickedFilter: { filter: Filter },
53
+ ClickedDismissNotice: {},
54
+ PressedNewTask: {},
55
+ CompletedFocus: {},
56
+ GotTasks: { tasks: S.Array(Task) },
57
+ SucceededAdd: {},
58
+ CompletedChange: {},
59
+ FailedChange: { reason: S.String },
60
+ })
61
+ export type Message = typeof Message.Type
62
+
63
+ /** The Messages a browser may post as they are. Typed text arrives through
64
+ * the session's handlers (onInput); store changes and Command results are
65
+ * server facts and cannot be forged. Anything posted is still checked in
66
+ * update: the browser is not trusted. */
67
+ export const ClientMessage = S.Union([
68
+ Message.SubmittedDraft,
69
+ Message.ClickedToggle,
70
+ Message.ClickedRemove,
71
+ Message.ClickedFilter,
72
+ Message.ClickedDismissNotice,
73
+ Message.PressedNewTask,
74
+ ])
75
+ export type ClientMessage = typeof ClientMessage.Type
76
+
77
+ // VALIDATION
78
+
79
+ const MAX_TITLE = 120
80
+
81
+ export const Title = S.String.pipe(
82
+ S.check(
83
+ S.isMinLength(1, { message: 'Describe the task.' }),
84
+ S.isMaxLength(MAX_TITLE, { message: `At most ${MAX_TITLE} characters.` }),
85
+ ),
86
+ )
87
+ const decodeTitle = S.decodeUnknownExit(Title)
88
+ const formatIssues = SchemaIssue.makeFormatterStandardSchemaV1()
89
+
90
+ /** The trimmed title, or the message of the first check it fails. */
91
+ export const validateTitle = (draft: string): Readonly<{ title: string } | { error: string }> => {
92
+ const decoded = decodeTitle(draft.trim())
93
+ if (decoded._tag === 'Success') return { title: decoded.value }
94
+ const error = decoded.cause.reasons.find(reason => reason._tag === 'Fail')?.error
95
+ return {
96
+ error: (S.isSchemaError(error) ? formatIssues(error.issue).issues[0]?.message : undefined) ?? 'Check the title.',
97
+ }
98
+ }
99
+
100
+ // COMMAND
101
+
102
+ export const AddTask = Command.define('AddTask', {
103
+ args: { title: S.String, by: S.String },
104
+ messages: [Message.SucceededAdd],
105
+ execute: ({ title, by }) =>
106
+ Tasks.use(tasks => tasks.add(title, by)).pipe(Effect.as(Message.SucceededAdd())),
107
+ })
108
+
109
+ export const ToggleTask = Command.define('ToggleTask', {
110
+ args: { id: S.String },
111
+ messages: [Message.CompletedChange, Message.FailedChange],
112
+ execute: ({ id }) =>
113
+ Tasks.use(tasks => tasks.toggle(id)).pipe(
114
+ Effect.as(Message.CompletedChange()),
115
+ Effect.catchTag('TaskNotFound', () => Effect.succeed(Message.FailedChange({ reason: 'That task was just deleted.' }))),
116
+ ),
117
+ })
118
+
119
+ export const RemoveTask = Command.define('RemoveTask', {
120
+ args: { id: S.String },
121
+ messages: [Message.CompletedChange, Message.FailedChange],
122
+ execute: ({ id }) =>
123
+ Tasks.use(tasks => tasks.remove(id)).pipe(
124
+ Effect.as(Message.CompletedChange()),
125
+ Effect.catchTag('TaskNotFound', () => Effect.succeed(Message.CompletedChange())),
126
+ ),
127
+ })
128
+
129
+ /** Runs in the browser after the next morph: forwarded as a focus action
130
+ * (see `clientCommands` in the program). */
131
+ export const FocusNewTask = Command.define('FocusNewTask', {
132
+ messages: [Message.CompletedFocus],
133
+ execute: Dom.focus('#task-title').pipe(Effect.ignore, Effect.as(Message.CompletedFocus())),
134
+ })
135
+
136
+ // INIT
137
+
138
+ export type Flags = Readonly<{ user: string; tasks: ReadonlyArray<Task> }>
139
+
140
+ export const init = (flags: Flags): Update.Return<Model, Message> => ({
141
+ model: {
142
+ user: flags.user,
143
+ tasks: flags.tasks,
144
+ filter: 'all',
145
+ draft: '',
146
+ draftError: null,
147
+ isAdding: false,
148
+ notice: null,
149
+ },
150
+ })
151
+
152
+ // UPDATE
153
+
154
+ type UpdateReturn = Update.Return<Model, Message, Tasks>
155
+
156
+ export const update = (model: Model, message: Message): UpdateReturn =>
157
+ M.value(message).pipe(
158
+ M.withReturnType<UpdateReturn>(),
159
+ M.tagsExhaustive({
160
+ ChangedDraft: ({ title }) => ({ model: { ...model, draft: title, draftError: null } }),
161
+ SubmittedDraft: () => {
162
+ if (model.isAdding) return { model }
163
+ const validated = validateTitle(model.draft)
164
+ return 'error' in validated
165
+ ? { model: { ...model, draftError: validated.error } }
166
+ : { model: { ...model, isAdding: true }, commands: [AddTask({ title: validated.title, by: model.user })] }
167
+ },
168
+ // Clearing the draft in the Model clears the input: the server patches
169
+ // a client-owned input when the Model diverges from what it sent.
170
+ SucceededAdd: () => ({ model: { ...model, draft: '', isAdding: false } }),
171
+ ClickedToggle: ({ id }) => ({ model, commands: [ToggleTask({ id })] }),
172
+ ClickedRemove: ({ id }) => ({ model, commands: [RemoveTask({ id })] }),
173
+ ClickedFilter: ({ filter }) => ({ model: { ...model, filter } }),
174
+ ClickedDismissNotice: () => ({ model: { ...model, notice: null } }),
175
+ PressedNewTask: () => ({ model, commands: [FocusNewTask()] }),
176
+ CompletedFocus: () => ({ model }),
177
+ GotTasks: ({ tasks }) => ({ model: { ...model, tasks } }),
178
+ CompletedChange: () => ({ model }),
179
+ FailedChange: ({ reason }) => ({ model: { ...model, notice: reason } }),
180
+ }),
181
+ )
182
+
183
+ // SUBSCRIPTION
184
+
185
+ // One Message per change of the list, shared by every session (instead of
186
+ // each session building its own copy).
187
+ const gotTasks = memoizeLast((tasks: ReadonlyArray<Task>) => Message.GotTasks({ tasks }))
188
+
189
+ export const subscriptions = Subscription.make<Model, Message, Tasks>()(entry => ({
190
+ tasks: entry(
191
+ {},
192
+ {
193
+ modelToDependencies: () => ({}),
194
+ dependenciesToStream: () =>
195
+ Stream.unwrap(Tasks.use(tasks => Effect.succeed(tasks.changes.pipe(Stream.map(gotTasks))))),
196
+ },
197
+ ),
198
+ }))
199
+
200
+ // VIEW
201
+
202
+ const visibleTasks = (model: Model): ReadonlyArray<Task> =>
203
+ model.filter === 'all' ? model.tasks : model.tasks.filter(task => task.isDone === (model.filter === 'done'))
204
+
205
+ const header = (user: string, h: HtmlBuilder<Message>): Html =>
206
+ h.header(
207
+ [h.Id('page-header'), h.Class('flex items-center justify-between gap-3 border-b bg-background px-4 py-3 sm:px-6')],
208
+ [
209
+ h.span([h.Class('text-base font-semibold')], ['{{title}}']),
210
+ h.div(
211
+ [h.Class('flex items-center gap-2 text-sm text-muted-foreground')],
212
+ [
213
+ h.span([], [user]),
214
+ // A plain form post: signing out is a navigation, not an event.
215
+ h.form(
216
+ [h.Method('post'), h.Action('/logout')],
217
+ [button({ children: ['Sign out'], type: 'submit', variant: 'ghost', size: 'sm' }, h)],
218
+ ),
219
+ ],
220
+ ),
221
+ ],
222
+ )
223
+
224
+ const newTaskForm = (model: Model, h: HtmlBuilder<Message>): Html =>
225
+ h.form(
226
+ [h.Id('new-task'), h.Class('flex items-start gap-2'), h.OnSubmit(Message.SubmittedDraft())],
227
+ [
228
+ h.div(
229
+ [h.Class('flex-1')],
230
+ [
231
+ input(
232
+ {
233
+ id: 'task-title',
234
+ ariaLabel: 'New task',
235
+ value: model.draft,
236
+ placeholder: 'What needs doing?',
237
+ onInput: title => Message.ChangedDraft({ title }),
238
+ ...(model.draftError === null ? {} : { description: model.draftError, isInvalid: true }),
239
+ },
240
+ h,
241
+ ),
242
+ ],
243
+ ),
244
+ button({ children: ['Add'], type: 'submit', isLoading: model.isAdding }, h),
245
+ ],
246
+ )
247
+
248
+ const FILTERS: ReadonlyArray<Readonly<{ value: Filter; label: string }>> = [
249
+ { value: 'all', label: 'All' },
250
+ { value: 'open', label: 'Open' },
251
+ { value: 'done', label: 'Done' },
252
+ ]
253
+
254
+ const filters = (model: Model, h: HtmlBuilder<Message>): Html => {
255
+ const open = model.tasks.filter(task => !task.isDone).length
256
+ return h.div(
257
+ [h.Id('filters'), h.Class('flex items-center justify-between gap-2 text-sm')],
258
+ [
259
+ h.div(
260
+ [h.Class('flex gap-1'), h.Role('group'), h.AriaLabel('Show')],
261
+ FILTERS.map(({ value, label }) =>
262
+ button(
263
+ {
264
+ children: [label],
265
+ variant: model.filter === value ? 'secondary' : 'ghost',
266
+ size: 'sm',
267
+ onClick: Message.ClickedFilter({ filter: value }),
268
+ },
269
+ h,
270
+ ),
271
+ ),
272
+ ),
273
+ h.span([h.Class('text-muted-foreground'), h.AriaLive('polite')], [`${open} open of ${model.tasks.length}`]),
274
+ ],
275
+ )
276
+ }
277
+
278
+ const taskRow = (task: Task, h: HtmlBuilder<Message>): Html =>
279
+ h.li(
280
+ [h.Id(`task-${task.id}`), h.Class('flex items-center gap-3 py-2')],
281
+ [
282
+ h.button(
283
+ [
284
+ h.Type('button'),
285
+ h.Class(
286
+ cn(
287
+ 'flex size-5 shrink-0 items-center justify-center rounded border text-xs',
288
+ task.isDone && 'border-primary bg-primary text-primary-foreground',
289
+ ),
290
+ ),
291
+ h.AriaPressed(task.isDone ? 'true' : 'false'),
292
+ h.AriaLabel(`Done: ${task.title}`),
293
+ h.OnClick(Message.ClickedToggle({ id: task.id })),
294
+ ],
295
+ [task.isDone ? '✓' : ''],
296
+ ),
297
+ h.div(
298
+ [h.Class('min-w-0 flex-1')],
299
+ [
300
+ h.p([h.Class(cn('truncate', task.isDone && 'text-muted-foreground line-through'))], [task.title]),
301
+ h.p([h.Class('text-xs text-muted-foreground')], [`by ${task.createdBy}`]),
302
+ ],
303
+ ),
304
+ button(
305
+ { children: ['Remove'], variant: 'ghost', size: 'xs', ariaLabel: `Remove ${task.title}`, onClick: Message.ClickedRemove({ id: task.id }) },
306
+ h,
307
+ ),
308
+ ],
309
+ )
310
+
311
+ const taskList = (tasks: ReadonlyArray<Task>, h: HtmlBuilder<Message>): Html =>
312
+ tasks.length === 0
313
+ ? h.p([h.Id('task-list'), h.Class('py-6 text-center text-sm text-muted-foreground')], ['Nothing here.'])
314
+ : h.ul(
315
+ [h.Id('task-list'), h.Class('divide-y')],
316
+ // A region per task: a change to one task sends only its row.
317
+ tasks.map(task => region(h, { id: `task-${task.id}`, deps: [task], render: () => taskRow(task, h) })),
318
+ )
319
+
320
+ const notice = (message: string | null, h: HtmlBuilder<Message>): Html =>
321
+ h.div(
322
+ [h.Id('notice'), h.AriaLive('polite')],
323
+ message === null
324
+ ? []
325
+ : [
326
+ h.div(
327
+ [h.Class('flex items-center justify-between gap-2 rounded-md border bg-muted px-3 py-2 text-sm')],
328
+ [
329
+ message,
330
+ button({ children: ['Dismiss'], variant: 'ghost', size: 'xs', onClick: Message.ClickedDismissNotice() }, h),
331
+ ],
332
+ ),
333
+ ],
334
+ )
335
+
336
+ export const view = (model: Model, h: HtmlBuilder<Message>): Document => {
337
+ const visible = visibleTasks(model)
338
+ const open = model.tasks.filter(task => !task.isDone).length
339
+ return {
340
+ title: open === 0 ? '{{title}}' : `(${open}) {{title}}`,
341
+ body: h.div(
342
+ [h.Class('flex min-h-screen flex-col bg-muted/30')],
343
+ [
344
+ // deps list every Model field the region's view reads; outside
345
+ // production a region whose deps miss one fails to render.
346
+ region(h, { id: 'page-header', deps: [model.user], render: () => header(model.user, h) }),
347
+ // Shortcuts for the whole page, matched in the browser: only a
348
+ // matching key posts its Message.
349
+ keyBindings(h, [{ keys: 'N', message: Message.PressedNewTask() }]),
350
+ h.main(
351
+ [h.Class('mx-auto grid w-full max-w-2xl gap-4 p-4 sm:p-6')],
352
+ [
353
+ card(
354
+ {
355
+ children: [
356
+ cardHeader({ children: [cardTitle({ children: ['Tasks'] }, h)] }, h),
357
+ cardContent(
358
+ {
359
+ class: 'grid gap-4',
360
+ children: [
361
+ region(h, {
362
+ id: 'new-task',
363
+ deps: [model.draft, model.draftError, model.isAdding],
364
+ render: () => newTaskForm(model, h),
365
+ }),
366
+ region(h, { id: 'notice', deps: [model.notice], render: () => notice(model.notice, h) }),
367
+ region(h, { id: 'filters', deps: [model.filter, model.tasks], render: () => filters(model, h) }),
368
+ region(h, { id: 'task-list', deps: [visible], render: () => taskList(visible, h) }),
369
+ ],
370
+ },
371
+ h,
372
+ ),
373
+ ],
374
+ },
375
+ h,
376
+ ),
377
+ ],
378
+ ),
379
+ ],
380
+ ),
381
+ }
382
+ }
383
+
384
+ // PROGRAM
385
+
386
+ export const program: Program<Model, Message, ClientMessage, Flags, Tasks> = {
387
+ name: 'tasks',
388
+ Model,
389
+ ClientMessage,
390
+ // Runs per page load: authenticate, then load what the page shows.
391
+ flags: request =>
392
+ Effect.gen(function* () {
393
+ const user = yield* requireUser(request)
394
+ const tasks = yield* Tasks.use(store => store.current)
395
+ return { user, tasks }
396
+ }),
397
+ init,
398
+ update,
399
+ view,
400
+ subscriptions,
401
+ // Snapshots keep only this user's own state; the list comes back from the
402
+ // Subscription after a restore.
403
+ snapshotDeps: model => [model.filter, model.draft, model.draftError, model.notice],
404
+ // Browser-only Commands, performed by the page after the next morph.
405
+ clientCommands: {
406
+ FocusNewTask: () => ({ focus: '#task-title' }),
407
+ },
408
+ }