@human-synthesis/norns-ui 0.0.1 → 0.0.3

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/README.md CHANGED
@@ -2,7 +2,9 @@
2
2
 
3
3
  UI library for the [Norns](https://github.com/human-synthesis/norns) ecosystem — Pug + Civet components on Tailwind v4.
4
4
 
5
- **Status: Phase 1 (foundation).** Currently ships `Btn` and the atom CSS layer. Forms tier (Input, Field, Form, ) lands in Phase 2; Bits-UI-backed behavior tier (Dialog, Popover, Tabs, Toast, …) in Phase 3.
5
+ **Status: Phase 3 (behavior tier).** Currently ships `Btn`, `Form`, `Field`, `FieldGroup`, `Input`, `Textarea`, `Select`, `Checkbox`, `Radio`, `Switch`, the in-house `ToastProvider` + `toast()` helper, and the Bits-UI-backed `Dialog`, `Sheet`, `Popover`, `Dropdown`, `Tooltip`, `Tabs`. Data tier (Combobox, Listbox, Pagination, Accordion) and polish tier (Avatar, Badge, Spinner, …) land in Phase 4–5.
6
+
7
+ > **Required: scope your preprocessors to project files.** Bits UI ships TypeScript-typed `<script lang="ts">` source, and the default Norns preprocess pipeline (svelte-preprocess + auto-import) corrupts those node_modules files. Wrap each preprocessor with a `node_modules` guard so they no-op on third-party `.svelte` files; vite-plugin-svelte handles them natively from there. See the **Setup** section below for the four-line `scopeToProject` helper.
6
8
 
7
9
  ## Stack
8
10
 
@@ -11,6 +13,7 @@ UI library for the [Norns](https://github.com/human-synthesis/norns) ecosystem
11
13
  - [Civet](https://civet.dev) — `<script>` language
12
14
  - [Tailwind CSS v4](https://tailwindcss.com) — styling, **hard peer dep**
13
15
  - [tailwind-merge](https://github.com/dcastil/tailwind-merge) — class deduplication
16
+ - [Bits UI](https://bits-ui.com) — headless behavior backing `Dialog`/`Popover`/`Tabs`/etc.
14
17
  - `@human-synthesis/norns` `^0.0.7` — peer (`nornsAutoImport` for the registration flow)
15
18
 
16
19
  ## Install
@@ -23,7 +26,7 @@ bun add -D @human-synthesis/norns-ui
23
26
 
24
27
  ### 1. Wire `presetUI()` into `nornsAutoImport`
25
28
 
26
- So `<Btn>`, `<Card>`, `<Field>` resolve in markup without explicit imports.
29
+ So `<Btn>`, `<Field>`, `<Input>`, etc. resolve in markup without explicit imports.
27
30
 
28
31
  ```js
29
32
  // vite.config.js
@@ -53,6 +56,27 @@ Same shape goes in `svelte.config.js`'s `preprocess` array.
53
56
 
54
57
  `componentDirs` resolves first — your `src/lib/components/Btn.n` silently shadows the library's `Btn` whenever you want to override.
55
58
 
59
+ ### 1b. Scope project preprocessors to skip `node_modules`
60
+
61
+ Wrap `nornsPreprocess()` outputs and `nornsAutoImport(...)` so their hooks no-op on third-party `.svelte` files. Bits UI's `lang="ts"` source compiles cleanly under vite-plugin-svelte's native TS handling, but breaks under our project-side svelte-preprocess + auto-import pipeline.
62
+
63
+ ```js
64
+ // svelte.config.js
65
+ const scopeToProject = (p) => ({
66
+ name: p.name,
67
+ markup: p.markup ? (a) => (a.filename?.includes('/node_modules/') ? null : p.markup(a)) : undefined,
68
+ script: p.script ? (a) => (a.filename?.includes('/node_modules/') ? null : p.script(a)) : undefined,
69
+ style: p.style ? (a) => (a.filename?.includes('/node_modules/') ? null : p.style(a)) : undefined
70
+ });
71
+
72
+ export default nornsConfig({
73
+ preprocess: [
74
+ ...nornsPreprocess().map(scopeToProject),
75
+ scopeToProject(nornsAutoImport({ /* … */ }))
76
+ ]
77
+ });
78
+ ```
79
+
56
80
  ### 2. Import the styles
57
81
 
58
82
  ```css
@@ -69,28 +93,32 @@ Same shape goes in `svelte.config.js`'s `preprocess` array.
69
93
  ### 3. Use components in Pug
70
94
 
71
95
  ```pug
72
- section.space-y-3
73
- h1.text-3xl Notes
74
- form(method="POST" action="?/create")
75
- input.input(name="title" required)
96
+ section.space-y-4
97
+ h1.text-3xl New note
98
+
99
+ Form(method="POST" action="?/create")
100
+ Field(label="Title" required error!="{form?.errors?.title}")
101
+ Input(name="title" required)
102
+ Field(label="Body" help="Optional — supports plain text only")
103
+ Textarea(name="body" rows="4")
76
104
  Btn(type="submit" variant="primary") Create note
77
105
  ```
78
106
 
79
- ## What ships
80
-
81
- ### Atoms (CSS-only — Tailwind `@layer components`)
82
-
83
- Use directly via Pug class shorthand:
107
+ ## Component catalog
84
108
 
85
- - `.btn` + variants: `.btn-primary`, `.btn-secondary`, `.btn-ghost`, `.btn-danger`, `.btn-link`
86
- - `.btn` + sizes: `.btn-sm`, `.btn-lg` (default md is built into `.btn`)
87
- - `.ui-spinner` — small inline spinner
109
+ ### Phase 1 atoms
88
110
 
89
- More atoms (`.input`, `.card`, `.badge`, `.field-*`) land in Phase 2.
111
+ CSS classes via `@layer components`. Use directly via Pug class shorthand:
90
112
 
91
- ### Components
113
+ - `.btn` + variants (`primary`, `secondary`, `ghost`, `danger`, `link`) + sizes (`sm`, `lg`)
114
+ - `.input` / `.textarea` / `.select` + `-err` modifier
115
+ - `.checkbox` / `.radio` / `.switch` + `-err` modifier
116
+ - `.field` / `.field-label` / `.field-required` / `.field-help` / `.field-error`
117
+ - `.field-group` / `.field-group-legend`
118
+ - `.form`
119
+ - `.ui-spinner`
92
120
 
93
- #### `<Btn>` ([source](src/components/Btn.n))
121
+ ### Phase 1 — Btn
94
122
 
95
123
  Wrapped `<button>` with class merging, variant/size props, loading state, and snippet-prop API for icons.
96
124
 
@@ -108,12 +136,129 @@ Props (see [`src/types/Btn.d.ts`](src/types/Btn.d.ts)):
108
136
  - `variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'link'` (default `'primary'`)
109
137
  - `size?: 'sm' | 'md' | 'lg'` (default `'md'`)
110
138
  - `loading?: boolean` — replaces leading icon with `.ui-spinner`, sets `disabled` and `aria-busy`
111
- - `disabled?: boolean`
112
- - `type?: 'button' | 'submit' | 'reset'` (default `'button'`)
113
- - `onclick?: (event) => void`
114
- - `class?: string` — merged via [`tailwind-merge`](https://github.com/dcastil/tailwind-merge)
115
- - `children` — default snippet (the button label)
116
- - `leading`, `trailing` — snippet slots for icons
139
+ - `disabled?: boolean`, `type?`, `onclick?`, `class?`, `children`, `leading`, `trailing`
140
+
141
+ ### Phase 2 forms tier
142
+
143
+ #### `<Field>`
144
+
145
+ Wraps a control with label + optional help/error text. Provides `id` and `hasError` to descendant controls via Svelte context — child Inputs auto-pick up the right `id` for `<label for=>` and switch to error styling.
146
+
147
+ ```pug
148
+ Field(label="Email" required error!="{form?.errors?.email}" help="We'll never share it")
149
+ Input(name="email" type="email" required)
150
+ ```
151
+
152
+ Props: `label?`, `help?`, `error?`, `required?`, `id?`, `class?`, `children`.
153
+
154
+ #### `<Form>`, `<FieldGroup>`
155
+
156
+ Styled `<form>` (vertical stack via `.form`) and `<fieldset>` (`.field-group` with optional legend).
157
+
158
+ ```pug
159
+ Form(action="?/save")
160
+ FieldGroup(legend="Profile")
161
+ Field(label="Name")
162
+ Input(name="name" required)
163
+ Field(label="Bio")
164
+ Textarea(name="bio" rows="3")
165
+ Btn(type="submit") Save
166
+ ```
167
+
168
+ #### Form controls
169
+
170
+ All accept `class?` (merged via `cn`), pull `id` + `hasError` from a parent `<Field>` automatically, and surface `aria-invalid` + `aria-describedby` for accessibility:
171
+
172
+ | Component | Bind | Common props |
173
+ |---|---|---|
174
+ | `<Input>` | `bind:value` | `type`, `size`, `name`, `placeholder`, `required`, `disabled`, `readonly`, `autocomplete` |
175
+ | `<Textarea>` | `bind:value` | `name`, `placeholder`, `rows` (default 4), `required`, `disabled`, `readonly` |
176
+ | `<Select>` | `bind:value` | `name`, `required`, `disabled`. Children: `<option>` elements |
177
+ | `<Checkbox>` | `bind:checked` | `name`, `value`, `required`, `disabled` |
178
+ | `<Radio>` | `bind:group` | `name`, `value`, `required`, `disabled` |
179
+ | `<Switch>` | `bind:checked` | `name`, `value`, `required`, `disabled`. Renders as a styled toggle (`role="switch"`) |
180
+
181
+ Each has a hand-rolled `.d.ts` shim under `src/types/`.
182
+
183
+ ### Phase 3 — behavior tier (Bits UI)
184
+
185
+ Headless behavior + accessibility from [Bits UI](https://bits-ui.com), wrapped with default Tailwind styling and a snippet-prop API designed for Pug.
186
+
187
+ #### `<Dialog>`
188
+
189
+ ```pug
190
+ Dialog(bind:open!="{deleteOpen}" title="Delete note?" description="This cannot be undone.")
191
+ +snippet('trigger')
192
+ Btn(variant="danger" size="sm") Delete
193
+ +snippet('actions')
194
+ Btn(variant="ghost" onclick!="{() => deleteOpen = false}") Cancel
195
+ Btn(variant="danger" onclick!="{confirm}") Delete
196
+ ```
197
+
198
+ Props: `open` (bindable), `title`, `description`, `hideClose`, `trigger`, `actions`, `children`, `class`, `overlayClass`, `triggerClass`.
199
+
200
+ #### `<Sheet>`
201
+
202
+ Same API as `Dialog` plus `side?: 'top' | 'right' | 'bottom' | 'left'` (default `'right'`). Slides in from the chosen edge instead of centering.
203
+
204
+ #### `<Popover>` and `<Dropdown>`
205
+
206
+ ```pug
207
+ Popover
208
+ +snippet('trigger')
209
+ Btn(variant="ghost" size="sm") Filters
210
+ .space-y-2
211
+ Field(label="Status")
212
+ Select(name="status")
213
+ option(value="all") All
214
+ option(value="open") Open
215
+
216
+ Dropdown(items!="{[{label:'Edit', onSelect: edit}, {separator: true}, {label:'Delete', onSelect: del}]}")
217
+ +snippet('trigger')
218
+ Btn(variant="ghost") ⋯
219
+ ```
220
+
221
+ #### `<Tooltip>`
222
+
223
+ ```pug
224
+ Tooltip(content="Saved 2 minutes ago")
225
+ +snippet('trigger')
226
+ Btn(variant="ghost" size="sm") ⓘ
227
+ ```
228
+
229
+ #### `<Tabs>`
230
+
231
+ ```pug
232
+ +snippet('panelHuman')
233
+ p Play against another person on this device.
234
+ +snippet('panelCpu')
235
+ p Play against a simple CPU.
236
+
237
+ Tabs(bind:value!="{mode}" items!="{[
238
+ { value: 'human', label: 'Human', panel: panelHuman },
239
+ { value: 'cpu', label: 'CPU', panel: panelCpu }
240
+ ]}")
241
+ ```
242
+
243
+ #### `<ToastProvider>` + `toast()`
244
+
245
+ Mount once near the app root, then call `toast()` from anywhere:
246
+
247
+ ```pug
248
+ // +layout.n
249
+ ToastProvider
250
+ | {@render children?.()}
251
+ ```
252
+
253
+ ```civet
254
+ import { toast } from '@human-synthesis/norns-ui/toast'
255
+
256
+ handleSave := =>
257
+ await save()
258
+ toast 'Saved!', { variant: 'success' }
259
+ ```
260
+
261
+ Variants: `info` (default), `success`, `warning`, `error`. Default duration 4000ms; pass `duration: 0` for sticky.
117
262
 
118
263
  ## Theming
119
264
 
@@ -126,14 +271,15 @@ Dark mode: toggle via `<html data-theme="dark">`. The library's tokens.css ships
126
271
  Every component takes a `class` prop merged via `tailwind-merge` (the `cn` helper):
127
272
 
128
273
  ```svelte
129
- <Btn variant="primary" class="w-full" /> <!-- both classes survive; later wins on conflicts -->
274
+ <Btn variant="primary" class="w-full" />
275
+ <Input class="font-mono" />
130
276
  ```
131
277
 
132
278
  `cn` is exported from `@human-synthesis/norns-ui/cn` if you want to swap in plain `clsx` for smaller bundle:
133
279
 
134
280
  ```js
135
281
  import { cn } from '@human-synthesis/norns-ui/cn';
136
- cn('btn', isActive && 'btn-active', extra)
282
+ cn('btn', isActive && 'btn-active', extra);
137
283
  ```
138
284
 
139
285
  ## Override a component
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@human-synthesis/norns-ui",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
4
  "description": "UI library for the Norns ecosystem — Pug + Civet components on Tailwind v4.",
5
5
  "license": "MIT",
6
6
  "author": "Daniel Teodoroiu (https://humansynthesis.ai)",
@@ -20,6 +20,7 @@
20
20
  ".": "./src/index.js",
21
21
  "./auto-import": "./src/auto-import.js",
22
22
  "./cn": "./src/lib/cn.js",
23
+ "./toast": "./src/lib/toast.svelte.js",
23
24
  "./styles": "./src/styles/index.css",
24
25
  "./styles/tokens": "./src/styles/tokens.css",
25
26
  "./styles/atoms": "./src/styles/atoms.css",
@@ -34,19 +35,20 @@
34
35
  },
35
36
  "peerDependenciesMeta": {
36
37
  "@human-synthesis/norns": {
37
- "optional": false
38
+ "optional": false
39
+ },
40
+ "@human-synthesis/norns-core": {
41
+ "optional": false
42
+ }
38
43
  },
39
- "@human-synthesis/norns-core": {
40
- "optional": false
41
- }
42
- },
43
- "dependencies": {
44
- "tailwind-merge": "^2.5.0"
45
- },
46
- "engines": {
47
- "node": ">=18"
48
- },
49
- "publishConfig": {
50
- "access": "public"
51
- }
44
+ "dependencies": {
45
+ "bits-ui": "^2.18.1",
46
+ "tailwind-merge": "^2.5.0"
47
+ },
48
+ "engines": {
49
+ "node": ">=18"
50
+ },
51
+ "publishConfig": {
52
+ "access": "public"
53
+ }
52
54
  }
@@ -9,39 +9,56 @@
9
9
  *
10
10
  * const ui = presetUI();
11
11
  *
12
- * // vite.config.js
13
12
  * plugins: [
14
13
  * nornsCivetPlugin(),
15
14
  * nornsAutoImport({
16
- * componentDirs: ['src/lib/components'], // user folder wins
17
- * components: ui.components, // norns-ui as fallback
18
- * helpers: ui.helpers
15
+ * componentDirs: ['src/lib/components'],
16
+ * components: ui.components,
17
+ * helpers: [...DEFAULT_HELPERS, ...ui.helpers]
19
18
  * }),
20
19
  * sveltekit()
21
20
  * ]
22
21
  *
23
- * Components are imported via bare specifiers so the consumer's
24
- * `node_modules/@human-synthesis/norns-ui/...` is the resolution target.
25
- * Helpers (functional APIs like `toast()`) are added incrementally as the
26
- * library grows.
27
- *
28
22
  * @typedef {Object} UIPreset
29
- * @property {Record<string, string>} components name → bare-specifier import path
30
- * @property {Array<{ from: string, imports: string[] }>} helpers name groups for nornsAutoImport
23
+ * @property {Record<string, string>} components
24
+ * @property {Array<{ from: string, imports: string[] }>} helpers
31
25
  *
32
26
  * @returns {UIPreset}
33
27
  */
34
28
  export function presetUI() {
35
29
  return {
36
30
  components: {
37
- Btn: '@human-synthesis/norns-ui/components/Btn.n'
38
- // Phase 2+ adds: Input, Textarea, Select, Checkbox, Radio, Switch,
39
- // Field, Form, FieldGroup, Card, Dialog, Sheet, Popover, Dropdown,
40
- // Tooltip, Tabs, Accordion, Toast, Listbox, Combobox, Pagination,
41
- // Avatar, Badge, Spinner, Progress, Skeleton, Icon
31
+ // Phase 1
32
+ Btn: '@human-synthesis/norns-ui/components/Btn.n',
33
+
34
+ // Phase 2 forms tier
35
+ Field: '@human-synthesis/norns-ui/components/Field.n',
36
+ FieldGroup: '@human-synthesis/norns-ui/components/FieldGroup.n',
37
+ Form: '@human-synthesis/norns-ui/components/Form.n',
38
+ Input: '@human-synthesis/norns-ui/components/Input.n',
39
+ Textarea: '@human-synthesis/norns-ui/components/Textarea.n',
40
+ Select: '@human-synthesis/norns-ui/components/Select.n',
41
+ Checkbox: '@human-synthesis/norns-ui/components/Checkbox.n',
42
+ Radio: '@human-synthesis/norns-ui/components/Radio.n',
43
+ Switch: '@human-synthesis/norns-ui/components/Switch.n',
44
+
45
+ // Phase 3 — behavior tier (Bits UI)
46
+ Dialog: '@human-synthesis/norns-ui/components/Dialog.n',
47
+ Sheet: '@human-synthesis/norns-ui/components/Sheet.n',
48
+ Popover: '@human-synthesis/norns-ui/components/Popover.n',
49
+ Dropdown: '@human-synthesis/norns-ui/components/Dropdown.n',
50
+ Tooltip: '@human-synthesis/norns-ui/components/Tooltip.n',
51
+ Tabs: '@human-synthesis/norns-ui/components/Tabs.n',
52
+ ToastProvider: '@human-synthesis/norns-ui/components/ToastProvider.n'
53
+
54
+ // Phase 4+ adds: Accordion, Listbox, Combobox, Pagination,
55
+ // Avatar, Badge, Spinner, Progress, Skeleton, Icon, Card
42
56
  },
43
57
  helpers: [
44
- // Phase 3+: { from: '@human-synthesis/norns-ui', imports: ['toast'] }
58
+ {
59
+ from: '@human-synthesis/norns-ui/toast',
60
+ imports: ['toast', 'notify', 'dismiss']
61
+ }
45
62
  ]
46
63
  };
47
64
  }
@@ -0,0 +1,33 @@
1
+ input(
2
+ type="checkbox"
3
+ id!="{resolvedId}"
4
+ name!="{name}"
5
+ value!="{value}"
6
+ bind:checked
7
+ required!="{required}"
8
+ disabled!="{disabled}"
9
+ aria-invalid!="{hasError ? 'true' : undefined}"
10
+ class!="{classes}"
11
+ )
12
+
13
+ <script>
14
+ import { cn } from '@human-synthesis/norns-ui/cn'
15
+ import { getContext } from 'svelte'
16
+
17
+ {
18
+ checked = $bindable(false)
19
+ value
20
+ name
21
+ id: providedId
22
+ required = false
23
+ disabled = false
24
+ error: explicitError = false
25
+ class: extra = ''
26
+ } .= $props()
27
+
28
+ field := getContext 'norns-ui:field'
29
+ resolvedId := providedId ?? field?.id
30
+ hasError := explicitError || field?.hasError || false
31
+
32
+ classes := cn 'checkbox', hasError && 'checkbox-err', extra
33
+ </script>
@@ -0,0 +1,54 @@
1
+ DialogRoot(bind:open)
2
+ +if('trigger')
3
+ DialogTrigger(class!="{triggerClass ?? undefined}")
4
+ | {@render trigger()}
5
+ DialogPortal
6
+ DialogOverlay(class!="{overlayClasses}")
7
+ DialogContent(class!="{contentClasses}")
8
+ +if('title')
9
+ DialogTitle.dialog-title
10
+ | {title}
11
+ +if('description')
12
+ DialogDescription.dialog-description
13
+ | {description}
14
+ +if('children')
15
+ .dialog-body
16
+ | {@render children()}
17
+ +if('actions')
18
+ .dialog-actions
19
+ | {@render actions()}
20
+ +if('!hideClose')
21
+ DialogClose.dialog-close(aria-label="Close")
22
+ | ✕
23
+
24
+ <script>
25
+ import { Dialog } from 'bits-ui'
26
+ import { cn } from '@human-synthesis/norns-ui/cn'
27
+
28
+ {
29
+ Root: DialogRoot
30
+ Trigger: DialogTrigger
31
+ Portal: DialogPortal
32
+ Overlay: DialogOverlay
33
+ Content: DialogContent
34
+ Title: DialogTitle
35
+ Description: DialogDescription
36
+ Close: DialogClose
37
+ } := Dialog
38
+
39
+ {
40
+ open = $bindable(false)
41
+ title
42
+ description
43
+ hideClose = false
44
+ trigger
45
+ actions
46
+ children
47
+ triggerClass
48
+ overlayClass = ''
49
+ class: extra = ''
50
+ } .= $props()
51
+
52
+ overlayClasses := cn 'dialog-overlay', overlayClass
53
+ contentClasses := cn 'dialog-content', extra
54
+ </script>
@@ -0,0 +1,41 @@
1
+ MenuRoot(bind:open)
2
+ +if('trigger')
3
+ MenuTrigger(class!="{triggerClass ?? undefined}")
4
+ | {@render trigger()}
5
+ MenuPortal
6
+ MenuContent(class!="{contentClasses}" side!="{side}" align!="{align}" sideOffset!="{sideOffset}")
7
+ +each('items as item, i')
8
+ +if('item.separator')
9
+ MenuSeparator.dropdown-separator
10
+ +if('!item.separator')
11
+ MenuItem.dropdown-item(disabled!="{item.disabled}" onSelect!="{item.onSelect}")
12
+ | {item.label}
13
+ | {@render children?.()}
14
+
15
+ <script>
16
+ import { DropdownMenu } from 'bits-ui'
17
+ import { cn } from '@human-synthesis/norns-ui/cn'
18
+
19
+ {
20
+ Root: MenuRoot
21
+ Trigger: MenuTrigger
22
+ Portal: MenuPortal
23
+ Content: MenuContent
24
+ Item: MenuItem
25
+ Separator: MenuSeparator
26
+ } := DropdownMenu
27
+
28
+ {
29
+ open = $bindable(false)
30
+ side = 'bottom'
31
+ align = 'start'
32
+ sideOffset = 4
33
+ items = []
34
+ trigger
35
+ children
36
+ triggerClass
37
+ class: extra = ''
38
+ } .= $props()
39
+
40
+ contentClasses := cn 'dropdown-content', extra
41
+ </script>
@@ -0,0 +1,56 @@
1
+ .field(class!="{classes}")
2
+ +if('label')
3
+ label.field-label(for!="{resolvedId}")
4
+ | {label}
5
+ +if('required')
6
+ span.field-required *
7
+ | {@render children?.()}
8
+ +if('resolvedError')
9
+ p.field-error(role="alert" id!="{resolvedId ? `${resolvedId}-error` : undefined}")
10
+ | {resolvedError}
11
+ +if('!resolvedError && help')
12
+ p.field-help(id!="{resolvedId ? `${resolvedId}-help` : undefined}")
13
+ | {help}
14
+
15
+ <script>
16
+ import { cn } from '@human-synthesis/norns-ui/cn'
17
+ import { setContext, getContext } from 'svelte'
18
+
19
+ {
20
+ label
21
+ help
22
+ error
23
+ name
24
+ required = false
25
+ id: providedId
26
+ children
27
+ class: extra = ''
28
+ } := $props()
29
+
30
+ // Error resolution order:
31
+ // 1. explicit `error` prop wins (caller has full control)
32
+ // 2. else look up `name` in the parent <Form>'s errors map (auto-wired)
33
+ // 3. else no error
34
+ formCtx := getContext 'norns-ui:form'
35
+ resolvedError := $derived error ?? (name && formCtx?.errors?.[name]) ?? undefined
36
+
37
+ // Deterministic id derivation. SSR-safe (no random fallback):
38
+ // 1. explicit `id` prop wins
39
+ // 2. else `name` (single source of truth for submission + id)
40
+ // 3. else slugify the label
41
+ // 4. else undefined — no label, no <label for=> association needed
42
+ slugify := (s) => s.toLowerCase().trim().replace(/[^\w]+/g, '-').replace(/^-+|-+$/g, '')
43
+ resolvedId := $derived providedId ?? name ?? (label && `field-${slugify(label)}`) ?? undefined
44
+
45
+ // Shared with descendants. Getters wrap $derived so context reads
46
+ // remain reactive in both SSR (synchronous) and client (post-mount)
47
+ // — `$effect` would only fire client-side, leaving SSR with stale
48
+ // initial values.
49
+ ctx := {
50
+ get id() { resolvedId }
51
+ get hasError() { !!resolvedError }
52
+ }
53
+ setContext 'norns-ui:field', ctx
54
+
55
+ classes := cn 'field', extra
56
+ </script>
@@ -0,0 +1,17 @@
1
+ fieldset(class!="{classes}")
2
+ +if('legend')
3
+ legend.field-group-legend
4
+ | {legend}
5
+ | {@render children?.()}
6
+
7
+ <script>
8
+ import { cn } from '@human-synthesis/norns-ui/cn'
9
+
10
+ {
11
+ legend
12
+ children
13
+ class: extra = ''
14
+ } := $props()
15
+
16
+ classes := cn 'field-group', extra
17
+ </script>
@@ -0,0 +1,45 @@
1
+ form(
2
+ method!="{method}"
3
+ action!="{action}"
4
+ enctype!="{enctype}"
5
+ class!="{classes}"
6
+ onsubmit!="{onsubmit}"
7
+ )
8
+ | {@render children?.()}
9
+
10
+ <script>
11
+ import { cn } from '@human-synthesis/norns-ui/cn'
12
+ import { setContext } from 'svelte'
13
+
14
+ {
15
+ method = 'POST'
16
+ action
17
+ enctype
18
+ onsubmit
19
+ form
20
+ children
21
+ class: extra = ''
22
+ } := $props()
23
+
24
+ // Errors-by-name map. Built reactively from the valibot issue list so
25
+ // descendant Fields can look up their own error by `name` without a
26
+ // per-page $derived helper.
27
+ //
28
+ // $derived (not $state + $effect) is critical for SSR — $effect runs
29
+ // only client-side, so an effect-based context would produce empty
30
+ // errors during initial server render.
31
+ errorsMap := $derived.by =>
32
+ out := {}
33
+ if form?.errors
34
+ for issue of form.errors
35
+ name := issue.path?.[0]?.key
36
+ if name && !out[name]
37
+ out[name] = issue.message
38
+ out
39
+
40
+ // Getter wraps the $derived so context consumers re-evaluate on read.
41
+ ctx := { get errors() { errorsMap } }
42
+ setContext 'norns-ui:form', ctx
43
+
44
+ classes := cn 'form', extra
45
+ </script>
@@ -0,0 +1,41 @@
1
+ input(
2
+ type!="{type}"
3
+ id!="{resolvedId}"
4
+ name!="{name}"
5
+ bind:value
6
+ placeholder!="{placeholder}"
7
+ required!="{required}"
8
+ disabled!="{disabled}"
9
+ readonly!="{readonly}"
10
+ autocomplete!="{autocomplete}"
11
+ aria-invalid!="{hasError ? 'true' : undefined}"
12
+ aria-describedby!="{describedBy}"
13
+ class!="{classes}"
14
+ )
15
+
16
+ <script>
17
+ import { cn } from '@human-synthesis/norns-ui/cn'
18
+ import { getContext } from 'svelte'
19
+
20
+ {
21
+ value = $bindable('')
22
+ type = 'text'
23
+ size = 'md'
24
+ name
25
+ id: providedId
26
+ placeholder
27
+ required = false
28
+ disabled = false
29
+ readonly = false
30
+ autocomplete
31
+ error: explicitError = false
32
+ class: extra = ''
33
+ } .= $props()
34
+
35
+ field := getContext 'norns-ui:field'
36
+ resolvedId := providedId ?? field?.id
37
+ hasError := explicitError || field?.hasError || false
38
+ describedBy := resolvedId && hasError ? `${resolvedId}-error` : undefined
39
+
40
+ classes := cn 'input', size !== 'md' && `input-${size}`, hasError && 'input-err', extra
41
+ </script>