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,81 @@
|
|
|
1
|
+
import { Clock, Context, Data, Effect, Layer, Schema as S, Stream, SubscriptionRef } from 'effect'
|
|
2
|
+
|
|
3
|
+
/* The task store: the app's shared state, which every page shows live.
|
|
4
|
+
|
|
5
|
+
In memory here, with a change feed: `changes` emits the current list at
|
|
6
|
+
once and then every new one. Pages subscribe to it and get the data itself,
|
|
7
|
+
so a change needs no extra query. A real app puts a database behind the
|
|
8
|
+
same service and turns its change notifications (Postgres LISTEN/NOTIFY)
|
|
9
|
+
into the same stream; see examples/admin/src/app/catalog/postgres.ts in the
|
|
10
|
+
hyper-effect repository. */
|
|
11
|
+
|
|
12
|
+
export const Task = S.Struct({
|
|
13
|
+
id: S.String,
|
|
14
|
+
title: S.String,
|
|
15
|
+
isDone: S.Boolean,
|
|
16
|
+
createdBy: S.String,
|
|
17
|
+
createdAt: S.Number,
|
|
18
|
+
})
|
|
19
|
+
export type Task = typeof Task.Type
|
|
20
|
+
|
|
21
|
+
export class TaskNotFound extends Data.TaggedError('TaskNotFound')<{ readonly id: string }> {}
|
|
22
|
+
|
|
23
|
+
export class Tasks extends Context.Service<
|
|
24
|
+
Tasks,
|
|
25
|
+
{
|
|
26
|
+
readonly current: Effect.Effect<ReadonlyArray<Task>>
|
|
27
|
+
/** The current list at once, then every new one. */
|
|
28
|
+
readonly changes: Stream.Stream<ReadonlyArray<Task>>
|
|
29
|
+
readonly add: (title: string, by: string) => Effect.Effect<Task>
|
|
30
|
+
readonly toggle: (id: string) => Effect.Effect<Task, TaskNotFound>
|
|
31
|
+
readonly remove: (id: string) => Effect.Effect<void, TaskNotFound>
|
|
32
|
+
}
|
|
33
|
+
>()('app/Tasks') {
|
|
34
|
+
static readonly layer = Layer.effect(
|
|
35
|
+
Tasks,
|
|
36
|
+
Effect.gen(function* () {
|
|
37
|
+
const now = yield* Clock.currentTimeMillis
|
|
38
|
+
const tasks = yield* SubscriptionRef.make<ReadonlyArray<Task>>([
|
|
39
|
+
{ id: 't1', title: 'Read the README', isDone: true, createdBy: 'hyper-effect', createdAt: now },
|
|
40
|
+
{ id: 't2', title: 'Open this page in a second window', isDone: false, createdBy: 'hyper-effect', createdAt: now },
|
|
41
|
+
{ id: 't3', title: 'Add a task and watch it appear in both', isDone: false, createdBy: 'hyper-effect', createdAt: now },
|
|
42
|
+
])
|
|
43
|
+
let nextId = 4
|
|
44
|
+
|
|
45
|
+
const find = (id: string) =>
|
|
46
|
+
Effect.flatMap(SubscriptionRef.get(tasks), list => {
|
|
47
|
+
const task = list.find(item => item.id === id)
|
|
48
|
+
return task === undefined ? Effect.fail(new TaskNotFound({ id })) : Effect.succeed(task)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
current: SubscriptionRef.get(tasks),
|
|
53
|
+
changes: SubscriptionRef.changes(tasks),
|
|
54
|
+
add: (title, by) =>
|
|
55
|
+
Effect.gen(function* () {
|
|
56
|
+
const task: Task = {
|
|
57
|
+
id: `t${nextId++}`,
|
|
58
|
+
title,
|
|
59
|
+
isDone: false,
|
|
60
|
+
createdBy: by,
|
|
61
|
+
createdAt: yield* Clock.currentTimeMillis,
|
|
62
|
+
}
|
|
63
|
+
yield* SubscriptionRef.update(tasks, list => [...list, task])
|
|
64
|
+
return task
|
|
65
|
+
}),
|
|
66
|
+
toggle: id =>
|
|
67
|
+
Effect.gen(function* () {
|
|
68
|
+
const task = yield* find(id)
|
|
69
|
+
const toggled = { ...task, isDone: !task.isDone }
|
|
70
|
+
yield* SubscriptionRef.update(tasks, list => list.map(item => (item.id === id ? toggled : item)))
|
|
71
|
+
return toggled
|
|
72
|
+
}),
|
|
73
|
+
remove: id =>
|
|
74
|
+
Effect.gen(function* () {
|
|
75
|
+
yield* find(id)
|
|
76
|
+
yield* SubscriptionRef.update(tasks, list => list.filter(item => item.id !== id))
|
|
77
|
+
}),
|
|
78
|
+
}
|
|
79
|
+
}),
|
|
80
|
+
)
|
|
81
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { Button as ButtonPrimitive } from '@foldkit/ui'
|
|
2
|
+
import type { Attribute, ChildAttribute, Html, HtmlBuilder } from 'foldkit/html'
|
|
3
|
+
|
|
4
|
+
export type ButtonType = 'button' | 'submit' | 'reset'
|
|
5
|
+
|
|
6
|
+
export type ButtonBehaviorProps<Msg> = Readonly<{
|
|
7
|
+
children: ReadonlyArray<Html | string>
|
|
8
|
+
onClick?: Msg
|
|
9
|
+
/** Native behavior attributes supplied by a composing overlay or dialog. */
|
|
10
|
+
buttonAttributes?: ReadonlyArray<Attribute<Msg> | ChildAttribute>
|
|
11
|
+
id?: string
|
|
12
|
+
isDisabled?: boolean
|
|
13
|
+
isLoading?: boolean
|
|
14
|
+
loadingContent?: ReadonlyArray<Html | string>
|
|
15
|
+
leadingIcon?: Html
|
|
16
|
+
trailingIcon?: Html
|
|
17
|
+
type?: ButtonType
|
|
18
|
+
isAutofocus?: boolean
|
|
19
|
+
ariaLabel?: string
|
|
20
|
+
name?: string
|
|
21
|
+
value?: string
|
|
22
|
+
form?: string
|
|
23
|
+
slot?: string
|
|
24
|
+
dataSize?: string
|
|
25
|
+
}>
|
|
26
|
+
|
|
27
|
+
export type ButtonLinkBehaviorProps = Readonly<{
|
|
28
|
+
children: ReadonlyArray<Html | string>
|
|
29
|
+
href: string
|
|
30
|
+
leadingIcon?: Html
|
|
31
|
+
trailingIcon?: Html
|
|
32
|
+
ariaLabel?: string
|
|
33
|
+
slot?: string
|
|
34
|
+
dataSize?: string
|
|
35
|
+
target?: '_blank' | '_self'
|
|
36
|
+
}>
|
|
37
|
+
|
|
38
|
+
const content = <Msg>(
|
|
39
|
+
props: Pick<
|
|
40
|
+
ButtonBehaviorProps<Msg>,
|
|
41
|
+
'children' | 'isLoading' | 'loadingContent' | 'leadingIcon' | 'trailingIcon'
|
|
42
|
+
>,
|
|
43
|
+
h: HtmlBuilder<Msg>,
|
|
44
|
+
): ReadonlyArray<Html> => [
|
|
45
|
+
...(props.isLoading === true && props.loadingContent !== undefined
|
|
46
|
+
? [
|
|
47
|
+
h.span(
|
|
48
|
+
[
|
|
49
|
+
h.DataAttribute('slot', 'button-loading'),
|
|
50
|
+
h.AriaHidden(true),
|
|
51
|
+
h.Style({ display: 'contents' }),
|
|
52
|
+
],
|
|
53
|
+
[...props.loadingContent],
|
|
54
|
+
),
|
|
55
|
+
]
|
|
56
|
+
: []),
|
|
57
|
+
...(props.leadingIcon === undefined
|
|
58
|
+
? []
|
|
59
|
+
: [
|
|
60
|
+
h.span(
|
|
61
|
+
[h.DataAttribute('slot', 'button-icon'), h.AriaHidden(true)],
|
|
62
|
+
[props.leadingIcon],
|
|
63
|
+
),
|
|
64
|
+
]),
|
|
65
|
+
h.span(
|
|
66
|
+
[
|
|
67
|
+
h.DataAttribute('slot', 'button-content'),
|
|
68
|
+
h.Style({ display: 'contents' }),
|
|
69
|
+
],
|
|
70
|
+
[...props.children],
|
|
71
|
+
),
|
|
72
|
+
...(props.trailingIcon === undefined
|
|
73
|
+
? []
|
|
74
|
+
: [
|
|
75
|
+
h.span(
|
|
76
|
+
[h.DataAttribute('slot', 'button-icon'), h.AriaHidden(true)],
|
|
77
|
+
[props.trailingIcon],
|
|
78
|
+
),
|
|
79
|
+
]),
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
export const renderButton = <Msg>(
|
|
83
|
+
props: ButtonBehaviorProps<Msg>,
|
|
84
|
+
visualAttributes: ReadonlyArray<Attribute<Msg> | ChildAttribute>,
|
|
85
|
+
h: HtmlBuilder<Msg>,
|
|
86
|
+
): Html => {
|
|
87
|
+
const isDisabled = props.isDisabled === true || props.isLoading === true
|
|
88
|
+
|
|
89
|
+
return ButtonPrimitive.view(
|
|
90
|
+
{
|
|
91
|
+
...(props.onClick === undefined ? {} : { onClick: props.onClick }),
|
|
92
|
+
isDisabled,
|
|
93
|
+
type: props.type ?? 'button',
|
|
94
|
+
isAutofocus: props.isAutofocus ?? false,
|
|
95
|
+
toView: ({ button: primitiveAttributes }) =>
|
|
96
|
+
h.button(
|
|
97
|
+
[
|
|
98
|
+
...primitiveAttributes,
|
|
99
|
+
h.DataAttribute('slot', props.slot ?? 'button'),
|
|
100
|
+
h.DataAttribute(
|
|
101
|
+
'state',
|
|
102
|
+
props.isLoading === true ? 'loading' : 'idle',
|
|
103
|
+
),
|
|
104
|
+
...(props.dataSize === undefined
|
|
105
|
+
? []
|
|
106
|
+
: [h.DataAttribute('size', props.dataSize)]),
|
|
107
|
+
...(isDisabled ? [h.Disabled(true)] : []),
|
|
108
|
+
...(props.id === undefined ? [] : [h.Id(props.id)]),
|
|
109
|
+
...(props.ariaLabel === undefined
|
|
110
|
+
? []
|
|
111
|
+
: [h.AriaLabel(props.ariaLabel)]),
|
|
112
|
+
...(props.name === undefined ? [] : [h.Name(props.name)]),
|
|
113
|
+
...(props.value === undefined ? [] : [h.Value(props.value)]),
|
|
114
|
+
...(props.form === undefined ? [] : [h.FormAttr(props.form)]),
|
|
115
|
+
...(props.buttonAttributes ?? []),
|
|
116
|
+
...visualAttributes,
|
|
117
|
+
],
|
|
118
|
+
content(props, h),
|
|
119
|
+
),
|
|
120
|
+
},
|
|
121
|
+
h,
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export const renderButtonLink = <Msg>(
|
|
126
|
+
props: ButtonLinkBehaviorProps,
|
|
127
|
+
visualAttributes: ReadonlyArray<Attribute<Msg> | ChildAttribute>,
|
|
128
|
+
h: HtmlBuilder<Msg>,
|
|
129
|
+
): Html =>
|
|
130
|
+
h.a(
|
|
131
|
+
[
|
|
132
|
+
h.Href(props.href),
|
|
133
|
+
h.DataAttribute('slot', props.slot ?? 'button'),
|
|
134
|
+
...(props.dataSize === undefined
|
|
135
|
+
? []
|
|
136
|
+
: [h.DataAttribute('size', props.dataSize)]),
|
|
137
|
+
...(props.target === undefined
|
|
138
|
+
? []
|
|
139
|
+
: [h.Attribute('target', props.target)]),
|
|
140
|
+
...(props.target === '_blank' ? [h.Rel('noopener noreferrer')] : []),
|
|
141
|
+
...(props.ariaLabel === undefined ? [] : [h.AriaLabel(props.ariaLabel)]),
|
|
142
|
+
...visualAttributes,
|
|
143
|
+
],
|
|
144
|
+
content(props, h),
|
|
145
|
+
)
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { Input as InputPrimitive } from '@foldkit/ui'
|
|
2
|
+
import type { Attribute, Html, HtmlBuilder } from 'foldkit/html'
|
|
3
|
+
|
|
4
|
+
export type InputMode =
|
|
5
|
+
| 'none'
|
|
6
|
+
| 'text'
|
|
7
|
+
| 'decimal'
|
|
8
|
+
| 'numeric'
|
|
9
|
+
| 'tel'
|
|
10
|
+
| 'search'
|
|
11
|
+
| 'email'
|
|
12
|
+
| 'url'
|
|
13
|
+
|
|
14
|
+
export type InputBehaviorProps<Msg> = Readonly<{
|
|
15
|
+
id: string
|
|
16
|
+
value: string
|
|
17
|
+
onInput?: (value: string) => Msg
|
|
18
|
+
onChange?: (value: string) => Msg
|
|
19
|
+
label?: Html | string
|
|
20
|
+
description?: Html | string
|
|
21
|
+
placeholder?: string
|
|
22
|
+
type?: string
|
|
23
|
+
name?: string
|
|
24
|
+
form?: string
|
|
25
|
+
autocomplete?: string
|
|
26
|
+
inputMode?: InputMode
|
|
27
|
+
describedBy?: string
|
|
28
|
+
/** Extra WAI-ARIA role for composite inputs (e.g. 'searchbox', 'combobox'). */
|
|
29
|
+
role?: string
|
|
30
|
+
ariaLabel?: string
|
|
31
|
+
isDisabled?: boolean
|
|
32
|
+
isReadOnly?: boolean
|
|
33
|
+
isInvalid?: boolean
|
|
34
|
+
isAutofocus?: boolean
|
|
35
|
+
isRequired?: boolean
|
|
36
|
+
/** Extra attributes for the input element itself, after crease's own: an
|
|
37
|
+
* event the props do not name (`h.OnBlur`, `h.OnKeyDown`), a data or ARIA
|
|
38
|
+
* attribute. */
|
|
39
|
+
attributes?: ReadonlyArray<Attribute<Msg>>
|
|
40
|
+
}>
|
|
41
|
+
|
|
42
|
+
export type InputVisualAttributes<Msg> = Readonly<{
|
|
43
|
+
field: ReadonlyArray<Attribute<Msg>>
|
|
44
|
+
label: ReadonlyArray<Attribute<Msg>>
|
|
45
|
+
input: ReadonlyArray<Attribute<Msg>>
|
|
46
|
+
description: ReadonlyArray<Attribute<Msg>>
|
|
47
|
+
}>
|
|
48
|
+
|
|
49
|
+
const descriptionIds = <Msg>(
|
|
50
|
+
props: InputBehaviorProps<Msg>,
|
|
51
|
+
): string | undefined => {
|
|
52
|
+
const ids = [
|
|
53
|
+
...(props.description === undefined
|
|
54
|
+
? []
|
|
55
|
+
: [InputPrimitive.descriptionId(props.id)]),
|
|
56
|
+
...(props.describedBy === undefined ? [] : [props.describedBy]),
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
return ids.length === 0 ? undefined : [...new Set(ids)].join(' ')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const renderInput = <Msg>(
|
|
63
|
+
props: InputBehaviorProps<Msg>,
|
|
64
|
+
visual: InputVisualAttributes<Msg>,
|
|
65
|
+
h: HtmlBuilder<Msg>,
|
|
66
|
+
): Html =>
|
|
67
|
+
InputPrimitive.view(
|
|
68
|
+
{
|
|
69
|
+
id: props.id,
|
|
70
|
+
value: props.value,
|
|
71
|
+
...(props.onInput === undefined ? {} : { onInput: props.onInput }),
|
|
72
|
+
isDisabled: props.isDisabled ?? false,
|
|
73
|
+
isReadOnly: props.isReadOnly ?? false,
|
|
74
|
+
isInvalid: props.isInvalid ?? false,
|
|
75
|
+
isAutofocus: props.isAutofocus ?? false,
|
|
76
|
+
type: props.type ?? 'text',
|
|
77
|
+
...(props.name === undefined ? {} : { name: props.name }),
|
|
78
|
+
...(props.placeholder === undefined
|
|
79
|
+
? {}
|
|
80
|
+
: { placeholder: props.placeholder }),
|
|
81
|
+
toView: ({ input: primitiveInput, label, description }) => {
|
|
82
|
+
const isInteractive =
|
|
83
|
+
props.isDisabled !== true && props.isReadOnly !== true
|
|
84
|
+
const describedBy = descriptionIds(props)
|
|
85
|
+
const inputAttributes = primitiveInput.filter(
|
|
86
|
+
attribute => attribute._tag !== 'AriaDescribedBy',
|
|
87
|
+
)
|
|
88
|
+
const control = h.input([
|
|
89
|
+
...inputAttributes,
|
|
90
|
+
h.DataAttribute('slot', 'input'),
|
|
91
|
+
...(props.onChange === undefined || !isInteractive
|
|
92
|
+
? []
|
|
93
|
+
: [h.OnChange(props.onChange)]),
|
|
94
|
+
...(props.isRequired === true
|
|
95
|
+
? [h.Required(true), h.AriaRequired(true)]
|
|
96
|
+
: []),
|
|
97
|
+
...(props.form === undefined ? [] : [h.FormAttr(props.form)]),
|
|
98
|
+
...(props.autocomplete === undefined
|
|
99
|
+
? []
|
|
100
|
+
: [h.Autocomplete(props.autocomplete)]),
|
|
101
|
+
...(props.inputMode === undefined
|
|
102
|
+
? []
|
|
103
|
+
: [h.InputMode(props.inputMode)]),
|
|
104
|
+
...(props.role === undefined ? [] : [h.Role(props.role)]),
|
|
105
|
+
...(props.ariaLabel === undefined
|
|
106
|
+
? []
|
|
107
|
+
: [h.AriaLabel(props.ariaLabel)]),
|
|
108
|
+
...(describedBy === undefined
|
|
109
|
+
? []
|
|
110
|
+
: [h.AriaDescribedBy(describedBy)]),
|
|
111
|
+
...visual.input,
|
|
112
|
+
...(props.attributes ?? []),
|
|
113
|
+
])
|
|
114
|
+
|
|
115
|
+
if (props.label === undefined && props.description === undefined)
|
|
116
|
+
return control
|
|
117
|
+
|
|
118
|
+
return h.div(
|
|
119
|
+
[
|
|
120
|
+
h.DataAttribute('slot', 'input-field'),
|
|
121
|
+
h.DataAttribute(
|
|
122
|
+
'state',
|
|
123
|
+
props.isInvalid === true ? 'invalid' : 'valid',
|
|
124
|
+
),
|
|
125
|
+
...visual.field,
|
|
126
|
+
],
|
|
127
|
+
[
|
|
128
|
+
...(props.label === undefined
|
|
129
|
+
? []
|
|
130
|
+
: [
|
|
131
|
+
h.label(
|
|
132
|
+
[
|
|
133
|
+
h.DataAttribute('slot', 'input-label'),
|
|
134
|
+
...label,
|
|
135
|
+
...visual.label,
|
|
136
|
+
],
|
|
137
|
+
[props.label],
|
|
138
|
+
),
|
|
139
|
+
]),
|
|
140
|
+
control,
|
|
141
|
+
...(props.description === undefined
|
|
142
|
+
? []
|
|
143
|
+
: [
|
|
144
|
+
h.p(
|
|
145
|
+
[
|
|
146
|
+
h.DataAttribute('slot', 'input-description'),
|
|
147
|
+
...description,
|
|
148
|
+
...visual.description,
|
|
149
|
+
],
|
|
150
|
+
[props.description],
|
|
151
|
+
),
|
|
152
|
+
]),
|
|
153
|
+
],
|
|
154
|
+
)
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
h,
|
|
158
|
+
)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { NodeHttpServer, NodeRuntime, NodeServices } from '@effect/platform-node'
|
|
2
|
+
import { Layer } from 'effect'
|
|
3
|
+
import { HttpRouter, HttpServerResponse } from 'effect/http'
|
|
4
|
+
import { createServer } from 'node:http'
|
|
5
|
+
import { fileURLToPath } from 'node:url'
|
|
6
|
+
|
|
7
|
+
import * as Hyper from '@hyper-effect/core'
|
|
8
|
+
|
|
9
|
+
import * as Auth from '@/app/auth'
|
|
10
|
+
import * as TaskPage from '@/app/tasks/page'
|
|
11
|
+
import { Tasks } from '@/app/tasks/store'
|
|
12
|
+
|
|
13
|
+
/* The server: one hyper-effect layer per page, plain routes for sign-in and
|
|
14
|
+
the stylesheet, the app's services, and the session snapshot store.
|
|
15
|
+
|
|
16
|
+
Environment:
|
|
17
|
+
- PORT (default 4000)
|
|
18
|
+
- APP_SECRET signs the sign-in cookie; HYPER_SNAPSHOT_SECRET seals session
|
|
19
|
+
snapshots. Both have development values in the source: set them
|
|
20
|
+
everywhere else. */
|
|
21
|
+
|
|
22
|
+
const port = Number(process.env['PORT'] ?? 4000)
|
|
23
|
+
const publicFile = (path: string) => fileURLToPath(new URL(`../public/${path}`, import.meta.url))
|
|
24
|
+
|
|
25
|
+
const page: Hyper.Options = {
|
|
26
|
+
stylesheets: ['/styles.css'],
|
|
27
|
+
// Sessions belong to the signed-in user; signing out ends them.
|
|
28
|
+
principal: Auth.principal,
|
|
29
|
+
snapshotSecret: Hyper.secretFromEnvironment('HYPER_SNAPSHOT_SECRET', 'dev-snapshot-secret, set HYPER_SNAPSHOT_SECRET'),
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const Routes = Layer.mergeAll(
|
|
33
|
+
Hyper.layer(TaskPage.program, { ...page, path: '/' }),
|
|
34
|
+
Auth.routes,
|
|
35
|
+
HttpRouter.add('GET', '/styles.css', HttpServerResponse.file(publicFile('styles.css'), { contentType: 'text/css' })),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
HttpRouter.serve(Routes).pipe(
|
|
39
|
+
Layer.provide([
|
|
40
|
+
Tasks.layer,
|
|
41
|
+
// Session snapshots on disk: restart the server and open pages carry on.
|
|
42
|
+
// Several machines: Hyper.sqlStore() on Postgres instead.
|
|
43
|
+
Hyper.fileStore('.hyper-sessions'),
|
|
44
|
+
]),
|
|
45
|
+
Layer.provide(NodeServices.layer),
|
|
46
|
+
Layer.provide(NodeHttpServer.layer(createServer, { port })),
|
|
47
|
+
Layer.launch,
|
|
48
|
+
NodeRuntime.runMain,
|
|
49
|
+
)
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
@import 'tailwindcss';
|
|
2
|
+
|
|
3
|
+
@custom-variant dark (&:is(.dark *));
|
|
4
|
+
@source '../src';
|
|
5
|
+
|
|
6
|
+
@theme inline {
|
|
7
|
+
--font-sans: 'Inter', 'Inter Fallback', ui-sans-serif, system-ui, sans-serif;
|
|
8
|
+
--color-background: var(--background);
|
|
9
|
+
--color-foreground: var(--foreground);
|
|
10
|
+
--color-card: var(--card);
|
|
11
|
+
--color-card-foreground: var(--card-foreground);
|
|
12
|
+
--color-popover: var(--popover);
|
|
13
|
+
--color-popover-foreground: var(--popover-foreground);
|
|
14
|
+
--color-primary: var(--primary);
|
|
15
|
+
--color-primary-foreground: var(--primary-foreground);
|
|
16
|
+
--color-secondary: var(--secondary);
|
|
17
|
+
--color-secondary-foreground: var(--secondary-foreground);
|
|
18
|
+
--color-muted: var(--muted);
|
|
19
|
+
--color-muted-foreground: var(--muted-foreground);
|
|
20
|
+
--color-accent: var(--accent);
|
|
21
|
+
--color-accent-foreground: var(--accent-foreground);
|
|
22
|
+
--color-destructive: var(--destructive);
|
|
23
|
+
--color-border: var(--border);
|
|
24
|
+
--color-input: var(--input);
|
|
25
|
+
--color-ring: var(--ring);
|
|
26
|
+
--color-chart-1: var(--chart-1);
|
|
27
|
+
--color-chart-2: var(--chart-2);
|
|
28
|
+
--color-chart-3: var(--chart-3);
|
|
29
|
+
--color-chart-4: var(--chart-4);
|
|
30
|
+
--color-chart-5: var(--chart-5);
|
|
31
|
+
--color-sidebar: var(--sidebar);
|
|
32
|
+
--color-sidebar-foreground: var(--sidebar-foreground);
|
|
33
|
+
--color-sidebar-primary: var(--sidebar-primary);
|
|
34
|
+
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
|
35
|
+
--color-sidebar-accent: var(--sidebar-accent);
|
|
36
|
+
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
|
37
|
+
--color-sidebar-border: var(--sidebar-border);
|
|
38
|
+
--color-sidebar-ring: var(--sidebar-ring);
|
|
39
|
+
--radius-sm: calc(var(--radius) - 4px);
|
|
40
|
+
--radius-md: calc(var(--radius) - 2px);
|
|
41
|
+
--radius-lg: var(--radius);
|
|
42
|
+
--radius-xl: calc(var(--radius) + 4px);
|
|
43
|
+
--radius-2xl: calc(var(--radius) * 1.8);
|
|
44
|
+
--radius-3xl: calc(var(--radius) * 2.2);
|
|
45
|
+
--radius-4xl: calc(var(--radius) * 2.6);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
:root {
|
|
49
|
+
--radius: 0.625rem;
|
|
50
|
+
--background: oklch(1 0 0);
|
|
51
|
+
--foreground: oklch(0.145 0 0);
|
|
52
|
+
--card: oklch(1 0 0);
|
|
53
|
+
--card-foreground: oklch(0.145 0 0);
|
|
54
|
+
--popover: oklch(1 0 0);
|
|
55
|
+
--popover-foreground: oklch(0.145 0 0);
|
|
56
|
+
--primary: oklch(0.205 0 0);
|
|
57
|
+
--primary-foreground: oklch(0.985 0 0);
|
|
58
|
+
--secondary: oklch(0.97 0 0);
|
|
59
|
+
--secondary-foreground: oklch(0.205 0 0);
|
|
60
|
+
--muted: oklch(0.97 0 0);
|
|
61
|
+
--muted-foreground: oklch(0.556 0 0);
|
|
62
|
+
--accent: oklch(0.97 0 0);
|
|
63
|
+
--accent-foreground: oklch(0.205 0 0);
|
|
64
|
+
--destructive: oklch(0.577 0.245 27.325);
|
|
65
|
+
--border: oklch(0.922 0 0);
|
|
66
|
+
--input: oklch(0.922 0 0);
|
|
67
|
+
--ring: oklch(0.708 0 0);
|
|
68
|
+
--chart-1: oklch(0.646 0.222 41.116);
|
|
69
|
+
--chart-2: oklch(0.6 0.118 184.704);
|
|
70
|
+
--chart-3: oklch(0.398 0.07 227.392);
|
|
71
|
+
--chart-4: oklch(0.828 0.189 84.429);
|
|
72
|
+
--chart-5: oklch(0.769 0.188 70.08);
|
|
73
|
+
--sidebar: oklch(0.985 0 0);
|
|
74
|
+
--sidebar-foreground: oklch(0.145 0 0);
|
|
75
|
+
--sidebar-primary: oklch(0.205 0 0);
|
|
76
|
+
--sidebar-primary-foreground: oklch(0.985 0 0);
|
|
77
|
+
--sidebar-accent: oklch(0.97 0 0);
|
|
78
|
+
--sidebar-accent-foreground: oklch(0.205 0 0);
|
|
79
|
+
--sidebar-border: oklch(0.922 0 0);
|
|
80
|
+
--sidebar-ring: oklch(0.708 0 0);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.dark {
|
|
84
|
+
--background: oklch(0.145 0 0);
|
|
85
|
+
--foreground: oklch(0.985 0 0);
|
|
86
|
+
--card: oklch(0.205 0 0);
|
|
87
|
+
--card-foreground: oklch(0.985 0 0);
|
|
88
|
+
--popover: oklch(0.205 0 0);
|
|
89
|
+
--popover-foreground: oklch(0.985 0 0);
|
|
90
|
+
--primary: oklch(0.922 0 0);
|
|
91
|
+
--primary-foreground: oklch(0.205 0 0);
|
|
92
|
+
--secondary: oklch(0.269 0 0);
|
|
93
|
+
--secondary-foreground: oklch(0.985 0 0);
|
|
94
|
+
--muted: oklch(0.269 0 0);
|
|
95
|
+
--muted-foreground: oklch(0.708 0 0);
|
|
96
|
+
--accent: oklch(0.269 0 0);
|
|
97
|
+
--accent-foreground: oklch(0.985 0 0);
|
|
98
|
+
--destructive: oklch(0.704 0.191 22.216);
|
|
99
|
+
--border: oklch(1 0 0 / 10%);
|
|
100
|
+
--input: oklch(1 0 0 / 15%);
|
|
101
|
+
--ring: oklch(0.556 0 0);
|
|
102
|
+
--chart-1: oklch(0.488 0.243 264.376);
|
|
103
|
+
--chart-2: oklch(0.696 0.17 162.48);
|
|
104
|
+
--chart-3: oklch(0.769 0.188 70.08);
|
|
105
|
+
--chart-4: oklch(0.627 0.265 303.9);
|
|
106
|
+
--chart-5: oklch(0.645 0.246 16.439);
|
|
107
|
+
--sidebar: oklch(0.205 0 0);
|
|
108
|
+
--sidebar-foreground: oklch(0.985 0 0);
|
|
109
|
+
--sidebar-primary: oklch(0.488 0.243 264.376);
|
|
110
|
+
--sidebar-primary-foreground: oklch(0.985 0 0);
|
|
111
|
+
--sidebar-accent: oklch(0.269 0 0);
|
|
112
|
+
--sidebar-accent-foreground: oklch(0.985 0 0);
|
|
113
|
+
--sidebar-border: oklch(1 0 0 / 10%);
|
|
114
|
+
--sidebar-ring: oklch(0.556 0 0);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/* Base layer from crease's theme; newer crease-theme registry items install it. */
|
|
118
|
+
@layer base {
|
|
119
|
+
* {
|
|
120
|
+
border-color: var(--color-border);
|
|
121
|
+
outline-color: color-mix(in oklab, var(--color-ring) 50%, transparent);
|
|
122
|
+
}
|
|
123
|
+
body {
|
|
124
|
+
background-color: var(--color-background);
|
|
125
|
+
color: var(--color-foreground);
|
|
126
|
+
-webkit-font-smoothing: antialiased;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { type VariantProps, cva } from 'class-variance-authority'
|
|
2
|
+
import type { Html, HtmlBuilder } from 'foldkit/html'
|
|
3
|
+
|
|
4
|
+
import { cn } from '@/lib/utils'
|
|
5
|
+
import {
|
|
6
|
+
type ButtonBehaviorProps,
|
|
7
|
+
type ButtonLinkBehaviorProps,
|
|
8
|
+
renderButton,
|
|
9
|
+
renderButtonLink,
|
|
10
|
+
} from '@/lib/button'
|
|
11
|
+
|
|
12
|
+
/* Ported from shadcn/ui button.tsx. The cva config is copied verbatim except:
|
|
13
|
+
- `disabled:` variants became `data-[disabled]:` + `aria-disabled:` because
|
|
14
|
+
the foldkit Button primitive signals disabled state via aria-disabled and
|
|
15
|
+
a data-disabled attribute rather than the native disabled attribute. */
|
|
16
|
+
export const buttonVariants = cva(
|
|
17
|
+
"group/button inline-flex shrink-0 items-center justify-center rounded-md border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px data-[disabled]:pointer-events-none data-[disabled]:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
18
|
+
{
|
|
19
|
+
variants: {
|
|
20
|
+
variant: {
|
|
21
|
+
default: 'bg-primary text-primary-foreground hover:bg-primary/80',
|
|
22
|
+
outline:
|
|
23
|
+
'border-border bg-background shadow-xs hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
|
|
24
|
+
secondary:
|
|
25
|
+
'bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
|
|
26
|
+
ghost:
|
|
27
|
+
'hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50',
|
|
28
|
+
destructive:
|
|
29
|
+
'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40',
|
|
30
|
+
link: 'text-primary underline-offset-4 hover:underline',
|
|
31
|
+
},
|
|
32
|
+
size: {
|
|
33
|
+
default:
|
|
34
|
+
'h-9 gap-1.5 px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
|
|
35
|
+
xs: "h-6 gap-1 rounded-[min(var(--radius-md),8px)] px-2 text-xs in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
|
36
|
+
sm: 'h-8 gap-1 rounded-[min(var(--radius-md),10px)] px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5',
|
|
37
|
+
lg: 'h-10 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
|
|
38
|
+
icon: 'size-9',
|
|
39
|
+
'icon-xs':
|
|
40
|
+
"size-6 rounded-[min(var(--radius-md),8px)] in-data-[slot=button-group]:rounded-md [&_svg:not([class*='size-'])]:size-3",
|
|
41
|
+
'icon-sm':
|
|
42
|
+
'size-8 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-md',
|
|
43
|
+
'icon-lg': 'size-10',
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
defaultVariants: {
|
|
47
|
+
variant: 'default',
|
|
48
|
+
size: 'default',
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
export type ButtonVariants = VariantProps<typeof buttonVariants>
|
|
54
|
+
|
|
55
|
+
export type ButtonProps<Msg> = ButtonBehaviorProps<Msg> &
|
|
56
|
+
Readonly<{
|
|
57
|
+
variant?: ButtonVariants['variant']
|
|
58
|
+
size?: ButtonVariants['size']
|
|
59
|
+
class?: string
|
|
60
|
+
}>
|
|
61
|
+
|
|
62
|
+
export const button = <Msg>(
|
|
63
|
+
props: ButtonProps<Msg>,
|
|
64
|
+
h: HtmlBuilder<Msg>,
|
|
65
|
+
): Html => {
|
|
66
|
+
const variant = props.variant ?? 'default'
|
|
67
|
+
const size = props.size ?? 'default'
|
|
68
|
+
return renderButton(
|
|
69
|
+
props,
|
|
70
|
+
[
|
|
71
|
+
h.DataAttribute('variant', variant),
|
|
72
|
+
...(props.dataSize === undefined ? [h.DataAttribute('size', size)] : []),
|
|
73
|
+
h.Class(cn(buttonVariants({ variant, size }), props.class)),
|
|
74
|
+
],
|
|
75
|
+
h,
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type ButtonLinkProps = ButtonLinkBehaviorProps &
|
|
80
|
+
Readonly<{
|
|
81
|
+
variant?: ButtonVariants['variant']
|
|
82
|
+
size?: ButtonVariants['size']
|
|
83
|
+
class?: string
|
|
84
|
+
}>
|
|
85
|
+
|
|
86
|
+
export const buttonLink = <Msg>(
|
|
87
|
+
props: ButtonLinkProps,
|
|
88
|
+
h: HtmlBuilder<Msg>,
|
|
89
|
+
): Html => {
|
|
90
|
+
const variant = props.variant ?? 'default'
|
|
91
|
+
const size = props.size ?? 'default'
|
|
92
|
+
return renderButtonLink(
|
|
93
|
+
props,
|
|
94
|
+
[
|
|
95
|
+
h.DataAttribute('variant', variant),
|
|
96
|
+
...(props.dataSize === undefined ? [h.DataAttribute('size', size)] : []),
|
|
97
|
+
h.Class(cn(buttonVariants({ variant, size }), props.class)),
|
|
98
|
+
],
|
|
99
|
+
h,
|
|
100
|
+
)
|
|
101
|
+
}
|