@solidrt/components 0.0.50 → 0.0.51

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.
Files changed (71) hide show
  1. package/AGENTS.md +66 -85
  2. package/README.md +387 -314
  3. package/docs/badge.md +10 -0
  4. package/docs/button.md +21 -0
  5. package/docs/card.md +11 -0
  6. package/docs/checkbox.md +9 -0
  7. package/docs/context-menu.md +17 -0
  8. package/docs/density.md +13 -0
  9. package/docs/divider.md +10 -0
  10. package/docs/field.md +11 -0
  11. package/docs/focus-nav.md +18 -0
  12. package/docs/icon.md +13 -0
  13. package/docs/image.md +21 -0
  14. package/docs/index.md +13 -0
  15. package/docs/item.md +25 -0
  16. package/docs/modal.md +15 -0
  17. package/docs/nav-shell.md +18 -0
  18. package/docs/policy.md +21 -0
  19. package/docs/portal.md +15 -0
  20. package/docs/pressable.md +17 -0
  21. package/docs/progress-bar.md +10 -0
  22. package/docs/qrcode.md +10 -0
  23. package/docs/radio.md +13 -0
  24. package/docs/rich-text-document.md +5 -0
  25. package/docs/rich-text-editor.md +24 -0
  26. package/docs/safe-area.md +12 -0
  27. package/docs/scroll-view.md +14 -0
  28. package/docs/segmented-control.md +13 -0
  29. package/docs/select.md +15 -0
  30. package/docs/slider.md +9 -0
  31. package/docs/spacing.md +7 -0
  32. package/docs/spinner.md +10 -0
  33. package/docs/split-view.md +14 -0
  34. package/docs/switch.md +13 -0
  35. package/docs/text-input.md +23 -0
  36. package/docs/text.md +15 -0
  37. package/docs/theme.md +56 -0
  38. package/docs/tooltip.md +11 -0
  39. package/docs/types.md +5 -0
  40. package/docs/typography.md +5 -0
  41. package/docs/view.md +14 -0
  42. package/docs/window.md +15 -0
  43. package/package.json +6 -3
  44. package/src/badge.tsx +10 -8
  45. package/src/button.tsx +40 -28
  46. package/src/card.tsx +12 -10
  47. package/src/checkbox.tsx +21 -9
  48. package/src/context-menu.tsx +5 -3
  49. package/src/density.tsx +39 -0
  50. package/src/divider.tsx +3 -1
  51. package/src/editor-field.tsx +361 -0
  52. package/src/field.tsx +45 -0
  53. package/src/index.ts +9 -1
  54. package/src/item.tsx +116 -0
  55. package/src/nav-shell.tsx +1 -1
  56. package/src/policy.ts +0 -8
  57. package/src/press.ts +37 -8
  58. package/src/pressable.tsx +4 -1
  59. package/src/progress-bar.tsx +4 -2
  60. package/src/radio.tsx +14 -12
  61. package/src/rich-text-document.ts +247 -0
  62. package/src/rich-text-editor.tsx +149 -0
  63. package/src/segmented-control.tsx +19 -14
  64. package/src/select.tsx +37 -19
  65. package/src/slider.tsx +1 -1
  66. package/src/spacing.ts +1 -1
  67. package/src/spinner.tsx +6 -4
  68. package/src/switch.tsx +2 -1
  69. package/src/text-input.tsx +50 -262
  70. package/src/theme.ts +163 -76
  71. package/src/tooltip.tsx +7 -4
package/README.md CHANGED
@@ -1,20 +1,22 @@
1
+ <!-- GENERATED FILE, do not edit: edit docs/*.md and the interfaces in src/, then run `bun scripts/build-components-docs.ts`. -->
2
+
1
3
  # @solidrt/components
2
4
 
3
- A collection of components for [SolidRT](https://github.com/wellawaretech/solidrt) apps.
5
+ A collection of components for [SolidRT](https://github.com/wellawaretech/solidrt) apps, built on the `@solidrt/core` primitives. Optional: an app can be built with core primitives alone, and a component is just a function returning core elements, so you can always drop down underneath.
4
6
 
5
7
  > LLM agents: see [AGENTS.md](./AGENTS.md) for a dense, self-contained quickstart.
6
8
 
7
9
  ## Installation
8
10
 
9
11
  ```sh
10
- bun add @solidrt/components
12
+ bun add @solidrt/components # peers: @solidrt/core, @solidjs/signals
11
13
  ```
12
14
 
13
- ## Theming
15
+ Per-component prose lives in `docs/`, one file per module; the props are the typed, commented interfaces in `src/` (this package ships its source, so your editor shows them on hover). The README is generated from both.
14
16
 
15
- Appearance (colors, spacing, border, font size) is controlled via a shared, reactive theme. Reads are tracked, so switching the theme at runtime recolors the live UI without remounting.
17
+ ## Theming
16
18
 
17
- Two presets ship out of the box, `darkTheme` and `lightTheme`. The default is dark. Switch by passing a full preset, or apply a targeted override with a partial:
19
+ Appearance (colors, spacing, border, font roles) comes from one shared, reactive theme backed by a Solid store: reads are tracked, so switching the theme at runtime recolors the live UI without remounting. Two presets ship, `darkTheme` and `lightTheme` (default dark); `setTheme(preset)` switches, `setTheme(partial)` merges an override one level deep per category. Custom themes are authored with `defineTheme`.
18
20
 
19
21
  ```jsx
20
22
  import { setTheme, darkTheme, lightTheme } from "@solidrt/components"
@@ -24,35 +26,67 @@ setTheme(darkTheme) // switch to dark
24
26
  setTheme({ color: { primary: "#ff2d55" } }) // override one token
25
27
  ```
26
28
 
27
- `setTheme` merges one level deep per category, so a partial only touches the keys you pass.
29
+ ### Authoring with defineTheme
28
30
 
29
- The color tokens are `background` (window fill), `surface` (control/card fill), `surfaceAlt` (subtle raised/track fill), `text`, `textMuted`, `border`, `primary`, `onPrimary`, `danger`, and `scrim` (modal dim). Non-color tokens are `spacing`, `radius`, `borderWidth`, and `text` (font sizes), shared across presets.
31
+ `defineTheme(definition, scheme?)` resolves a definition into a theme. Any color may be a single value or a `[light, dark]` pair; the `scheme` argument picks the side. Pairs are opt-in per token, and a definition without any needs no scheme at all - modes are a per-theme choice, not a framework requirement (a game ships one look, not two). The built-in presets are one definition resolved twice, so they cannot drift apart.
30
32
 
31
- ## Policies
33
+ ```jsx
34
+ import { defineTheme, setTheme } from "@solidrt/components"
35
+
36
+ let def = {
37
+ color: {
38
+ background: ["#ffffff", "#101014"], // [light, dark]
39
+ primary: "#ff2d55", // same in both
40
+ /* ... every color token ... */
41
+ },
42
+ text: { base: 15, ratio: 1.25 },
43
+ }
44
+
45
+ setTheme(defineTheme(def, "dark"))
46
+ ```
47
+
48
+ The type scale derives from `text.base` (the body size, default 14) and `text.ratio` (default 1.26): caption, label, body, title, heading sit at `base * ratio^(-2..2)`, rounded to whole pixels, with per-role `text.roles` overrides for sizes, line heights, and weights.
49
+
50
+ ### Tokens
32
51
 
33
- Theme answers "how does it look"; policies answer "how does it behave". Policies are a second reactive layer, derived from the platform facts in `@solidrt/core` (`capabilities`, `env`), so components adapt to touch vs. desktop, window size, and display without every app wiring that logic itself.
52
+ The color tokens are `background` (window fill), `surface` (control/card fill), `surfaceAlt` (subtle raised/track fill), `text`, `textMuted`, `border`, `primary`/`onPrimary`, `secondary`/`onSecondary` (lower-emphasis accent), `danger` (validation/destructive), `scrim` (modal dim), and the feedback pair `overlayHover`/`overlayPressed`: translucent tints components draw OVER a control's own fill, so one token pair gives hover/pressed feedback on every fill color, including caller-set ones. Non-color tokens are `spacing`, `radius`, `borderWidth`, and `text` (the type scale: `caption`/`label`/`body`/`title`/`heading` roles, each `{ size, lineHeight, weight }`, plus `fontFamily`).
53
+
54
+ ### Per-component overrides
55
+
56
+ `theme.components` restyles a component everywhere without wrapping it: a `StyleProps` object per component name, merged between the component's themed defaults and each instance's `style` prop (instance style still wins).
34
57
 
35
58
  ```jsx
