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.
- package/LICENSE +21 -0
- package/README.md +15 -2
- package/bin.mjs +97 -0
- package/package.json +33 -4
- package/skill/SKILL.md +87 -0
- package/skill/references/api.md +85 -0
- package/skill/references/browser.md +21 -0
- package/skill/references/gotchas.md +46 -0
- package/skill/references/patterns.md +69 -0
- package/template/AGENTS.md +10 -0
- package/template/README.md +39 -0
- package/template/_gitignore +3 -0
- package/template/_package.json +35 -0
- package/template/scripts/check.mjs +76 -0
- package/template/src/app/auth.ts +132 -0
- package/template/src/app/tasks/page.ts +408 -0
- package/template/src/app/tasks/store.ts +81 -0
- package/template/src/lib/button.ts +145 -0
- package/template/src/lib/input.ts +158 -0
- package/template/src/lib/utils.ts +6 -0
- package/template/src/main.ts +49 -0
- package/template/src/styles.css +128 -0
- package/template/src/ui/button.ts +101 -0
- package/template/src/ui/card.ts +102 -0
- package/template/src/ui/input.ts +38 -0
- package/template/test/tasks.test.ts +89 -0
- package/template/tsconfig.json +21 -0
- package/template/vitest.config.ts +12 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { Html, HtmlBuilder } from 'foldkit/html'
|
|
2
|
+
|
|
3
|
+
import { cn } from '@/lib/utils'
|
|
4
|
+
|
|
5
|
+
/* Ported from shadcn/ui card.tsx. Class strings copied verbatim, including the
|
|
6
|
+
data-slot attributes shadcn uses for structural selectors
|
|
7
|
+
(has-data-[slot=card-action]). Card has no behavior, so there is no foldkit
|
|
8
|
+
UI primitive underneath — these are plain view functions. */
|
|
9
|
+
|
|
10
|
+
type Slot = Readonly<{
|
|
11
|
+
class?: string
|
|
12
|
+
children: ReadonlyArray<Html | string>
|
|
13
|
+
}>
|
|
14
|
+
|
|
15
|
+
export type CardProps = Slot &
|
|
16
|
+
Readonly<{
|
|
17
|
+
id?: string
|
|
18
|
+
element?: 'div' | 'section' | 'article'
|
|
19
|
+
size?: 'default' | 'sm'
|
|
20
|
+
}>
|
|
21
|
+
|
|
22
|
+
const slotDiv =
|
|
23
|
+
(slot: string, baseClass: string) =>
|
|
24
|
+
<Msg>(props: Slot, h: HtmlBuilder<Msg>): Html => {
|
|
25
|
+
return h.div(
|
|
26
|
+
[h.DataAttribute('slot', slot), h.Class(cn(baseClass, props.class))],
|
|
27
|
+
[...props.children],
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const card = <Msg>(props: CardProps, h: HtmlBuilder<Msg>): Html => {
|
|
32
|
+
const attributes = [
|
|
33
|
+
...(props.id === undefined ? [] : [h.Id(props.id)]),
|
|
34
|
+
h.DataAttribute('slot', 'card'),
|
|
35
|
+
h.DataAttribute('size', props.size ?? 'default'),
|
|
36
|
+
h.Class(
|
|
37
|
+
cn(
|
|
38
|
+
'group/card bg-card text-card-foreground flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl border py-(--card-spacing) text-sm shadow-sm [--card-spacing:--spacing(6)] has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(4)] *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl',
|
|
39
|
+
props.class,
|
|
40
|
+
),
|
|
41
|
+
),
|
|
42
|
+
]
|
|
43
|
+
const children = [...props.children]
|
|
44
|
+
|
|
45
|
+
switch (props.element ?? 'div') {
|
|
46
|
+
case 'section':
|
|
47
|
+
return h.section(attributes, children)
|
|
48
|
+
case 'article':
|
|
49
|
+
return h.article(attributes, children)
|
|
50
|
+
default:
|
|
51
|
+
return h.div(attributes, children)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const cardHeader = slotDiv(
|
|
56
|
+
'card-header',
|
|
57
|
+
'@container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)',
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
export type CardTitleProps = Slot & Readonly<{ element?: 'h2' | 'h3' | 'h4' }>
|
|
61
|
+
export const cardTitle = <Msg>(
|
|
62
|
+
props: CardTitleProps,
|
|
63
|
+
h: HtmlBuilder<Msg>,
|
|
64
|
+
): Html => {
|
|
65
|
+
const attributes = [
|
|
66
|
+
h.DataAttribute('slot', 'card-title'),
|
|
67
|
+
h.Class(
|
|
68
|
+
cn(
|
|
69
|
+
'text-base leading-normal font-medium group-data-[size=sm]/card:text-sm',
|
|
70
|
+
props.class,
|
|
71
|
+
),
|
|
72
|
+
),
|
|
73
|
+
]
|
|
74
|
+
switch (props.element ?? 'h3') {
|
|
75
|
+
case 'h2':
|
|
76
|
+
return h.h2(attributes, [...props.children])
|
|
77
|
+
case 'h4':
|
|
78
|
+
return h.h4(attributes, [...props.children])
|
|
79
|
+
default:
|
|
80
|
+
return h.h3(attributes, [...props.children])
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export const cardDescription = slotDiv(
|
|
85
|
+
'card-description',
|
|
86
|
+
'text-muted-foreground text-sm',
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
export const cardAction = slotDiv(
|
|
90
|
+
'card-action',
|
|
91
|
+
'col-start-2 row-span-2 row-start-1 self-start justify-self-end',
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
export const cardContent = slotDiv(
|
|
95
|
+
'card-content',
|
|
96
|
+
'flex flex-col gap-3 px-(--card-spacing)',
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
export const cardFooter = slotDiv(
|
|
100
|
+
'card-footer',
|
|
101
|
+
'flex items-center rounded-b-xl px-(--card-spacing) [.border-t]:pt-(--card-spacing)',
|
|
102
|
+
)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Html, HtmlBuilder } from 'foldkit/html'
|
|
2
|
+
|
|
3
|
+
import { type InputBehaviorProps, renderInput } from '@/lib/input'
|
|
4
|
+
import { cn } from '@/lib/utils'
|
|
5
|
+
|
|
6
|
+
/* Ported from shadcn/ui input.tsx + label.tsx. Class strings verbatim — the
|
|
7
|
+
foldkit Input primitive sets the native disabled attribute as well as
|
|
8
|
+
data-disabled, so shadcn's `disabled:` variants work unchanged. The primitive
|
|
9
|
+
also wires label/description ids for free via its attribute groups. */
|
|
10
|
+
|
|
11
|
+
const INPUT_CLASS =
|
|
12
|
+
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-8 w-full min-w-0 rounded-md border bg-transparent px-2.5 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive'
|
|
13
|
+
|
|
14
|
+
const LABEL_CLASS =
|
|
15
|
+
'flex items-center gap-2 text-sm leading-none font-medium select-none'
|
|
16
|
+
|
|
17
|
+
const DESCRIPTION_CLASS = 'text-muted-foreground text-sm'
|
|
18
|
+
|
|
19
|
+
export type InputProps<Msg> = InputBehaviorProps<Msg> &
|
|
20
|
+
Readonly<{
|
|
21
|
+
class?: string
|
|
22
|
+
}>
|
|
23
|
+
|
|
24
|
+
export const input = <Msg>(
|
|
25
|
+
props: InputProps<Msg>,
|
|
26
|
+
h: HtmlBuilder<Msg>,
|
|
27
|
+
): Html => {
|
|
28
|
+
return renderInput(
|
|
29
|
+
props,
|
|
30
|
+
{
|
|
31
|
+
field: [h.Class('grid gap-2')],
|
|
32
|
+
label: [h.Class(LABEL_CLASS)],
|
|
33
|
+
input: [h.Class(cn(INPUT_CLASS, props.class))],
|
|
34
|
+
description: [h.Class(DESCRIPTION_CLASS)],
|
|
35
|
+
},
|
|
36
|
+
h,
|
|
37
|
+
)
|
|
38
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { assert, describe, it } from '@effect/vitest'
|
|
2
|
+
import { Story } from 'foldkit/test'
|
|
3
|
+
|
|
4
|
+
import { openPage, openStream, postEvent, readUntil, serve, token } from '@hyper-effect/core/testing'
|
|
5
|
+
|
|
6
|
+
import { USER_COOKIE_NAME, cookieValue } from '@/app/auth'
|
|
7
|
+
import * as TaskPage from '@/app/tasks/page'
|
|
8
|
+
import { Tasks } from '@/app/tasks/store'
|
|
9
|
+
|
|
10
|
+
/* Two kinds of tests:
|
|
11
|
+
- update is a pure Foldkit function: Foldkit's Story runs Messages through
|
|
12
|
+
it and resolves Commands by hand.
|
|
13
|
+
- The page over HTTP, as a browser drives it: @hyper-effect/core/testing
|
|
14
|
+
serves the program with its services and posts events. */
|
|
15
|
+
|
|
16
|
+
const model: TaskPage.Model = {
|
|
17
|
+
user: 'ada',
|
|
18
|
+
tasks: [],
|
|
19
|
+
filter: 'all',
|
|
20
|
+
draft: '',
|
|
21
|
+
draftError: null,
|
|
22
|
+
isAdding: false,
|
|
23
|
+
notice: null,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('tasks: update (Story)', () => {
|
|
27
|
+
it('refuses an empty title and adds a valid one', () => {
|
|
28
|
+
Story.story(
|
|
29
|
+
TaskPage.update,
|
|
30
|
+
Story.given({ ...model, draft: ' ' }),
|
|
31
|
+
Story.message(TaskPage.Message.SubmittedDraft()),
|
|
32
|
+
Story.model(next => assert.strictEqual(next.draftError, 'Describe the task.')),
|
|
33
|
+
Story.message(TaskPage.Message.ChangedDraft({ title: ' Water the plants ' })),
|
|
34
|
+
Story.message(TaskPage.Message.SubmittedDraft()),
|
|
35
|
+
Story.model(next => {
|
|
36
|
+
assert.isNull(next.draftError)
|
|
37
|
+
assert.isTrue(next.isAdding)
|
|
38
|
+
}),
|
|
39
|
+
Story.Command.resolve(TaskPage.AddTask, TaskPage.Message.SucceededAdd()),
|
|
40
|
+
Story.model(next => {
|
|
41
|
+
assert.strictEqual(next.draft, '')
|
|
42
|
+
assert.isFalse(next.isAdding)
|
|
43
|
+
}),
|
|
44
|
+
)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('shows why a change failed', () => {
|
|
48
|
+
Story.story(
|
|
49
|
+
TaskPage.update,
|
|
50
|
+
Story.given(model),
|
|
51
|
+
Story.message(TaskPage.Message.ClickedToggle({ id: 'gone' })),
|
|
52
|
+
Story.Command.resolve(TaskPage.ToggleTask, TaskPage.Message.FailedChange({ reason: 'That task was just deleted.' })),
|
|
53
|
+
Story.model(next => assert.strictEqual(next.notice, 'That task was just deleted.')),
|
|
54
|
+
)
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
describe('tasks: the page over HTTP', () => {
|
|
59
|
+
const cookie = `${USER_COOKIE_NAME}=${cookieValue('ada')}`
|
|
60
|
+
|
|
61
|
+
it('sends a signed-out visitor to sign in', async () => {
|
|
62
|
+
const server = serve(TaskPage.program, {}, undefined, Tasks.layer)
|
|
63
|
+
try {
|
|
64
|
+
const response = await server.handler(new Request('http://localhost/'))
|
|
65
|
+
assert.strictEqual(response.status, 303)
|
|
66
|
+
assert.strictEqual(response.headers.get('location'), '/login?next=%2F')
|
|
67
|
+
} finally {
|
|
68
|
+
await server.dispose()
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('adds a task typed into the form', async () => {
|
|
73
|
+
const server = serve(TaskPage.program, {}, undefined, Tasks.layer)
|
|
74
|
+
try {
|
|
75
|
+
const page = await openPage(server.handler, '/', cookie)
|
|
76
|
+
assert.include(page.html, 'Read the README')
|
|
77
|
+
// The input is client-owned: what the user types arrives through its
|
|
78
|
+
// handler, named in the page.
|
|
79
|
+
const handler = page.html.match(/data-hyper-input="([^"]+)"[^>]*id="task-title"/)?.[1]
|
|
80
|
+
assert.isDefined(handler)
|
|
81
|
+
assert.strictEqual((await postEvent(server.handler, page, { h: handler, v: 'Water the plants', q: 1 })).status, 204)
|
|
82
|
+
assert.strictEqual((await postEvent(server.handler, page, { m: token({ _tag: 'SubmittedDraft' }), q: 2 })).status, 204)
|
|
83
|
+
const stream = await readUntil(await openStream(server.handler, page), 'Water the plants')
|
|
84
|
+
assert.include(stream, 'Water the plants')
|
|
85
|
+
} finally {
|
|
86
|
+
await server.dispose()
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
})
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"lib": ["ES2022", "DOM"],
|
|
6
|
+
"moduleResolution": "bundler",
|
|
7
|
+
"types": ["node"],
|
|
8
|
+
"strict": true,
|
|
9
|
+
"noUncheckedIndexedAccess": true,
|
|
10
|
+
"exactOptionalPropertyTypes": true,
|
|
11
|
+
"skipLibCheck": true,
|
|
12
|
+
"esModuleInterop": true,
|
|
13
|
+
"isolatedModules": true,
|
|
14
|
+
"noEmit": true,
|
|
15
|
+
"allowImportingTsExtensions": true,
|
|
16
|
+
"paths": {
|
|
17
|
+
"@/*": ["./src/*"]
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"include": ["src/**/*", "test/**/*", "vitest.config.ts"]
|
|
21
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { fileURLToPath } from 'node:url'
|
|
2
|
+
import { defineConfig } from 'vitest/config'
|
|
3
|
+
|
|
4
|
+
export default defineConfig({
|
|
5
|
+
resolve: {
|
|
6
|
+
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
|
|
7
|
+
},
|
|
8
|
+
test: {
|
|
9
|
+
name: '{{name}}',
|
|
10
|
+
include: ['test/**/*.test.ts'],
|
|
11
|
+
},
|
|
12
|
+
})
|