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,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,6 @@
1
+ import { type ClassValue, clsx } from 'clsx'
2
+ import { twMerge } from 'tailwind-merge'
3
+
4
+ // Verbatim shadcn/ui utils.ts — nothing React-specific in it.
5
+ export const cn = (...inputs: ReadonlyArray<ClassValue>): string =>
6
+ twMerge(clsx(inputs))
@@ -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
+ }