36
- import { policy, setPolicy, densityScale } from "@solidrt/components"
59
+ setTheme({ components: { button: { borderRadius: 999 } } }) // pill buttons app-wide
60
+ ```
61
+
62
+ Keys: `button`, `card`, `badge`, `switch`, `checkbox`, `radio`, `item`, `select`, `segmentedControl`, `textInput`, `richTextEditor`, `tooltip`, `divider`, `progressBar`, `spinner`.
63
+
64
+ ### Icon slots
37
65
 
38
- policy.interaction // touch vs. desktop affordances (hover, long-press, ...)
39
- policy.density // control/spacing scale
66
+ `theme.icons` holds semantic control glyphs as SVG document strings (the same currency as `Icon`): `chevronDown` (the Select trigger) and `check` (the Checkbox mark). Components draw their built-in vector paths by default; a theme that sets a slot swaps that glyph everywhere it appears, and the package still bundles no icon set.
67
+
68
+ ```jsx
69
+ import ChevronDown from "lucide-static/icons/chevron-down.svg"
70
+
71
+ setTheme({ icons: { chevronDown: ChevronDown } })
40
72
  ```
41
73
 
42
- `policy` fields:
74
+ API: `theme`, `setTheme`, `defineTheme`, `darkTheme`, `lightTheme`, `Theme`, `ThemeDefinition`, `ThemeColor`, `ThemedComponent`, `TextStyle`, `TextVariant` - typed and commented in [src/theme.ts](./src/theme.ts).
75
+
76
+ ## Policies
77
+
78
+ Theme answers "how does it look"; policies answer "how does it behave". `policy` is a second reactive layer derived from the platform facts in `@solidrt/core` (`capabilities`, `env`), so components adapt to touch vs. desktop, window size, and display without every app wiring that logic itself. Reads are reactive like `theme`: a window resize or the first mouse move on a touch-capable device updates every consuming component live.
43
79
 
44
- | Field | Type | Description |
45
- | ----------------- | ---------------------------------------------- | ----------------------------------------------------------------------------- |
46
- | `interaction` | `"touch" \| "desktop" \| "hybrid"` | Which interaction affordances a component shows (hover states vs. long-press). |
47
- | `density` | `"comfortable" \| "compact" \| "dense"` | Control/hit-target/spacing scale; see `densityScale()`. |
48
- | `motion` | `"normal" \| "reduced" \| "none"` | Animation intensity. |
49
- | `focusRing` | `boolean` | Whether focused controls draw a visible focus indicator. |
50
- | `textScale` | `number` | Multiplier on type-scale font sizes; defaults to the OS text-scale preference. |
51
- | `textWeightDelta` | `number` | Weight compensation (in steps of 100) for light-on-dark text on low-DPI displays. |
52
- | `navigation` | `"bottomTabs" \| "rail" \| "sidebar"` | Recommended nav layout, derived from window size class. |
53
- | `layout` | `"singlePane" \| "twoPane"` | Recommended single vs. two-pane layout, derived from window size class. |
80
+ The fields:
54
81
 
55
- Reads are reactive like `theme`, so a window resize or the first mouse move on a touch-capable device updates every consuming component live.
82
+ - `interaction` (`"touch" | "desktop" | "hybrid"`) - which affordances a component shows (hover states vs. long-press). `Tooltip`, `Select`, and `ContextMenu` fork on it.
83
+ - `density` (`"comfortable" | "compact" | "dense"`) - control/hit-target/spacing scale; drives `densityScale()` (1 / 0.85 / 0.7). A `<Density>` region overrides it per subtree.
84
+ - `motion` (`"normal" | "reduced" | "none"`) - animation intensity.
85
+ - `focusRing` (`boolean`) - whether focused controls draw a visible focus indicator (true when a keyboard or gamepad/remote is present).
86
+ - `textScale` (`number`) - multiplier on type-scale font sizes; defaults to the OS text-scale preference.
87
+ - `textWeightDelta` (`number`) - weight compensation (steps of 100) for light-on-dark text on low-DPI displays.
88
+ - `navigation` (`"bottomTabs" | "rail" | "sidebar"`) - recommended nav layout, derived from the window size class. `NavShell` follows it.
89
+ - `layout` (`"singlePane" | "twoPane"`) - recommended pane count, derived from the window size class. `SplitView` follows it.
56
90
 
57
91
  ```jsx
58
92
  setPolicy({ density: "compact" }) // pin a field, overriding the derived value
@@ -61,35 +95,39 @@ setPolicy({ density: undefined }) // hand it back to the resolver
61
95
 
62
96
  `setPolicyResolver((caps) => Policies)` replaces the whole system-derivation function for full custom control; `defaultPolicyResolver` is exported to wrap or extend instead of replacing it outright.
63
97
 
64
- `densityScale()` is a reactive multiplier (1 / 0.85 / 0.7 for comfortable/compact/dense) driven by `policy.density`, used internally for spacing and hit-target sizing.
98
+ API: `policy`, `setPolicy`, `setPolicyResolver`, `defaultPolicyResolver`, `Policies`, `PolicyResolver`, `InteractionPolicy`, `DensityPolicy`, `MotionPolicy`, `NavigationPolicy`, `LayoutPolicy` - typed and commented in [src/policy.ts](./src/policy.ts).
65
99
 
66
100
  ## Layout and style
67
101
 
68
- Most components group their props into two objects:
102
+ Most components group their props into two objects, split by one rule: `layout` properties feed the layout engine (flexbox/grid, sizing, padding, margin, position - the core `LayoutProps` set) and changing them triggers a relayout; `style` properties are paint-only and never affect layout: `color`, `backgroundColor`, `borderColor`, `borderWidth`, `borderRadius`, `opacity`, and the transform (`x`, `y`, `scale`, `rotate`, `rotateX`/`rotateY` with `perspective`, `originX`/`originY`, `clipRadius`). Event handlers (`onPointerDown`, `onKeyDown`, ...) are top-level props, never inside `layout` or `style`.
103
+
104
+ `StyleProps` is that paint set. `TextLayoutProps` extends `LayoutProps` with the font fields (`fontFamily`, `fontSize`, `lineHeight`, `fontStyle`, `fontWeight`, `textAlign`, `maxLines`) because text shaping affects measurement; note `lineHeight` is a multiplier of `fontSize` (the theme uses 1.3-1.6), not a pixel value. `Option` (`{ value, label }`) is the shared shape of the single-choice controls (`Select`, `SegmentedControl`): shared shapes go through this module so components never import a sibling.
105
+
106
+ API: `StyleProps`, `TextLayoutProps`, `Option` - typed and commented in [src/types.ts](./src/types.ts).
107
+
108
+ ## Typography helpers
69
109
 
70
- - `layout` - properties that feed the layout engine (flexbox/grid, sizing, padding, margin, position). Changing them triggers a relayout. This is the core `LayoutProps` set.
71
- - `style` - paint-only properties that never affect layout: `color`, `backgroundColor`, `borderColor`, `borderWidth`, `borderRadius`, and the transform `x`, `y`, `rotate`, `scale`.
110
+ `typeStyle(variant)` resolves a theme type-scale role (`caption`/`label`/`body`/`title`/`heading`) to font props ready to spread onto a `<text>` or `d-text`: `fontSize` carries `policy.textScale`, and `fontWeight` carries the low-DPI weight compensation. Reactive when called inside a tracked scope, like any theme/policy read. `Text` applies it for you; reach for the helpers when building custom text out of core primitives.
72
111
 
73
- Event handlers (`onPointerDown`, `onKeyDown`, etc.) are passed directly as top-level props.
112
+ The compensation exists because the renderer rasterizes glyphs unhinted and composites in nonlinear sRGB, which thins light-on-dark text on low-DPI displays as glyphs shrink. `typeWeight(weight, size, onDark?)` adds `policy.textWeightDelta` (0 on high-DPI displays) plus one extra step below 16px; dark-on-light text passes through untouched. `lightOnDark(text, fill)` computes the polarity for a known pair of colors (Button uses it for its fills); omitted, the theme's own palette polarity is used.
74
113
 
75
- `StyleProps`:
114
+ API: `typeStyle`, `typeWeight`, `lightOnDark` - typed and commented in [src/typography.ts](./src/typography.ts).
115
+
116
+ ## Spacing
117
+
118
+ `space(token)` is density-scaled spacing: a `theme.spacing` token (`sm`/`md`/`lg`/`xl`) multiplied by the density scale of the nearest `<Density>` region (falling back to the global policy) and rounded to whole pixels. Use it for gaps and paddings that should tighten under `compact`/`dense` density; read `theme.spacing` directly only for distances that must not move with density. Reactive when called inside a tracked scope.
119
+
120
+ ```jsx
121
+ <View layout={{ padding: space("md"), gap: space("sm") }} />
122
+ ```
76
123
 
77
- | Prop | Type | Description |
78
- | ----------------- | ------------------------------------------ | ------------------------------------ |
79
- | `color` | `string` | Text color (used by `Text`). |
80
- | `backgroundColor` | `string` | Fill color. |
81
- | `borderColor` | `string` | Stroke color (when `borderWidth` set). |
82
- | `borderWidth` | `number` | Border stroke width. |
83
- | `borderRadius` | `number \| [number, number, number, number]` | Corner radius. |
84
- | `x` / `y` | `number` | Translation offset. |
85
- | `rotate` | `number` | Rotation. |
86
- | `scale` | `number` | Scale factor. |
124
+ API: `space` - typed and commented in [src/spacing.ts](./src/spacing.ts).
87
125
 
88
126
  ## Components
89
127
 
90
128
  ### Window
91
129
 
92
- The root surface of an app. Accepts `layout` and the `backgroundColor` from `style`; a window cannot be transformed or bordered.
130
+ The root surface of an app: renders a core `<window>`, so `render()` accepts it. Applies `layout` and `style.backgroundColor` only (a window cannot be transformed or bordered), plus `title` and `fullscreen`.
93
131
 
94
132
  ```jsx
95
133
  import { Window } from "@solidrt/components"
@@ -103,19 +141,11 @@ function App() {
103
141
  }
104
142
  ```
105
143
 
106
- **Props**
107
-
108
- | Prop | Type | Default | Description |
109
- | ------------ | ------------- | ------- | ------------------------------------ |
110
- | `title` | `string` | - | Window title. |
111
- | `fullscreen` | `boolean` | - | Open fullscreen. |
112
- | `layout` | `LayoutProps` | - | Layout properties. |
113
- | `style` | `StyleProps` | - | Only `backgroundColor` is applied. |
114
- | `children` | `any` | - | Content. |
144
+ API: `Window`, `WindowProps` - typed and commented in [src/window.tsx](./src/window.tsx).
115
145
 
116
146
  ### View
117
147
 
118
- A general-purpose box. Spreads `layout` onto the underlying view, applies the transform from `style`, and draws a background and/or border when those style props are set.
148
+ A general-purpose box. Spreads `layout` onto the underlying view, applies the transform from `style`, and draws a background and/or border when those style props are set. Takes all pointer event props.
119
149
 
120
150
  ```jsx
121
151
  import { View } from "@solidrt/components"
@@ -128,92 +158,68 @@ import { View } from "@solidrt/components"
128
158
  </View>
129
159
  ```
130
160
 
131
- **Props**
132
-
133
- Accepts all pointer event props, plus:
134
-
135
- | Prop | Type | Description |
136
- | ---------- | ----------------------------- | --------------------- |
137
- | `layout` | `LayoutProps` | Layout properties. |
138
- | `style` | `StyleProps` | Paint properties. |
139
- | `ref` | `(node: { id: number }) => void` | Node reference. |
140
- | `children` | `any` | Content. |
161
+ API: `View`, `ViewProps` - typed and commented in [src/view.tsx](./src/view.tsx).
141
162
 
142
163
  ### Text
143
164
 
144
- Renders text inside a layout box. Font properties live in `layout` (they affect measurement); `color` lives in `style`.
165
+ Themed text in a layout box. `variant` picks a typography role from the theme's type scale (`caption`/`label`/`body`/`title`/`heading`, default `body`); `color` picks a semantic theme color (`text`, `textMuted`, `primary`, `onPrimary`, `danger`, default `text`), with `muted` as sugar for `color="textMuted"`. Font fields go in `layout` (they affect measurement) and individually override the role; `style.color` still wins over `color`.
166
+
167
+ Font sizes carry `policy.textScale` (the OS text-size preference) and weights carry the low-DPI light-on-dark compensation; use the core `<text>` primitive for text that must not scale.
145
168
 
146
169
  ```jsx
147
170
  import { Text } from "@solidrt/components"
148
171
 
149
- <Text layout={{ fontSize: 18, maxLines: 2 }} style={{ color: "#fff" }}>
150
- Hello
151
- </Text>
172
+ <Text variant="title">Section</Text>
173
+ <Text muted layout={{ maxLines: 2 }}>Supporting copy that may wrap.</Text>
174
+ <Text layout={{ fontSize: 18 }} style={{ color: "#fff" }}>Custom</Text>
152
175
  ```
153
176
 
154
- `layout` accepts all `LayoutProps` plus the font fields `fontFamily`, `fontSize`, `lineHeight`, `fontStyle`, `fontWeight`, `textAlign`, and `maxLines`. Note that `lineHeight` is a multiplier of `fontSize` (the theme uses 1.3-1.6), not a pixel value.
155
-
156
- **Props**
177
+ Note that `lineHeight` is a multiplier of `fontSize` (the theme uses 1.3-1.6), not a pixel value.
157
178
 
158
- Accepts all pointer event props, plus:
159
-
160
- | Prop | Type | Description |
161
- | ---------- | ----------------------------- | --------------------------------- |
162
- | `layout` | `TextLayoutProps` | Layout properties plus font fields. |
163
- | `style` | `StyleProps` | `color` and transform. |
164
- | `ref` | `(node: { id: number }) => void` | Node reference. |
165
- | `children` | `any` | Text content. |
179
+ API: `Text`, `TextProps`, `TextColor` - typed and commented in [src/text.tsx](./src/text.tsx).
166
180
 
167
181
  ### Image
168
182
 
169
- Loads and displays an image from a URL or raw bytes. URL loads are shared
170
- runtime-wide: mounts of the same URL reuse one fetch and one texture, and the
171
- bytes are cached on disk (fetched with `cache: "force-cache"` - no freshness
172
- check, so use versioned URLs for content that changes). The runtime keeps
173
- concurrent asset fetches polite with a per-host limit; a failed load rejects
174
- the mounts sharing it and a later remount retries.
183
+ Loads and displays an image from a URL or raw bytes (`src: string | Uint8Array`). URL loads are shared runtime-wide: mounts of the same URL reuse one fetch and one texture, and the bytes are cached on disk (fetched with `cache: "force-cache"` - no freshness check, so use versioned URLs for content that changes). Concurrent asset fetches are kept polite with a per-host limit; a failed load rejects the mounts sharing it and a later remount retries.
175
184
 
176
185
  ```jsx
177
186
  import { Image } from "@solidrt/components"
178
187
 
179
- function Avatar() {
180
- return (
181
- <Image
182
- src="https://example.com/avatar.png"
183
- fallback={PLACEHOLDER_PNG}
184
- layout={{ width: 64, height: 64 }}
185
- />
186
- )
187
- }
188
+ <Image
189
+ src="https://example.com/avatar.png"
190
+ fallback={PLACEHOLDER_PNG}
191
+ layout={{ width: 64, height: 64 }}
192
+ />
193
+ ```
194
+
195
+ With `fit` the image fills whatever box `layout` gives the component - numbers, `pct()`, or flex - and the fit decides how the pixels map into it (CSS object-fit, centered; `"cover"` is the ported-web-hero-image answer). Without `fit`, only numeric layout sizes reach the image; anything else draws at intrinsic size.
196
+
197
+ ```jsx
198
+ <Image src={hero} fit="cover" layout={{ width: pct(100), height: 240 }} />
188
199
  ```
189
200
 
190
- **Props**
201
+ A failing `src` is contained by the component: the `fallback` shows, or the `backgroundColor` placeholder stays; the error does not propagate to an outer `<Errored>` boundary. `onLoad` fires each time a source finishes loading, `onError` when `src` fails.
191
202
 
192
- Accepts `layout`, `style`, and all pointer event props, plus:
203
+ API: `Image`, `ImageProps` - typed and commented in [src/image.tsx](./src/image.tsx).
193
204
 
194
- | Prop | Type | Description |
195
- | ---------- | ------------------------ | ------------------------------------------------------------------------------------ |
196
- | `src` | `string \| Uint8Array` | URL to fetch, or raw image bytes to decode |
197
- | `fit` | `"fill" \| "cover" \| "contain" \| "none" \| "scale-down"` | How the image maps into the box (CSS object-fit, centered) |
198
- | `fallback` | `string \| Uint8Array` | Source shown when `src` fails; if it also fails, the `backgroundColor` placeholder stays |
199
- | `onLoad` | `() => void` | Called each time a source finishes loading |
200
- | `onError` | `(err: unknown) => void` | Called when `src` fails to load or decode |
205
+ ### SafeArea
201
206
 
202
- With `fit` the image fills whatever box `layout` gives the component - numbers,
203
- `pct()`, or flex - and the fit decides how the pixels map into it (`"cover"` is
204
- the ported-web-hero-image answer). Without `fit`, only *numeric* layout sizes
205
- reach the image; anything else draws at intrinsic size.
207
+ Wraps its children in a view padded clear of system UI intrusions (status bars, home indicators, notches). Top and bottom insets are applied by default; pass `false` to opt out of an edge, or a number to apply the inset with that minimum padding.
206
208
 
207
209
  ```jsx
208
- <Image src={hero} fit="cover" layout={{ width: pct(100), height: 240 }} />
210
+ import { SafeArea } from "@solidrt/components"
211
+
212
+ <SafeArea top bottom>...</SafeArea> // the default edges
213
+ <SafeArea bottom={false}>...</SafeArea> // top only
214
+ <SafeArea top={16} bottom={16}>...</SafeArea> // insets with a 16px minimum
215
+ <SafeArea top bottom left right>...</SafeArea> // all four edges
209
216
  ```
210
217
 
211
- A failing `src` is contained by the component (the fallback or placeholder
212
- shows); it does not propagate to an outer `<Errored>` boundary.
218
+ API: `SafeArea` - typed and commented in [src/safe-area.tsx](./src/safe-area.tsx).
213
219
 
214
220
  ### TextInput
215
221
 
216
- Single-line text input.
222
+ Text input, single-line by default; `multiline` wraps at the field's width and edits across lines (Enter inserts a newline, Up/Down move by line; grows with content up to `maxRows` unless `layout.height` fixes the box, and scrolls to the caret). Controlled via `value`/`onInput`, or uncontrolled via `defaultValue`; `onSubmit` fires on Enter (single-line only). Also `placeholder`, `maxLength`, `autoFocus`, `disabled`, `onFocus`/`onBlur`, and `hints` for IME behavior (keyboard type, capitalization, autocorrect - identifier-like fields want `{ capitalize: "none", autocorrect: false }`).
217
223
 
218
224
  ```jsx
219
225
  import { TextInput } from "@solidrt/components"
@@ -233,95 +239,65 @@ function NameField() {
233
239
  }
234
240
  ```
235
241
 
236
- **Props**
237
-
238
- | Prop | Type | Default | Description |
239
- | -------------- | ------------------------- | ------- | ------------------------------------------------------------ |
240
- | `value` | `string` | - | Controlled value. If omitted, the component is uncontrolled. |
241
- | `defaultValue` | `string` | `""` | Initial value for uncontrolled use. |
242
- | `onInput` | `(value: string) => void` | - | Fires on every change. |
243
- | `onSubmit` | `(value: string) => void` | - | Fires on Enter. |
244
- | `onFocus` | `() => void` | - | Fires when the field gains focus. |
245
- | `onBlur` | `() => void` | - | Fires when the field loses focus. |
246
- | `placeholder` | `string` | - | Shown when value is empty and the field is not focused. |
247
- | `maxLength` | `number` | - | Truncates input to this length. |
248
- | `disabled` | `boolean` | `false` | Ignores pointer and key events when true. |
249
- | `autoFocus` | `boolean` | `false` | Focuses on mount (the on-screen keyboard waits for a tap). |
250
- | `hints` | `TextInputHints` | - | IME behavior: keyboard type, capitalization, autocorrect. |
251
- | `layout` | `LayoutProps` | - | Layout properties (e.g. `width`). |
252
- | `style` | `StyleProps` | - | Overrides theme colors, border, and radius. |
242
+ `style` overrides the themed colors, border, and radius. `autoFocus` focuses on mount (the on-screen keyboard still waits for a tap).
253
243
 
254
- ### SafeArea
244
+ API: `TextInput`, `TextInputProps` - typed and commented in [src/text-input.tsx](./src/text-input.tsx).
245
+
246
+ ### RichTextEditor
255
247
 
256
- Wraps its children in a view that applies padding to avoid system UI intrusions (status bars, home indicators, notches, etc.).
248
+ Edits a rich text `Document` (styled runs, paragraph attributes) in the same field as `TextInput`: always multiline, same caret, keys, wrapping, and scrolling. Controlled via `value`/`onInput`, or uncontrolled via `defaultValue` (start from `plainDocument("")`). Formatting is driven through `editorRef`, which hands you the document buffer - the component ships no toolbar; the app renders its own controls.
257
249
 
258
250
  ```jsx
259
- import { SafeArea } from "@solidrt/components"
251
+ import { RichTextEditor, plainDocument } from "@solidrt/components"
260
252
 
261
- function App() {
253
+ function Notes() {
254
+ let editor
262
255
  return (
263
- <Window>
264
- <SafeArea top bottom>
265
- <Text>Content clear of system UI</Text>
266
- </SafeArea>
267
- </Window>
256
+ <>
257
+ <Button onPress={() => editor.format({ bold: editor.attributes().bold ? null : true })}>B</Button>
258
+ <RichTextEditor
259
+ defaultValue={plainDocument("Start typing...")}
260
+ editorRef={(e) => (editor = e)}
261
+ layout={{ width: 320 }}
262
+ maxRows={10}
263
+ />
264
+ </>
268
265
  )
269
266
  }
270
267
  ```
271
268
 
272
- Top and bottom insets are applied by default. Pass `false` to opt out of an edge, or a number to apply the inset with a minimum padding.
269
+ Drawn attributes - inline: `bold`, `italic`, `underline`, `code` (mono), `color` (a color string), `link` (a URL string: primary color, underlined); block: `heading: 1 | 2 | 3`. Other attributes are carried in the document and ignored by the drawing; font-affecting ones feed the text geometry too, so caret and wrap follow the drawn glyphs. Inline atoms (U+FFFC) render as their placeholder character for now.
273
270
 
274
- ```jsx
275
- // top only
276
- <SafeArea bottom={false}>
271
+ API: `RichTextEditor`, `RichTextEditorProps` - typed and commented in [src/rich-text-editor.tsx](./src/rich-text-editor.tsx).
277
272
 
278
- // apply top and bottom insets, with a minimum of 16px each
279
- <SafeArea top={16} bottom={16}>
273
+ ### Document model
280
274
 
281
- // all four edges
282
- <SafeArea top bottom left right>
283
- ```
275
+ The value model behind `RichTextEditor`: a `Document` is `{ text, runs, blocks }` - plain text plus attributed runs (inline formatting) and per-paragraph blocks. `plainDocument(text)` builds one from a string; `createDocumentBuffer(doc)` wraps one in the editing API (`format`, `formatBlock`, `insertAtom`, `attributes`, selection and edits) that `editorRef` hands out. `ATOM` is the inline-atom placeholder character (U+FFFC) for embedded objects.
284
276
 
285
- **Props**
277
+ The shapes (`Document`, `DocumentRun`, `Attributes`, `AttributePatch`, `DocumentBuffer`) are exported so an app can build, inspect, persist, and transform documents outside the editor.
286
278
 
287
- | Prop | Type | Default | Description |
288
- | ---------- | ------------------- | ------- | ------------------------------------------------------ |
289
- | `top` | `boolean \| number` | `true` | Apply top inset. A number sets the minimum padding. |
290
- | `bottom` | `boolean \| number` | `true` | Apply bottom inset. A number sets the minimum padding. |
291
- | `left` | `boolean \| number` | `false` | Apply left inset. A number sets the minimum padding. |
292
- | `right` | `boolean \| number` | `false` | Apply right inset. A number sets the minimum padding. |
293
- | `children` | `any` | - | Content to render inside the safe area. |
279
+ API: `createDocumentBuffer`, `plainDocument`, `ATOM`, `Document`, `DocumentRun`, `DocumentBuffer`, `DocumentBufferOptions`, `Attributes`, `AttributePatch` - typed and commented in [src/rich-text-document.ts](./src/rich-text-document.ts).
294
280
 
295
281
  ### ScrollView
296
282
 
297
- A scrollable region. Scrolls vertically by default; pass `horizontal` to scroll the other axis instead. Both the wheel and dragging scroll the content. The drag activates after a small movement threshold along the scroll axis, also when it starts on a pressable (the press is cancelled and its feedback retracts), and keeps scrolling when the pointer leaves the box. There is no momentum/fling yet.
283
+ A scrollable region; vertical by default, `horizontal` to flip. Both the wheel and dragging scroll the content: the drag activates after a small movement threshold along the scroll axis, also when it starts on a pressable (the press is cancelled and its feedback retracts), and keeps scrolling when the pointer leaves the box. No momentum/fling yet.
298
284
 
299
285
  ```jsx
300
286
  import { ScrollView, Text } from "@solidrt/components"
301
287
  import { For } from "@solidrt/core"
302
288
 
303
289
  <ScrollView layout={{ height: 300 }} style={{ backgroundColor: "#111", borderRadius: 8 }}>
304
- <For each={items()}>{(item) => <Text style={{ color: "#fff" }}>{item}</Text>}</For>
290
+ <For each={items()}>{(item) => <Text>{item}</Text>}</For>
305
291
  </ScrollView>
306
292
  ```
307
293
 
308
- **Props**
309
-
310
- Accepts all pointer event props, plus:
311
-
312
- | Prop | Type | Description |
313
- | ------------ | -------------------------------- | -------------------------------------------- |
314
- | `horizontal` | `boolean` | Scroll the horizontal axis instead of vertical. |
315
- | `layout` | `LayoutProps` | Layout of the outer box (e.g. `height`). |
316
- | `style` | `StyleProps` | Background, border, and transform. |
317
- | `ref` | `(node: { id: number }) => void` | Reference to the outer box. |
318
- | `children` | `any` | Scrollable content. |
319
-
320
294
  The underlying geometry primitive `createScroll` is available from `@solidrt/core` for building custom scrollers.
321
295
 
296
+ API: `ScrollView`, `ScrollViewProps` - typed and commented in [src/scroll-view.tsx](./src/scroll-view.tsx).
297
+
322
298
  ### Pressable
323
299
 
324
- A pressable box. `onPress` fires on a primary-button press released over the box; a drag out of the box (or a non-primary button) does not fire it. `children` and `style` may each be a function of the `{ pressed, hovered }` state, so the box can restyle on press/hover without extra signals.
300
+ A pressable box: `onPress` fires on a primary-button press released over the box; a drag out of the box (or a non-primary button) does not fire it, and a drag back in restores the pressed state. `children` and `style` may each be a function of the live `{ pressed, hovered, pending }` state, so the box restyles on press/hover without extra signals - read the state inside the prop or child expression, never eagerly into a local.
325
301
 
326
302
  ```jsx
327
303
  import { Pressable, Text } from "@solidrt/components"
@@ -331,49 +307,41 @@ import { Pressable, Text } from "@solidrt/components"
331
307
  layout={{ padding: 12 }}
332
308
  style={(s) => ({ backgroundColor: s.pressed ? "#333" : "#222", borderRadius: 8 })}
333
309
  >
334
- <Text style={{ color: "#fff" }}>Tap me</Text>
310
+ <Text>Tap me</Text>
335
311
  </Pressable>
336
312
  ```
337
313
 
338
- **Props**
339
-
340
- Accepts all pointer event props, plus:
314
+ `disabled` takes no pointer events. When pressables nest, the innermost one wins the press. An `onPress` returning a promise sets `pending` until it settles; presses meanwhile are ignored, so async actions cannot double-fire.
341
315
 
342
- | Prop | Type | Description |
343
- | ---------- | --------------------------------------------------- | -------------------------------------------- |
344
- | `onPress` | `() => void` | Fires on a completed press. |
345
- | `disabled` | `boolean` | Takes no pointer events when true. |
346
- | `layout` | `LayoutProps` | Layout properties. |
347
- | `style` | `StyleProps \| (state) => StyleProps` | Paint properties, or a function of state. |
348
- | `children` | `any \| (state) => any` | Content, or a function of state. |
349
- | `ref` | `(node: { id: number }) => void` | Node reference. |
316
+ API: `Pressable`, `PressableProps`, `PressState` - typed and commented in [src/pressable.tsx](./src/pressable.tsx).
350
317
 
351
318
  ### Button
352
319
 
353
- Themed convenience over `Pressable`: a padded, centered, accent-colored box with a label that scales slightly on press. A string or number child is rendered as the themed label; any other child renders as-is. Colors come from the theme (`color.primary`, `color.onPrimary`); override per-button via `style`.
320
+ A themed press target over `Pressable`: a padded, centered box with a label. `variant` picks the visual role - `primary` (accent fill, the default), `secondary`, `ghost` (no fill until hover), `danger` (destructive) - with fill, hover tint, and label color from the matching theme tokens; no variant draws a border. `size` (`sm`/`md`/`lg`) pins a minimum width so a row of buttons lines up (a longer label still expands past it); omitted, the button sizes to its content. A string or number child renders as the themed label; any other child renders as-is (an icon, a row, ...).
354
321
 
355
322
  ```jsx
356
323
  import { Button } from "@solidrt/components"
357
324
 
358
- <Button onPress={save} layout={{ minWidth: 120 }}>Save</Button>
325
+ <Button onPress={save}>Save</Button>
326
+ <Button variant="ghost" onPress={cancel}>Cancel</Button>
327
+ <Button variant="danger" size="md" onPress={remove}>Delete</Button>
359
328
  ```
360
329
 
361
- **Props**
330
+ Press feedback is a slight scale; hover feedback is the theme's `overlayHover` tint drawn over the fill (non-touch interaction policies only), so it composes with any background, including a caller-set `style.backgroundColor`. `disabled` mutes the colors and takes no pointer events.
331
+
332
+ An `onPress` returning a promise makes the button an async action: while it is unsettled a centered spinner replaces the label (geometry unchanged, so nothing shifts) and further presses are ignored - a save or submit cannot double-fire.
333
+
334
+ ```jsx
335
+ <Button onPress={async () => { await save() }}>Save</Button>
336
+ ```
362
337
 
363
- | Prop | Type | Description |
364
- | ----------- | ------------- | -------------------------------------------------------- |
365
- | `onPress` | `() => void` | Fires on a completed press. |
366
- | `disabled` | `boolean` | Mutes colors and ignores presses. |
367
- | `focusable` | `boolean` | Focus-navigation candidacy; defaults to `true`. |
368
- | `layout` | `LayoutProps` | Overrides padding/sizing. |
369
- | `style` | `StyleProps` | Overrides background, radius, etc. |
370
- | `children` | `any` | Label text, or custom content. |
338
+ A focused Button (see `createFocusNav`) draws a text-colored ring under the `focusRing` policy - text-colored rather than primary so it stays visible on primary-filled buttons - and activates on Enter, Space, or a remote's center key. `focusable` (default true) opts out of focus-navigation candidacy; disabled buttons are never candidates.
371
339
 
372
- A focused Button (see `createFocusNav`) wears a focus ring under the `focusRing` policy and activates on Enter, Space, or a remote's center key.
340
+ API: `Button`, `ButtonProps`, `ButtonVariant` - typed and commented in [src/button.tsx](./src/button.tsx).
373
341
 
374
342
  ### createFocusNav
375
343
 
376
- Focus navigation for pointer-free control (TV remote, keyboard, gamepad), moving focus across the elements declaring `focusable`. Two movement types over the same candidates: spatial (arrow keys, dpad) picks the nearest candidate in the pressed direction by on-screen boxes, and sequential (Tab / Shift+Tab) walks visual reading order - rows top to bottom, left to right - wrapping at the ends. Enter / remote center / gamepad south activates the focused control. Nothing is focused until the first navigation press; pointer input works unchanged throughout. When the focused control disappears (an action replacing it, a screen change), focus lands on the nearest candidate to where it sat as soon as the successor is laid out - the ring follows a Disconnect button into the Connect button that replaces it. A deliberate blur (tapping outside, dismissing the keyboard) stays blurred; the next press resumes at the nearest candidate.
344
+ Focus navigation for pointer-free control (TV remote, keyboard, gamepad), moving real focus across the elements declaring `focusable`. Two movement types over the same candidates: spatial (arrow keys, dpad) picks the nearest candidate in the pressed direction by on-screen boxes, and sequential (Tab / Shift+Tab) walks visual reading order - rows top to bottom, left to right - wrapping at the ends. Enter / remote center / gamepad south activates the focused control. Nothing is focused until the first navigation press; pointer input works unchanged throughout.
377
345
 
378
346
  ```jsx
379
347
  import { createFocusNav } from "@solidrt/components"
@@ -384,11 +352,17 @@ function App() {
384
352
  }
385
353
  ```
386
354
 
387
- Attaching `nav.onKeyDown` on the window is what keeps it cooperative: key events bubble from the focused node, so a focused TextInput keeps its arrow keys and navigation only sees what nothing else consumed. Gamepad dpad/south are wired automatically. An open `Modal` traps navigation inside itself with no extra wiring (topmost wins when stacked); pass `scope: () => nodeOrNull` to trap into some other subtree instead. `move`/`tab`/`activate` are exposed for custom triggers.
355
+ Attaching `nav.onKeyDown` on the window is what keeps it cooperative: key events bubble from the focused node, so a focused TextInput keeps its arrow keys and navigation only sees what nothing else consumed. Gamepad dpad/south are wired automatically.
356
+
357
+ When the focused control disappears (an action replacing it, a screen change), focus lands on the nearest candidate to where it sat as soon as the successor is laid out - the ring follows a Disconnect button into the Connect button that replaces it. A deliberate blur (tapping outside, dismissing the keyboard) stays blurred; the next press resumes at the nearest candidate.
358
+
359
+ An open `Modal` traps navigation inside itself with no extra wiring (topmost wins when stacked); pass `scope: () => nodeOrNull` to trap into some other subtree instead. `move`/`tab`/`activate` are exposed for custom triggers.
360
+
361
+ API: `createFocusNav`, `FocusNavOptions` - typed and commented in [src/focus-nav.ts](./src/focus-nav.ts).
388
362
 
389
363
  ### Switch
390
364
 
391
- An on/off toggle. The track fills with `primary` when on and `surfaceAlt` when off; the thumb slides across. Controlled via `value`/`onChange`, or uncontrolled via `defaultValue`. Built on `Pressable`, so `disabled` takes no pointer events.
365
+ An on/off toggle: the track fills with `primary` when on and `surfaceAlt` when off; the thumb slides across. Controlled via `value`/`onChange`, or uncontrolled via `defaultValue`. Built on `Pressable`, so `disabled` takes no pointer events. `style` overrides the track colors and radius.
392
366
 
393
367
  ```jsx
394
368
  import { Switch } from "@solidrt/components"
@@ -400,20 +374,11 @@ function NotifyToggle() {
400
374
  }
401
375
  ```
402
376
 
403
- **Props**
404
-
405
- | Prop | Type | Default | Description |
406
- | -------------- | ---------------------------- | ------- | ----------------------------------------------- |
407
- | `value` | `boolean` | - | Controlled state. Omit for uncontrolled. |
408
- | `defaultValue` | `boolean` | `false` | Initial value for uncontrolled use. |
409
- | `onChange` | `(value: boolean) => void` | - | Fires with the new value on toggle. |
410
- | `disabled` | `boolean` | `false` | Takes no pointer events when true. |
411
- | `layout` | `LayoutProps` | - | Overrides sizing/positioning of the track. |
412
- | `style` | `StyleProps` | - | Overrides track colors and radius. |
377
+ API: `Switch`, `SwitchProps` - typed and commented in [src/switch.tsx](./src/switch.tsx).
413
378
 
414
379
  ### Checkbox
415
380
 
416
- A checkbox. When checked it fills with `primary` and draws a checkmark; otherwise it is an empty bordered box. Controlled via `checked`/`onChange`, or uncontrolled via `defaultChecked`.
381
+ A checkbox: filled with `primary` and a drawn checkmark when checked, an empty bordered box otherwise. Controlled via `checked`/`onChange`, or uncontrolled via `defaultChecked`. The mark is the `theme.icons.check` slot when a theme sets one. `style` overrides the box colors, border, and radius.
417
382
 
418
383
  ```jsx
419
384
  import { Checkbox } from "@solidrt/components"
@@ -421,20 +386,11 @@ import { Checkbox } from "@solidrt/components"
421
386
  <Checkbox checked={agree()} onChange={setAgree} />
422
387
  ```
423
388
 
424
- **Props**
425
-
426
- | Prop | Type | Default | Description |
427
- | ---------------- | --------------------------- | ------- | ------------------------------------------ |
428
- | `checked` | `boolean` | - | Controlled state. Omit for uncontrolled. |
429
- | `defaultChecked` | `boolean` | `false` | Initial value for uncontrolled use. |
430
- | `onChange` | `(checked: boolean) => void`| - | Fires with the new state on toggle. |
431
- | `disabled` | `boolean` | `false` | Takes no pointer events when true. |
432
- | `layout` | `LayoutProps` | - | Overrides sizing. |
433
- | `style` | `StyleProps` | - | Overrides box colors, border, and radius. |
389
+ API: `Checkbox`, `CheckboxProps` - typed and commented in [src/checkbox.tsx](./src/checkbox.tsx).
434
390
 
435
391
  ### RadioGroup / Radio
436
392
 
437
- A single-selection group. `RadioGroup` owns the selected value and shares it with its `Radio` children; each `Radio` is a ring with an inner dot when selected. A string/number child of `Radio` renders as a themed label beside the ring. Controlled via `value`/`onChange` on the group, or uncontrolled via `defaultValue`.
393
+ A single-selection pair: `RadioGroup` owns the selected value (controlled via `value`/`onChange`, or uncontrolled via `defaultValue`) and shares it with its `Radio` children; each `Radio` is a ring with an inner dot when selected. A string/number child of `Radio` renders as a themed label beside the ring; anything else as-is. `disabled` on the group disables every option, on a `Radio` just that one.
438
394
 
439
395
  ```jsx
440
396
  import { RadioGroup, Radio } from "@solidrt/components"
@@ -446,30 +402,11 @@ import { RadioGroup, Radio } from "@solidrt/components"
446
402
  </RadioGroup>
447
403
  ```
448
404
 
449
- **RadioGroup props**
450
-
451
- | Prop | Type | Default | Description |
452
- | -------------- | --------------------------- | ------- | ---------------------------------------- |
453
- | `value` | `unknown` | - | Controlled selected value. |
454
- | `defaultValue` | `unknown` | - | Initial selection for uncontrolled use. |
455
- | `onChange` | `(value: unknown) => void` | - | Fires with the newly selected value. |
456
- | `disabled` | `boolean` | `false` | Disables every `Radio` in the group. |
457
- | `layout` | `LayoutProps` | - | Layout of the group container. |
458
- | `children` | `any` | - | `Radio` elements. |
459
-
460
- **Radio props**
461
-
462
- | Prop | Type | Description |
463
- | ---------- | ------------- | --------------------------------------------------- |
464
- | `value` | `unknown` | This option's value; selecting it sets the group. |
465
- | `disabled` | `boolean` | Disables this option (also disabled by the group). |
466
- | `layout` | `LayoutProps` | Layout of the option row. |
467
- | `style` | `StyleProps` | Paint properties of the option row. |
468
- | `children` | `any` | A string/number label, or custom content. |
405
+ API: `RadioGroup`, `Radio`, `RadioGroupProps`, `RadioProps` - typed and commented in [src/radio.tsx](./src/radio.tsx).
469
406
 
470
407
  ### Slider
471
408
 
472
- A horizontal slider. The groove fills up to the thumb; pressing or dragging the track sets the value from the pointer position. Controlled via `value`/`onChange`, or uncontrolled via `defaultValue`. The drag keeps tracking when the pointer drifts off the track, and an enclosing ScrollView never takes it over.
409
+ A horizontal slider: the groove fills up to the thumb, and pressing or dragging the track sets the value from the pointer position. Controlled via `value`/`onChange` (fires while dragging), or uncontrolled via `defaultValue` (defaults to `min`). `min`/`max` default to 0/100; `step` snaps to an increment, omitted the value is continuous. The drag keeps tracking when the pointer drifts off the track, and an enclosing ScrollView never takes it over.
473
410
 
474
411
  ```jsx
475
412
  import { Slider } from "@solidrt/components"
@@ -477,23 +414,11 @@ import { Slider } from "@solidrt/components"
477
414
  <Slider value={volume()} onChange={setVolume} min={0} max={100} step={1} layout={{ width: 180 }} />
478
415
  ```
479
416
 
480
- **Props**
481
-
482
- | Prop | Type | Default | Description |
483
- | -------------- | -------------------------- | ------- | -------------------------------------------- |
484
- | `value` | `number` | - | Controlled value. Omit for uncontrolled. |
485
- | `defaultValue` | `number` | `min` | Initial value for uncontrolled use. |
486
- | `min` | `number` | `0` | Lower bound. |
487
- | `max` | `number` | `100` | Upper bound. |
488
- | `step` | `number` | - | Snap increment. Omit for continuous. |
489
- | `onChange` | `(value: number) => void` | - | Fires with the new value while dragging. |
490
- | `disabled` | `boolean` | `false` | Takes no pointer events when true. |
491
- | `layout` | `LayoutProps` | - | Layout of the track (e.g. `width`). |
492
- | `style` | `StyleProps` | - | Transform only (`x`/`y`/`rotate`/`scale`). |
417
+ API: `Slider`, `SliderProps` - typed and commented in [src/slider.tsx](./src/slider.tsx).
493
418
 
494
419
  ### Card
495
420
 
496
- A themed surface container: a padded column box with a `surface` fill, a subtle `border` stroke, and rounded corners. All colors come from the theme, so it recolors live on a theme switch. Pass a `title` for a heading, or lay out the content yourself. Override any paint via `style`, spacing/sizing via `layout`.
421
+ A themed surface container: a padded column box with a `surface` fill, a subtle `border` stroke, and rounded corners, recoloring live on a theme switch. Pass a `title` for a heading, or lay out the content yourself; override paint via `style`, spacing/sizing via `layout`.
497
422
 
498
423
  ```jsx
499
424
  import { Card } from "@solidrt/components"
@@ -503,18 +428,53 @@ import { Card } from "@solidrt/components"
503
428
  </Card>
504
429
  ```
505
430
 
506
- **Props**
431
+ API: `Card`, `CardProps` - typed and commented in [src/card.tsx](./src/card.tsx).
432
+
433
+ ### Item
507
434
 
508
- | Prop | Type | Default | Description |
509
- | ---------- | ------------- | --------- | ---------------------------------------------------- |
510
- | `title` | `string` | - | Optional heading rendered above the content. |
511
- | `children` | `any` | - | Card content. |
512
- | `layout` | `LayoutProps` | - | Box layout (e.g. `width`, `gap`, `padding`). |
513
- | `style` | `StyleProps` | - | Paint overrides: `backgroundColor`, `borderColor`, `borderWidth`, `borderRadius`, transform. |
435
+ A list row: `startContent` (icon, avatar, checkbox), a `label` with an optional `description` under it, and `endContent` (badge, timestamp, action) pushed to the end. String/number label and description render as themed body and muted caption text; anything else as-is. The dense-data workhorse: rows compose with `<For>` inside a plain column view or `ScrollView` - there is no List wrapper, because a column IS the list. Paddings and gaps are density-scaled, so a `<Density>` region compacts rows wholesale.
436
+
437
+ ```jsx
438
+ import { Item, Badge, Icon } from "@solidrt/components"
439
+ import { For } from "@solidrt/core"
440
+
441
+ <view flexDirection="column">
442
+ <For each={issues()}>
443
+ {(issue) => (
444
+ <Item
445
+ startContent={<Icon src={Bug} />}
446
+ label={issue.title}
447
+ description={issue.assignee}
448
+ endContent={<Badge variant="neutral">{issue.id}</Badge>}
449
+ selected={issue.id === current()}
450
+ onPress={() => setCurrent(issue.id)}
451
+ />
452
+ )}
453
+ </For>
454
+ </view>
455
+ ```
456
+
457
+ With `onPress` the row is interactive: hover/pressed overlay tints (no scale - rows sit flush in a list), focusable for spatial navigation, Enter/remote activation, and a focus ring under the `focusRing` policy. An async `onPress` (returning a promise) is not re-fired until it settles. Without `onPress` the row attaches no press recognizer, so controls inside it (a Switch in a settings row) and enclosing pressables receive pointer events untouched; interactivity is decided at mount. `selected` fills the row with `surfaceAlt`; `disabled` dims the row and takes no pointer events. Separate rows with `Divider` where needed.
458
+
459
+ API: `Item`, `ItemProps` - typed and commented in [src/item.tsx](./src/item.tsx).
460
+
461
+ ### Field
462
+
463
+ A form row: `label` above the control, the control itself (`children`, rendered as-is), and a help or error line below. `error` renders in the danger color and replaces `description` while set. It draws no chrome and does not reach into the control - error styling of the input itself stays the input's `style` prop, no hidden magic. The message line only occupies space while there is one; reserve the space with a constant `description` if the form must not jump when an error appears.
464
+
465
+ ```jsx
466
+ import { Field, TextInput } from "@solidrt/components"
467
+
468
+ <Field label="Email" description="Used for receipts only." error={emailError()}>
469
+ <TextInput value={email()} onInput={setEmail} />
470
+ </Field>
471
+ ```
472
+
473
+ API: `Field`, `FieldProps` - typed and commented in [src/field.tsx](./src/field.tsx).
514
474
 
515
475
  ### Divider
516
476
 
517
- A thin rule in the theme `border` color. It stretches across its container on the cross axis: full width inside a column, full height inside a row (pass `orientation="vertical"`). Add spacing with `layout` margins, and override the color via `style.backgroundColor`.
477
+ A thin rule in the theme `border` color. It stretches across its container on the cross axis: full width inside a column, full height inside a row (pass `orientation="vertical"`). `thickness` defaults to 1px; add spacing with `layout` margins, and override the color via `style.backgroundColor`.
518
478
 
519
479
  ```jsx
520
480
  import { Divider } from "@solidrt/components"
@@ -523,37 +483,24 @@ import { Divider } from "@solidrt/components"
523
483
  <Divider orientation="vertical" />
524
484
  ```
525
485
 
526
- **Props**
527
-
528
- | Prop | Type | Default | Description |
529
- | ------------- | ----------------------------- | -------------- | -------------------------------------- |
530
- | `orientation` | `"horizontal" \| "vertical"` | `"horizontal"` | Rule direction. |
531
- | `thickness` | `number` | `1` | Line thickness in pixels. |
532
- | `layout` | `LayoutProps` | - | Layout (e.g. margins for spacing). |
533
- | `style` | `StyleProps` | - | `backgroundColor` overrides the color. |
486
+ API: `Divider`, `DividerProps` - typed and commented in [src/divider.tsx](./src/divider.tsx).
534
487
 
535
488
  ### Badge
536
489
 
537
- A small rounded pill for counts, labels, and status. Accent `primary` fill with `onPrimary` text by default; a string/number child is rendered as the themed label, anything else as-is. Override the fill via `style.backgroundColor` and the label color via `style.color`.
490
+ A small rounded pill for counts, labels, and status. `variant` picks the role: `primary` (accent fill, the default), `neutral` (subtle surface), `danger`. A string/number child renders as the themed label, anything else as-is (an icon, a dot, ...). Override the fill via `style.backgroundColor` and the label color via `style.color`.
538
491
 
539
492
  ```jsx
540
493
  import { Badge } from "@solidrt/components"
541
494
 
542
495
  <Badge>New</Badge>
543
- <Badge style={{ backgroundColor: theme.color.danger }}>Error</Badge>
496
+ <Badge variant="danger">Error</Badge>
544
497
  ```
545
498
 
546
- **Props**
547
-
548
- | Prop | Type | Default | Description |
549
- | ---------- | ------------- | ------- | -------------------------------------------------- |
550
- | `children` | `any` | - | String/number renders as the label; else as-is. |
551
- | `layout` | `LayoutProps` | - | Box layout (e.g. padding overrides). |
552
- | `style` | `StyleProps` | - | `backgroundColor` (fill), `color` (label), transform. |
499
+ API: `Badge`, `BadgeProps`, `BadgeVariant` - typed and commented in [src/badge.tsx](./src/badge.tsx).
553
500
 
554
501
  ### Spinner
555
502
 
556
- An indeterminate spinner: a 270-degree arc that rotates continuously. It is driven by core `onFrame`, so it participates in demand-driven rendering and stops when unmounted. Color comes from the theme `primary`; override via `style.color`.
503
+ An indeterminate spinner: a 270-degree arc that rotates continuously, driven by core `onFrame`, so it participates in demand-driven rendering and stops when unmounted. `size` (diameter, default 24), `thickness` (default 3), `speed` (revolutions per second, default 1). Color comes from the theme `primary`; override via `style.color`.
557
504
 
558
505
  ```jsx
559
506
  import { Spinner } from "@solidrt/components"
@@ -562,19 +509,11 @@ import { Spinner } from "@solidrt/components"
562
509
  <Spinner size={32} thickness={4} speed={1.5} />
563
510
  ```
564
511
 
565
- **Props**
566
-
567
- | Prop | Type | Default | Description |
568
- | ----------- | ------------- | ------- | ---------------------------------- |
569
- | `size` | `number` | `24` | Overall diameter in pixels. |
570
- | `thickness` | `number` | `3` | Arc stroke width in pixels. |
571
- | `speed` | `number` | `1` | Revolutions per second. |
572
- | `layout` | `LayoutProps` | - | Layout of the box. |
573
- | `style` | `StyleProps` | - | `color` sets the arc; plus transform. |
512
+ API: `Spinner`, `SpinnerProps` - typed and commented in [src/spinner.tsx](./src/spinner.tsx).
574
513
 
575
514
  ### ProgressBar
576
515
 
577
- A horizontal progress bar. Determinate when given a `value` in `[0, 1]` (the fill grows from the left); indeterminate when `value` is undefined (a short segment slides back and forth, driven by core `onFrame`). Track and fill colors come from the theme; override the track via `style.backgroundColor` and the fill via `style.color`.
516
+ A horizontal progress bar: determinate when given a `value` in `[0, 1]` (the fill grows from the left), indeterminate when `value` is undefined (a short segment slides back and forth, driven by core `onFrame`). Track is `surfaceAlt`, fill is `primary`; override via `style.backgroundColor` (track) and `style.color` (fill).
578
517
 
579
518
  ```jsx
580
519
  import { ProgressBar } from "@solidrt/components"
@@ -583,17 +522,153 @@ import { ProgressBar } from "@solidrt/components"
583
522
  <ProgressBar /> // indeterminate
584
523
  ```
585
524
 
586
- **Props**
525
+ API: `ProgressBar`, `ProgressBarProps` - typed and commented in [src/progress-bar.tsx](./src/progress-bar.tsx).
526
+
527
+ ### Portal
528
+
529
+ Renders its child somewhere other than its lexical position: by default at the window root, so overlays (modals, menus, tooltips) escape the clipping and stacking of their surrounding layout; `mount` targets another node captured from a `ref` instead. A thin JSX wrapper over core `createPortal`. The child should be a single element with `position="absolute"`, since it is inserted into the window's flex root. Portals cannot mount during the app's initial render, so gate them behind a signal that starts false.
530
+
531
+ ```jsx
532
+ import { Portal } from "@solidrt/components"
533
+
534
+ <Show when={open()}>
535
+ <Portal>
536
+ <view position="absolute" right={16} bottom={16}>
537
+ <Card>Saved</Card>
538
+ </view>
539
+ </Portal>
540
+ </Show>
541
+ ```
542
+
543
+ API: `Portal`, `PortalProps` - typed and commented in [src/portal.tsx](./src/portal.tsx).
544
+
545
+ ### Modal
546
+
547
+ A centered overlay rendered at the window root via core `createPortal`: it fills the window with a dimming backdrop (theme `scrim`; override via `backdropColor`, `"transparent"` for no dim) and centers `children` on top. Control visibility by mounting/unmounting it, e.g. `<Show when={open()}>`; the gating signal must start false since portals cannot mount during the initial render. Pressing the backdrop calls `onClose` (unless `dismissable` is false), pressing the content does not, and while mounted the modal traps `createFocusNav` inside itself.
548
+
549
+ ```jsx
550
+ import { Modal, Card, Button } from "@solidrt/components"
551
+
552
+ <Show when={open()}>
553
+ <Modal onClose={() => setOpen(false)}>
554
+ <Card>
555
+ <Button onPress={() => setOpen(false)}>Close</Button>
556
+ </Card>
557
+ </Modal>
558
+ </Show>
559
+ ```
560
+
561
+ API: `Modal`, `ModalProps` - typed and commented in [src/modal.tsx](./src/modal.tsx).
562
+
563
+ ### Tooltip
564
+
565
+ A hover-only affordance: under the `desktop`/`hybrid` interaction policies, resting a mouse pointer on the wrapped content shows a bubble near it after `delay` (default 500ms). Under the `touch` policy it never shows, so tooltip content must stay non-essential. The bubble is portal-mounted at the window root, clamped to the window edges, takes no pointer events, and hides on leave and on press. A string/number `content` renders as themed body text; anything else as-is. `placement` picks the side (`"top"`, the default, or `"bottom"`).
566
+
567
+ ```jsx
568
+ import { Tooltip, Button } from "@solidrt/components"
569
+
570
+ <Tooltip content="Save (Ctrl+S)">
571
+ <Button onPress={save}>Save</Button>
572
+ </Tooltip>
573
+ ```
574
+
575
+ API: `Tooltip`, `TooltipProps` - typed and commented in [src/tooltip.tsx](./src/tooltip.tsx).
576
+
577
+ ### Select
578
+
579
+ A single-choice picker whose presentation forks on the interaction policy: `desktop`/`hybrid` opens an anchored dropdown under the trigger (flipping above when there is no room), `touch` opens a bottom sheet over a scrim. Same contract either way: `options` is an `Option[]` (`{ value, label }`), controlled via `value`/`onChange` or uncontrolled via `defaultValue`; pressing outside closes without a change. `placeholder` shows in the trigger while nothing is selected. The option list is not scrollable yet, so keep it short. The trigger's chevron is the `theme.icons.chevronDown` slot when a theme sets one.
580
+
581
+ ```jsx
582
+ import { Select } from "@solidrt/components"
587
583
 
588
- | Prop | Type | Default | Description |
589
- | -------- | ------------- | ------- | --------------------------------------------------- |
590
- | `value` | `number` | - | Progress in `[0, 1]`. Omit for an indeterminate bar. |
591
- | `layout` | `LayoutProps` | - | Layout (e.g. `width`, `height`). |
592
- | `style` | `StyleProps` | - | `backgroundColor` (track), `color` (fill). |
584
+ let options = [
585
+ { value: "s", label: "Small" },
586
+ { value: "m", label: "Medium" },
587
+ { value: "l", label: "Large" },
588
+ ]
589
+
590
+ <Select options={options} value={size()} onChange={setSize} placeholder="Size" />
591
+ ```
592
+
593
+ API: `Select`, `SelectProps` - typed and commented in [src/select.tsx](./src/select.tsx).
594
+
595
+ ### SegmentedControl
596
+
597
+ A single-choice row of equal-width segments joined flush: only the control's outermost corners are rounded, hairline dividers separate the segments, and the active segment fills with the theme `primary`. Hovered segments tint with the theme `overlayHover` under non-touch interaction policies. `options` is an `Option[]`; controlled via `value`/`onChange`, or uncontrolled via `defaultValue`. Override the inactive fill via `style.backgroundColor` and the outer radius via `style.borderRadius`.
598
+
599
+ ```jsx
600
+ import { SegmentedControl } from "@solidrt/components"
601
+
602
+ <SegmentedControl
603
+ options={[{ value: "day", label: "Day" }, { value: "week", label: "Week" }]}
604
+ value={range()}
605
+ onChange={setRange}
606
+ />
607
+ ```
608
+
609
+ API: `SegmentedControl`, `SegmentedControlProps` - typed and commented in [src/segmented-control.tsx](./src/segmented-control.tsx).
610
+
611
+ ### ContextMenu
612
+
613
+ Secondary actions on the wrapped content. The opening gesture follows the physical pointer: right-click for a mouse, long-press (500ms, cancelled by finger travel) for touch. The presentation forks on the interaction policy: `touch` gets a bottom sheet over a scrim, `desktop`/`hybrid` an anchored menu at the pointer that flips up near the bottom edge. `items` is a `ContextMenuItem[]` (`{ label, onSelect?, disabled? }`); pressing outside closes without selecting.
614
+
615
+ ```jsx
616
+ import { ContextMenu } from "@solidrt/components"
617
+
618
+ <ContextMenu
619
+ items={[
620
+ { label: "Rename", onSelect: rename },
621
+ { label: "Delete", onSelect: remove },
622
+ { label: "Share", disabled: true },
623
+ ]}
624
+ >
625
+ <Card>{file.name}</Card>
626
+ </ContextMenu>
627
+ ```
628
+
629
+ API: `ContextMenu`, `ContextMenuProps`, `ContextMenuItem` - typed and commented in [src/context-menu.tsx](./src/context-menu.tsx).
630
+
631
+ ### NavShell
632
+
633
+ An app shell that arranges primary navigation around the content per `policy.navigation`: bottom tabs under it (`bottomTabs`), a narrow rail (`rail`), or a wide sidebar (`sidebar`) beside it. The content is a single stable node; switching arrangement only flips the shell's flex direction and remounts the stateless nav strip, so page state survives a resize across a breakpoint. `items` is a `NavItem[]` (`{ value, label, icon? }`; the icon renders above the label in tabs/rail, beside it in the sidebar); controlled via `value`/`onChange`, or uncontrolled via `defaultValue`. Safe areas are the caller's concern: wrap the shell in `SafeArea`.
634
+
635
+ ```jsx
636
+ import { NavShell, Icon } from "@solidrt/components"
637
+
638
+ let items = [
639
+ { value: "home", label: "Home", icon: <Icon src={House} /> },
640
+ { value: "settings", label: "Settings", icon: <Icon src={Cog} /> },
641
+ ]
642
+
643
+ <NavShell items={items} value={page()} onChange={setPage} layout={{ flex: 1 }}>
644
+ <Show when={page() === "home"} fallback={<Settings />}>
645
+ <Home />
646
+ </Show>
647
+ </NavShell>
648
+ ```
649
+
650
+ API: `NavShell`, `NavShellProps`, `NavItem` - typed and commented in [src/nav-shell.tsx](./src/nav-shell.tsx).
651
+
652
+ ### SplitView
653
+
654
+ A list-detail container driven by `policy.layout`: `twoPane` shows the `list` pane (width `listWidth`, default 320) beside the `detail` pane, `singlePane` shows one at a time per `showDetail`. Keep pane state (selection, scroll) in the app, not in the panes: crossing a breakpoint re-arranges and can remount them. It draws no chrome and adds no padding; a back affordance in the single-pane detail is the app's to render (fork on `policy.layout`).
655
+
656
+ ```jsx
657
+ import { SplitView } from "@solidrt/components"
658
+
659
+ <SplitView
660
+ layout={{ flex: 1 }}
661
+ list={<Inbox onOpen={setSelected} />}
662
+ detail={<Message id={selected()} onBack={() => setSelected(null)} />}
663
+ showDetail={selected() !== null}
664
+ />
665
+ ```
666
+
667
+ API: `SplitView`, `SplitViewProps` - typed and commented in [src/split-view.tsx](./src/split-view.tsx).
593
668
 
594
669
  ### QrCode
595
670
 
596
- Renders a QR code for `data` out of primitives: same-color modules in a row collapse into one box, drawn on a light quiet-zone panel. The grid recomputes only when `data` or `level` changes. It paints black on white by default (not the theme) so it stays scannable through a theme switch; override `color`/`background` only if the contrast still holds.
671
+ Renders a QR code for `data` out of primitives: same-color modules in a row collapse into one box, drawn on a light quiet-zone panel; the grid recomputes only when `data` or `level` changes. It paints black on white by default (not the theme) so it stays scannable through a theme switch; override `color`/`background` only if the contrast still holds. `moduleSize` (default 6) is pixels per module, `margin` (default 16) the quiet zone (keep it non-zero), `level` the error correction (`L`/`M`/`Q`/`H`, default `M`: higher tolerates more damage but caps data length sooner), `radius` the panel's corner radius.
597
672
 
598
673
  ```jsx
599
674
  import { QrCode } from "@solidrt/components"
@@ -602,24 +677,13 @@ import { QrCode } from "@solidrt/components"
602
677
  <QrCode data={ticket()} moduleSize={8} level="L" />
603
678
  ```
604
679
 
605
- **Props**
606
-
607
- | Prop | Type | Default | Description |
608
- | ------------ | -------------------------- | -------------- | -------------------------------------------------------------- |
609
- | `data` | `string` | - | The string to encode (URL, pairing ticket, text, ...). |
610
- | `moduleSize` | `number` | `6` | Pixels per module (the smallest square). |
611
- | `margin` | `number` | `16` | Quiet-zone padding in pixels around the grid; keep non-zero. |
612
- | `color` | `string` | `"#000000"` | Dark-module color. |
613
- | `background` | `string` | `"#ffffff"` | Panel/light-module color. |
614
- | `level` | `"L" \| "M" \| "Q" \| "H"` | `"M"` | Error-correction level; higher tolerates more damage but caps data length sooner. |
615
- | `radius` | `number` | `8` | Corner radius of the background panel. |
616
- | `layout` | `LayoutProps` | - | Layout of the outer box. |
680
+ API: `QrCode`, `QrCodeProps` - typed and commented in [src/qrcode.tsx](./src/qrcode.tsx).
617
681
 
618
682
  ### Icon
619
683
 
620
- A thin themed wrapper over the core `parseSvg` primitive. `src` is a whole SVG document as a string; the component parses it once (memoized), maps the draws to `<d-path>` in a square `viewBox`-fitted box and, for monochrome icons that stroke/fill with `currentColor`, recolors it from the theme. It carries no icon set and no name registry, so any `currentColor` SVG works (Lucide, Feather, Heroicons) and only the icons you import are bundled. Multi-color documents keep their own fills. For a non-square box, use `parseSvg` directly.
684
+ A thin themed wrapper over the core `parseSvg` primitive. `src` is a whole SVG document as a string; the component parses it once (memoized), maps the draws to `<d-path>` in a square `viewBox`-fitted box (`size`, default 24) and, for monochrome icons that stroke/fill with `currentColor`, recolors it via `color` (default the theme text color). It carries no icon set and no name registry, so any `currentColor` SVG works (Lucide, Feather, Heroicons, ...) and only the icons you import are bundled. Multi-color documents keep their own fills. For a non-square box, use `parseSvg` directly.
621
685
 
622
- Icons are just SVG strings. Import them as assets (`import House from "lucide-static/icons/house.svg"`, resolved to a string), pull them from a string export, or inline a literal:
686
+ Icons are just SVG strings: import them as assets (`import House from "lucide-static/icons/house.svg"`, resolved to a string), pull them from a string export, or inline a literal.
623
687
 
624
688
  ```jsx
625
689
  import { Icon } from "@solidrt/components"
@@ -629,14 +693,23 @@ import House from "lucide-static/icons/house.svg"
629
693
  <Icon src={House} size={32} color={theme.color.primary} />
630
694
  ```
631
695
 
632
- **Props**
696
+ API: `Icon`, `IconProps` - typed and commented in [src/icon.tsx](./src/icon.tsx).
697
+
698
+ ### Density
699
+
700
+ `<Density value="compact">` overrides the density policy for its subtree: every density-scaled metric below - `space()`, control sizes (Checkbox, Switch, Radio, Slider), `Item` and `Button` paddings - resolves this value instead of the global `policy.density`. Regions nest; the nearest wins. Use it to tighten a toolbar, a data table, or a sidebar without per-child props.
701
+
702
+ ```jsx
703
+ import { Density, Item } from "@solidrt/components"
704
+
705
+ <Density value="dense">
706
+ <For each={rows()}>{(r) => <Item label={r.name} />}</For>
707
+ </Density>
708
+ ```
709
+
710
+ `densityScale()` is the reactive multiplier behind it (1 / 0.85 / 0.7 for comfortable/compact/dense): the nearest `<Density>` above the calling scope, falling back to `policy.density`. Call it during component setup or inside JSX/thunks when building custom density-aware components.
633
711
 
634
- | Prop | Type | Default | Description |
635
- | -------- | ------------- | ------------------ | -------------------------------------------------------------- |
636
- | `src` | `string` | - | The SVG document to draw. |
637
- | `size` | `number` | `24` | Square box side in pixels. |
638
- | `color` | `string` | `theme.color.text` | Drives `currentColor`; explicit fills/strokes still win. |
639
- | `layout` | `LayoutProps` | - | Layout of the box. |
712
+ API: `Density`, `DensityProps`, `densityScale` - typed and commented in [src/density.tsx](./src/density.tsx).
640
713
 
641
714
  ## License
642
715