@solidrt/components 0.0.51 → 0.0.53

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/AGENTS.md CHANGED
@@ -3,8 +3,8 @@
3
3
  Higher-level components built on @solidrt/core primitives. Optional: an app can
4
4
  be built with core primitives alone. For the underlying element model, events,
5
5
  reactivity, and how to run/verify, see @solidrt/core and @solidrt/cli (their
6
- AGENTS.md). Per-module prose is in docs/ (one file per module, generated into the README);
7
- props are the typed, commented interfaces in src/.
6
+ AGENTS.md). Per-module prose is in docs/ (one file per module, generated into
7
+ the README); props are the typed, commented interfaces in src/.
8
8
 
9
9
  ## Install
10
10
 
@@ -24,6 +24,33 @@ Most components group props into two objects, plus top-level event handlers:
24
24
  transform `x`/`y`/`rotate`/`scale`.
25
25
  - Event handlers (`onPointerDown`, `onKeyDown`, ...) are top-level props, NOT
26
26
  inside `layout`/`style`.
27
+
28
+ Core intrinsics take these props flat instead - there are no `layout`/`style`
29
+ objects at that level - and the two compose freely in one tree.
30
+
31
+ ## Traps
32
+
33
+ - `Window`/`View` do not paint on their own; they only paint when you set
34
+ `style.backgroundColor`/`borderColor` etc. There is no separate background
35
+ element to place by hand (that is the core-only pattern).
36
+ - There is no `onClick`. `onPress` comes from `Pressable`/`Button`; raw
37
+ `View` only has `onPointerDown` and friends.
38
+ - `ScrollView` needs an explicit main-axis size - a height, or flex inside a
39
+ sized parent. With neither it resolves to 0 and its content silently
40
+ vanishes; `maxHeight` alone does not size it. The runtime warns.
41
+ - `Modal`/`Portal` cannot mount during the initial render: gate them behind
42
+ a signal that starts false.
43
+ - Cover/contain images: give `Image` a `fit` prop (`"fill" | "cover" |
44
+ "contain" | "none" | "scale-down"`, CSS object-fit semantics, centered)
45
+ plus a box via `layout` in any form. Without `fit`, only NUMERIC layout
46
+ sizes reach the image; `width: pct(100)` alone draws at intrinsic size.
47
+ `fit="cover"` is the ported-web hero-image/thumbnail pattern.
48
+ - Reach for @solidrt/core directly only for what components does not wrap:
49
+ raw intrinsics and the `d-` primitives (`d-rect`/`d-path`/`d-oval`) for
50
+ vector art or perf-sensitive positioned drawing, device/GPU subpath
51
+ imports (@solidrt/core/camera, /microphone, /gpu), gradients, and
52
+ createImage/decodeImage below `Image`'s level. Dropping to a `<d-path>`
53
+ for one custom shape never means giving up `View`/`Text` elsewhere.
27
54
  - Focus navigation: `createFocusNav` moves real focus across `focusable`
28
55
  elements (Button is focusable by default; Pressable opt-in) - spatially on
29
56
  arrows/dpad, sequentially on Tab/Shift+Tab (reading order, wrapping).
@@ -46,16 +73,16 @@ One bullet per module, generated from the first paragraph of its docs/ file.
46
73
  - `TextInput` - 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 }`).
47
74
  - `RichTextEditor` - 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.
48
75
  - `Document model` - 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.
49
- - `ScrollView` - 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.
76
+ - `ScrollView` - 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. Scrolling glides: the offset springs to each new target (250 ms, critically damped), so a wheel notch never jumps and a burst of notches reads as one motion; a dragging finger is tracked exactly, without the spring. No momentum/fling yet.
50
77
  - `Pressable` - 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.
51
78
  - `Button` - 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, ...).
52
- - `createFocusNav` - 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.
79
+ - `createFocusNav` - 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. Every interactive control (Button, Item, TextInput, RichTextEditor, Checkbox, Radio, Switch, Select and its options, SegmentedControl segments, Slider) is a candidate unless disabled, and draws the theme's `ring` color at `borderWidth.focus` while focused under the `focusRing` policy; the Slider steps its value with the arrow keys.
53
80
  - `Switch` - 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.
54
81
  - `Checkbox` - 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.
55
82
  - `RadioGroup / Radio` - 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.
56
83
  - `Slider` - 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.
57
84
  - `Card` - 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`.
58
- - `Item` - 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.
85
+ - `Item` - 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 body 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.
59
86
  - `Field` - 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.
60
87
  - `Divider` - 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`.
61
88
  - `Badge` - 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`.
@@ -68,9 +95,9 @@ One bullet per module, generated from the first paragraph of its docs/ file.
68
95
  - `SegmentedControl` - 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`.
69
96
  - `ContextMenu` - 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.
70
97
  - `NavShell` - 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`.
71
- - `SplitView` - 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`).
98
+ - `SplitView` - A list-detail container driven by `policy.layout`: `twoPane` shows the `list` pane (width `listWidth`, default `theme.size.splitViewList`) 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`).
72
99
  - `QrCode` - 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.
73
- - `Icon` - 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.
100
+ - `Icon` - 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 `designSize`-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.
74
101
  - `Theming` - 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`.
75
102
  - `Policies` - 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.
76
103
  - `Density` - `<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.
package/README.md CHANGED
@@ -45,11 +45,23 @@ let def = {
45
45
  setTheme(defineTheme(def, "dark"))
46
46
  ```
47
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.
48
+ The type scale derives from `text.base` (the body size, default 14) and `text.ratio` (default 1.26): caption sits one step under body, label is body at an emphasized weight, title and heading sit one and two steps above, rounded to whole pixels, with per-role `text.roles` overrides for sizes, line heights, and weights. De-emphasis is a color (`textMuted`), not a size: caption is for small glanceable text (badges, tab labels, timestamps) and stays in the full text color.
49
49
 
50
50
  ### Tokens
51
51
 
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`).
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), `ring` (the focus ring; defaults to `text` so it stays visible on primary fills), 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` (`sm` for borders, `focus` for the ring), `size` (app-wide default extents: `navRail` 72, `navSidebar` 220, `splitViewList` 320, `menuMinWidth` 120, `slider` 200; each overridable per instance through its layout or prop), and `text` (the type scale: `caption`/`label`/`body`/`title`/`heading` roles, each `{ size, lineHeight, weight }`, plus `fontFamily` and `monoFamily` for code).
53
+
54
+ ### Spacing
55
+
56
+ Spacing is one base unit: `spacing` in a theme definition is a number (default 4) and the steps are multiples of it (`sm` 1x, `md` 2x, `lg` 4x, `xl` 5x). Components read them through `space()`, which applies the density policy on top, so a theme sets the rhythm and density tightens it. Pass an object (`spacing: { sm, md, lg, xl }`, any subset) to pin individual steps.
57
+
58
+ ### Radius
59
+
60
+ Corner radius is set once: `radius` in a theme definition is a single number, the control radius (default 8), and the scale derives from it: `md` is the base (Button, TextInput, RichTextEditor, Select, SegmentedControl, QrCode), `sm` half of it (Checkbox, Item, NavShell items, Select and ContextMenu popups, Tooltip), `lg` one and a half (Card), and `full` the pill (Badge). Set `radius: 0` for a square theme, `radius: 12` for a soft one; buttons and inputs always match. Shapes derived from a control's own height (Switch, Slider, ProgressBar, Radio) are not on the scale. Pass an object (`radius: { sm, md, lg, full }`, any subset) to pin individual steps instead.
61
+
62
+ ```jsx
63
+ setTheme({ radius: 4 }) // sm 2, md 4, lg 6
64
+ ```
53
65
 
54
66
  ### Per-component overrides
55
67
 
@@ -85,7 +97,7 @@ The fields:
85
97
  - `focusRing` (`boolean`) - whether focused controls draw a visible focus indicator (true when a keyboard or gamepad/remote is present).
86
98
  - `textScale` (`number`) - multiplier on type-scale font sizes; defaults to the OS text-scale preference.
87
99
  - `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.
100
+ - `navigation` (`"bottomTabs" | "rail" | "sidebar"`) - recommended nav layout, derived from the pane count: `sidebar` beside a two-pane layout, `bottomTabs` under a single pane (a side strip spends the width a narrow window is short of). `rail` is never derived; set it for a content-dense two-pane app. `NavShell` follows it.
89
101
  - `layout` (`"singlePane" | "twoPane"`) - recommended pane count, derived from the window size class. `SplitView` follows it.
90
102
 
91
103
  ```jsx
@@ -103,7 +115,11 @@ Most components group their props into two objects, split by one rule: `layout`
103
115
 
104
116
  `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
117
 
106
- API: `StyleProps`, `TextLayoutProps`, `Option` - typed and commented in [src/types.ts](./src/types.ts).
118
+ `TransitionProps` (`transition`, `onTransitionEnd`) is the third top-level group, in the component's own vocabulary rather than core's: a declaration names the view-level properties (`opacity`, `x`, `y`, `scale*`, `rotate*`, `origin*`, `perspective`, `clipRadius`) and the style ones (`backgroundColor`, `borderColor`, `borderWidth`, `borderRadius`), plus `all`, a shorthand string, and `stagger` - `<Button transition={{ backgroundColor: { duration: 300 }, opacity: "200ms ease-out" }}>`. Core's paint names (`color`, `radius`, `strokeWidth`) are rejected by the types: a component is a root view plus the rects it draws for `style`, and `splitTransition` hands each entry to the node that owns it (the background rect gets `backgroundColor`/`borderRadius`, the stroke rect `borderColor`/`borderWidth`/`borderRadius`, the root view the rest). `onTransitionEnd` reports the component name (`backgroundColor`, not `color`). `Text` adds `color` (its text node), `ScrollView` adds `scrollX`/`scrollY` (its viewport).
119
+
120
+ Controls whose paint is their own - `Switch` knob, `Slider` thumb, `Checkbox` mark, `Radio` dot, `ProgressBar` fill, `Spinner`, `Icon`, `QrCode`, and the chrome of `NavShell`, `ContextMenu`, `Field` - animate the view-level entries only for now; their internal parts are not reachable through `transition` yet (okf/backlog/component-transitions-internal-paint.md).
121
+
122
+ API: `StyleProps`, `TextLayoutProps`, `Option`, `TransitionProps`, `ComponentTransition`, `TransitionViewProp`, `TransitionStyleProp`, `TransitionScrollProp` - typed and commented in [src/types.ts](./src/types.ts).
107
123
 
108
124
  ## Typography helpers
109
125
 
@@ -280,7 +296,7 @@ API: `createDocumentBuffer`, `plainDocument`, `ATOM`, `Document`, `DocumentRun`,
280
296
 
281
297
  ### ScrollView
282
298
 
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.
299
+ 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. Scrolling glides: the offset springs to each new target (250 ms, critically damped), so a wheel notch never jumps and a burst of notches reads as one motion; a dragging finger is tracked exactly, without the spring. No momentum/fling yet.
284
300
 
285
301
  ```jsx
286
302
  import { ScrollView, Text } from "@solidrt/components"
@@ -291,6 +307,27 @@ import { For } from "@solidrt/core"
291
307
  </ScrollView>
292
308
  ```
293
309
 
310
+ `scrollRef` hands out the scroll handle from `createScroll`: `offset()` and `range()` (the largest reachable offset, refreshed each layout) are reactive; `scrollTo({ x, y, behavior })` and `scrollBy({ x, y, behavior })` clamp to the range, an omitted axis stays put, and `behavior: "instant"` writes without the spring (the web's word; `"auto"` and `"smooth"` are the default motion). Scroll policies are written against it in the app. A transcript that opens at its newest message and then follows growth, without yanking a reader who has scrolled back:
311
+
312
+ ```tsx
313
+ let [scroll, setScroll] = createSignal<Scroll>()
314
+ createEffect(
315
+ () => scroll()?.range(),
316
+ (r, prev) =>
317
+ untrack(() => {
318
+ let s = scroll()
319
+ if (!s || !r) return
320
+ if (!prev || prev.y === 0) s.scrollTo({ y: Infinity, behavior: "instant" })
321
+ else if (s.offset().y >= prev.y - 1) s.scrollTo({ y: Infinity })
322
+ }),
323
+ )
324
+ <ScrollView scrollRef={setScroll}>...</ScrollView>
325
+ ```
326
+
327
+ The range changes whenever the content or the viewport changes size. The first fill (nothing was scrollable before it, whether it mounted with the view or arrived a second later) lands instantly, as a chat opens at its end; after that the view follows the end only if it was at the previous end, and the spring makes that follow a glide. The handle arrives once the component has settled, after an effect's first compute, so hold it in a signal (a setter can be passed as the ref) rather than a plain variable, which the effect would find unset and never track. The offset is read untracked: the policy reacts to the range, not to every scroll.
328
+
329
+ A `scrollX`/`scrollY` entry in `transition` replaces the default spring: `transition={{ scrollY: { duration: 400, bounce: 0.2 } }}` (keep it a spring rather than a tween, because the wheel retargets mid-flight). The other entries animate the box itself and its background/border as on any component.
330
+
294
331
  The underlying geometry primitive `createScroll` is available from `@solidrt/core` for building custom scrollers.
295
332
 
296
333
  API: `ScrollView`, `ScrollViewProps` - typed and commented in [src/scroll-view.tsx](./src/scroll-view.tsx).
@@ -335,13 +372,13 @@ An `onPress` returning a promise makes the button an async action: while it is u
335
372
  <Button onPress={async () => { await save() }}>Save</Button>
336
373
  ```
337
374
 
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.
375
+ A focused Button (see `createFocusNav`) draws a ring in the theme `ring` color under the `focusRing` policy (text-colored by default 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.
339
376
 
340
377
  API: `Button`, `ButtonProps`, `ButtonVariant` - typed and commented in [src/button.tsx](./src/button.tsx).
341
378
 
342
379
  ### createFocusNav
343
380
 
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.
381
+ 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. Every interactive control (Button, Item, TextInput, RichTextEditor, Checkbox, Radio, Switch, Select and its options, SegmentedControl segments, Slider) is a candidate unless disabled, and draws the theme's `ring` color at `borderWidth.focus` while focused under the `focusRing` policy; the Slider steps its value with the arrow keys.
345
382
 
346
383
  ```jsx
347
384
  import { createFocusNav } from "@solidrt/components"
@@ -432,7 +469,7 @@ API: `Card`, `CardProps` - typed and commented in [src/card.tsx](./src/card.tsx)
432
469
 
433
470
  ### Item
434
471
 
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.
472
+ 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 body 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
473
 
437
474
  ```jsx
438
475
  import { Item, Badge, Icon } from "@solidrt/components"
@@ -651,7 +688,7 @@ API: `NavShell`, `NavShellProps`, `NavItem` - typed and commented in [src/nav-sh
651
688
 
652
689
  ### SplitView
653
690
 
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`).
691
+ A list-detail container driven by `policy.layout`: `twoPane` shows the `list` pane (width `listWidth`, default `theme.size.splitViewList`) 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
692
 
656
693
  ```jsx
657
694
  import { SplitView } from "@solidrt/components"
@@ -681,7 +718,7 @@ API: `QrCode`, `QrCodeProps` - typed and commented in [src/qrcode.tsx](./src/qrc
681
718
 
682
719
  ### Icon
683
720
 
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.
721
+ 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 `designSize`-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.
685
722
 
686
723
  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.
687
724
 
package/docs/button.md CHANGED
@@ -18,4 +18,4 @@ An `onPress` returning a promise makes the button an async action: while it is u
18
18
  <Button onPress={async () => { await save() }}>Save</Button>
19
19
  ```
20
20
 
21
- 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.
21
+ A focused Button (see `createFocusNav`) draws a ring in the theme `ring` color under the `focusRing` policy (text-colored by default 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.
package/docs/focus-nav.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # createFocusNav
2
2
 
3
- 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.
3
+ 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. Every interactive control (Button, Item, TextInput, RichTextEditor, Checkbox, Radio, Switch, Select and its options, SegmentedControl segments, Slider) is a candidate unless disabled, and draws the theme's `ring` color at `borderWidth.focus` while focused under the `focusRing` policy; the Slider steps its value with the arrow keys.
4
4
 
5
5
  ```jsx
6
6
  import { createFocusNav } from "@solidrt/components"
package/docs/icon.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Icon
2
2
 
3
- 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.
3
+ 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 `designSize`-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.
4
4
 
5
5
  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.
6
6
 
package/docs/item.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Item
2
2
 
3
- 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.
3
+ 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 body 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.
4
4
 
5
5
  ```jsx
6
6
  import { Item, Badge, Icon } from "@solidrt/components"
package/docs/policy.md CHANGED
@@ -10,7 +10,7 @@ The fields:
10
10
  - `focusRing` (`boolean`) - whether focused controls draw a visible focus indicator (true when a keyboard or gamepad/remote is present).
11
11
  - `textScale` (`number`) - multiplier on type-scale font sizes; defaults to the OS text-scale preference.
12
12
  - `textWeightDelta` (`number`) - weight compensation (steps of 100) for light-on-dark text on low-DPI displays.
13
- - `navigation` (`"bottomTabs" | "rail" | "sidebar"`) - recommended nav layout, derived from the window size class. `NavShell` follows it.
13
+ - `navigation` (`"bottomTabs" | "rail" | "sidebar"`) - recommended nav layout, derived from the pane count: `sidebar` beside a two-pane layout, `bottomTabs` under a single pane (a side strip spends the width a narrow window is short of). `rail` is never derived; set it for a content-dense two-pane app. `NavShell` follows it.
14
14
  - `layout` (`"singlePane" | "twoPane"`) - recommended pane count, derived from the window size class. `SplitView` follows it.
15
15
 
16
16
  ```jsx
@@ -1,6 +1,6 @@
1
1
  # ScrollView
2
2
 
3
- 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.
3
+ 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. Scrolling glides: the offset springs to each new target (250 ms, critically damped), so a wheel notch never jumps and a burst of notches reads as one motion; a dragging finger is tracked exactly, without the spring. No momentum/fling yet.
4
4
 
5
5
  ```jsx
6
6
  import { ScrollView, Text } from "@solidrt/components"
@@ -11,4 +11,25 @@ import { For } from "@solidrt/core"
11
11
  </ScrollView>
12
12
  ```
13
13
 
14
+ `scrollRef` hands out the scroll handle from `createScroll`: `offset()` and `range()` (the largest reachable offset, refreshed each layout) are reactive; `scrollTo({ x, y, behavior })` and `scrollBy({ x, y, behavior })` clamp to the range, an omitted axis stays put, and `behavior: "instant"` writes without the spring (the web's word; `"auto"` and `"smooth"` are the default motion). Scroll policies are written against it in the app. A transcript that opens at its newest message and then follows growth, without yanking a reader who has scrolled back:
15
+
16
+ ```tsx
17
+ let [scroll, setScroll] = createSignal<Scroll>()
18
+ createEffect(
19
+ () => scroll()?.range(),
20
+ (r, prev) =>
21
+ untrack(() => {
22
+ let s = scroll()
23
+ if (!s || !r) return
24
+ if (!prev || prev.y === 0) s.scrollTo({ y: Infinity, behavior: "instant" })
25
+ else if (s.offset().y >= prev.y - 1) s.scrollTo({ y: Infinity })
26
+ }),
27
+ )
28
+ <ScrollView scrollRef={setScroll}>...</ScrollView>
29
+ ```
30
+
31
+ The range changes whenever the content or the viewport changes size. The first fill (nothing was scrollable before it, whether it mounted with the view or arrived a second later) lands instantly, as a chat opens at its end; after that the view follows the end only if it was at the previous end, and the spring makes that follow a glide. The handle arrives once the component has settled, after an effect's first compute, so hold it in a signal (a setter can be passed as the ref) rather than a plain variable, which the effect would find unset and never track. The offset is read untracked: the policy reacts to the range, not to every scroll.
32
+
33
+ A `scrollX`/`scrollY` entry in `transition` replaces the default spring: `transition={{ scrollY: { duration: 400, bounce: 0.2 } }}` (keep it a spring rather than a tween, because the wheel retargets mid-flight). The other entries animate the box itself and its background/border as on any component.
34
+
14
35
  The underlying geometry primitive `createScroll` is available from `@solidrt/core` for building custom scrollers.
@@ -1,6 +1,6 @@
1
1
  # SplitView
2
2
 
3
- 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`).
3
+ A list-detail container driven by `policy.layout`: `twoPane` shows the `list` pane (width `listWidth`, default `theme.size.splitViewList`) 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`).
4
4
 
5
5
  ```jsx
6
6
  import { SplitView } from "@solidrt/components"
package/docs/theme.md CHANGED
@@ -29,11 +29,23 @@ let def = {
29
29
  setTheme(defineTheme(def, "dark"))
30
30
  ```
31
31
 
32
- 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.
32
+ The type scale derives from `text.base` (the body size, default 14) and `text.ratio` (default 1.26): caption sits one step under body, label is body at an emphasized weight, title and heading sit one and two steps above, rounded to whole pixels, with per-role `text.roles` overrides for sizes, line heights, and weights. De-emphasis is a color (`textMuted`), not a size: caption is for small glanceable text (badges, tab labels, timestamps) and stays in the full text color.
33
33
 
34
34
  ## Tokens
35
35
 
36
- 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`).
36
+ 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), `ring` (the focus ring; defaults to `text` so it stays visible on primary fills), 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` (`sm` for borders, `focus` for the ring), `size` (app-wide default extents: `navRail` 72, `navSidebar` 220, `splitViewList` 320, `menuMinWidth` 120, `slider` 200; each overridable per instance through its layout or prop), and `text` (the type scale: `caption`/`label`/`body`/`title`/`heading` roles, each `{ size, lineHeight, weight }`, plus `fontFamily` and `monoFamily` for code).
37
+
38
+ ## Spacing
39
+
40
+ Spacing is one base unit: `spacing` in a theme definition is a number (default 4) and the steps are multiples of it (`sm` 1x, `md` 2x, `lg` 4x, `xl` 5x). Components read them through `space()`, which applies the density policy on top, so a theme sets the rhythm and density tightens it. Pass an object (`spacing: { sm, md, lg, xl }`, any subset) to pin individual steps.
41
+
42
+ ## Radius
43
+
44
+ Corner radius is set once: `radius` in a theme definition is a single number, the control radius (default 8), and the scale derives from it: `md` is the base (Button, TextInput, RichTextEditor, Select, SegmentedControl, QrCode), `sm` half of it (Checkbox, Item, NavShell items, Select and ContextMenu popups, Tooltip), `lg` one and a half (Card), and `full` the pill (Badge). Set `radius: 0` for a square theme, `radius: 12` for a soft one; buttons and inputs always match. Shapes derived from a control's own height (Switch, Slider, ProgressBar, Radio) are not on the scale. Pass an object (`radius: { sm, md, lg, full }`, any subset) to pin individual steps instead.
45
+
46
+ ```jsx
47
+ setTheme({ radius: 4 }) // sm 2, md 4, lg 6
48
+ ```
37
49
 
38
50
  ## Per-component overrides
39
51
 
package/docs/types.md CHANGED
@@ -3,3 +3,7 @@
3
3
  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`.
4
4
 
5
5
  `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.
6
+
7
+ `TransitionProps` (`transition`, `onTransitionEnd`) is the third top-level group, in the component's own vocabulary rather than core's: a declaration names the view-level properties (`opacity`, `x`, `y`, `scale*`, `rotate*`, `origin*`, `perspective`, `clipRadius`) and the style ones (`backgroundColor`, `borderColor`, `borderWidth`, `borderRadius`), plus `all`, a shorthand string, and `stagger` - `<Button transition={{ backgroundColor: { duration: 300 }, opacity: "200ms ease-out" }}>`. Core's paint names (`color`, `radius`, `strokeWidth`) are rejected by the types: a component is a root view plus the rects it draws for `style`, and `splitTransition` hands each entry to the node that owns it (the background rect gets `backgroundColor`/`borderRadius`, the stroke rect `borderColor`/`borderWidth`/`borderRadius`, the root view the rest). `onTransitionEnd` reports the component name (`backgroundColor`, not `color`). `Text` adds `color` (its text node), `ScrollView` adds `scrollX`/`scrollY` (its viewport).
8
+
9
+ Controls whose paint is their own - `Switch` knob, `Slider` thumb, `Checkbox` mark, `Radio` dot, `ProgressBar` fill, `Spinner`, `Icon`, `QrCode`, and the chrome of `NavShell`, `ContextMenu`, `Field` - animate the view-level entries only for now; their internal parts are not reachable through `transition` yet (okf/backlog/component-transitions-internal-paint.md).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solidrt/components",
3
- "version": "0.0.51",
3
+ "version": "0.0.53",
4
4
  "license": "MIT",
5
5
  "funding": "https://github.com/sponsors/wellawaretech",
6
6
  "author": "Antoine van Wel",
@@ -20,7 +20,7 @@
20
20
  "qrcode-generator": "^2.0.4"
21
21
  },
22
22
  "peerDependencies": {
23
- "@solidjs/signals": "2.0.0-rc.0",
24
- "@solidrt/core": "0.0.51"
23
+ "@solidjs/signals": "2.0.0-rc.3",
24
+ "@solidrt/core": "0.0.53"
25
25
  }
26
26
  }
package/src/badge.tsx CHANGED
@@ -1,12 +1,15 @@
1
1
  import { Show, children } from "@solidrt/core"
2
2
  import type { LayoutProps } from "@solidrt/core"
3
3
  import { theme } from "./theme"
4
- import { typeStyle, lightOnDark } from "./typography"
5
- import type { StyleProps } from "./types"
4
+ import { space } from "./spacing"
5
+ import { typeStyle, typeWeight, lightOnDark } from "./typography"
6
+ import { policy } from "./policy"
7
+ import type { StyleProps, TransitionProps } from "./types"
8
+ import { splitTransition, transitionEndFor } from "./types"
6
9
 
7
10
  export type BadgeVariant = "primary" | "neutral" | "danger"
8
11
 
9
- export interface BadgeProps {
12
+ export interface BadgeProps extends TransitionProps {
10
13
  // A string/number renders as the themed pill label; anything else is rendered
11
14
  // as-is (an icon, a dot, ...).
12
15
  children?: any
@@ -16,10 +19,6 @@ export interface BadgeProps {
16
19
  style?: StyleProps
17
20
  }
18
21
 
19
- // A rounded radius large enough to fully pill any typical badge height; the
20
- // renderer clamps it to half the box, so both ends stay round.
21
- const RADIUS = 999
22
-
23
22
  // A small rounded pill for counts, labels, and status. Accent fill with
24
23
  // onPrimary text by default; override the fill via style.backgroundColor and the
25
24
  // label color via style.color.
@@ -39,22 +38,26 @@ export function Badge(props: BadgeProps) {
39
38
  let styled = () => ({ ...theme.components.badge, ...props.style })
40
39
  let bg = () => styled().backgroundColor ?? colors().bg
41
40
  let fg = () => styled().color ?? colors().fg
42
- let radius = () => styled().borderRadius ?? RADIUS
41
+ let radius = () => styled().borderRadius ?? theme.radius.full
43
42
  // Resolved once via children(): the typeof probe and the mount sites must
44
43
  // share one build - reading the raw getter again would orphan native nodes.
45
44
  let resolved = children(() => props.children)
46
45
  let isText = () => typeof resolved() === "string" || typeof resolved() === "number"
47
46
  let labelOnDark = () => lightOnDark(fg(), bg())
48
47
 
48
+ let split = () => splitTransition(props.transition)
49
+
49
50
  return (
50
51
  <view
52
+ transition={split().root}
53
+ onTransitionEnd={transitionEndFor("root", props.onTransitionEnd)}
51
54
  flexDirection="row"
52
55
  alignItems="center"
53
56
  justifyContent="center"
54
- paddingLeft={8}
55
- paddingRight={8}
56
- paddingTop={2}
57
- paddingBottom={2}
57
+ paddingLeft={space("md")}
58
+ paddingRight={space("md")}
59
+ paddingTop={Math.round(space("sm") / 2)}
60
+ paddingBottom={Math.round(space("sm") / 2)}
58
61
  {...props.layout}
59
62
  x={styled().x}
60
63
  y={styled().y}
@@ -62,9 +65,13 @@ export function Badge(props: BadgeProps) {
62
65
  rotate={styled().rotate}
63
66
  opacity={styled().opacity}
64
67
  >
65
- <d-rect color={bg()} radius={radius()} />
68
+ <d-rect transition={split().background} onTransitionEnd={transitionEndFor("background", props.onTransitionEnd)} color={bg()} radius={radius()} />
66
69
  <Show when={isText()} fallback={resolved()}>
67
- <text color={fg()} {...typeStyle("label", labelOnDark())}>
70
+ <text
71
+ color={fg()}
72
+ {...typeStyle("caption", labelOnDark())}
73
+ fontWeight={typeWeight(600, theme.text.caption.size * policy.textScale, labelOnDark())}
74
+ >
68
75
  {resolved()}
69
76
  </text>
70
77
  </Show>
package/src/button.tsx CHANGED
@@ -6,12 +6,13 @@ import { space } from "./spacing"
6
6
  import { typeStyle, lightOnDark } from "./typography"
7
7
  import { Spinner } from "./spinner"
8
8
  import type { LayoutProps } from "@solidrt/core"
9
- import type { StyleProps } from "./types"
9
+ import type { StyleProps, TransitionProps } from "./types"
10
+ import { splitTransition, transitionEndFor } from "./types"
10
11
 
11
12
  export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger"
12
13
  export type ButtonSize = "sm" | "md" | "lg"
13
14
 
14
- export interface ButtonProps {
15
+ export interface ButtonProps extends TransitionProps {
15
16
  // A string/number is rendered as the themed label; anything else is rendered
16
17
  // as-is, so a button can hold custom content (an icon, a row, ...).
17
18
  children?: any
@@ -47,9 +48,8 @@ const SIZE_WIDTH: Record<ButtonSize, number> = { sm: 88, md: 120, lg: 160 }
47
48
  // box via style and the padding/sizing via layout; because hover is an
48
49
  // overlay, it composes over a caller-set backgroundColor too. When disabled,
49
50
  // it takes no pointer events at all. Focus (spatial nav) draws a ring under
50
- // the focusRing policy, text-colored rather than primary so it stays visible
51
- // on primary-filled buttons; Enter/Space/remote-select activates (handled by
52
- // createPress).
51
+ // the focusRing policy in the theme's ring color; Enter/Space/remote-select
52
+ // activates (handled by createPress).
53
53
  export function Button(props: ButtonProps) {
54
54
  // Fill and label color per variant, read reactively from the theme. No
55
55
  // variant draws a border.
@@ -95,7 +95,7 @@ export function Button(props: ButtonProps) {
95
95
  let press = createPress(props)
96
96
  let style = () => ({
97
97
  ...styled(),
98
- ...(press.focused() && policy.focusRing ? { borderWidth: 2, borderColor: theme.color.text } : {}),
98
+ ...(press.focused() && policy.focusRing ? { borderWidth: theme.borderWidth.focus, borderColor: theme.color.ring } : {}),
99
99
  backgroundColor: bg(),
100
100
  borderRadius: radius(),
101
101
  // Always a number: a scale that flips from a number back to undefined
@@ -104,8 +104,12 @@ export function Button(props: ButtonProps) {
104
104
  scale: (styled().scale ?? 1) * (press.pressed() && policy.motion !== "none" ? 0.97 : 1),
105
105
  })
106
106
 
107
+ let split = () => splitTransition(props.transition)
108
+
107
109
  return (
108
110
  <view
111
+ transition={split().root}
112
+ onTransitionEnd={transitionEndFor("root", props.onTransitionEnd)}
109
113
  ref={(n: { id: number }) => {
110
114
  press.ref(n)
111
115
  props.ref?.(n)
@@ -130,7 +134,7 @@ export function Button(props: ButtonProps) {
130
134
  focusable={(props.focusable ?? true) && props.disabled !== true}
131
135
  pointerEvents={props.disabled ? "none" : undefined}
132
136
  >
133
- <d-rect color={style().backgroundColor ?? "transparent"} radius={style().borderRadius} />
137
+ <d-rect transition={split().background} onTransitionEnd={transitionEndFor("background", props.onTransitionEnd)} color={style().backgroundColor ?? "transparent"} radius={style().borderRadius} />
134
138
  <d-rect color={overlay(press.state())} radius={style().borderRadius} />
135
139
  <Show when={isText()} fallback={resolved()}>
136
140
  <text color={press.pending() ? "transparent" : label()} {...typeStyle("body", labelOnDark())}>
@@ -145,6 +149,8 @@ export function Button(props: ButtonProps) {
145
149
  <Show when={(style().borderWidth ?? 0) > 0}>
146
150
  <d-rect
147
151
  drawStyle="stroke"
152
+ transition={split().border}
153
+ onTransitionEnd={transitionEndFor("border", props.onTransitionEnd)}
148
154
  color={style().borderColor ?? "transparent"}
149
155
  strokeWidth={style().borderWidth}
150
156
  radius={style().borderRadius}
package/src/card.tsx CHANGED
@@ -3,9 +3,10 @@ import type { LayoutProps } from "@solidrt/core"
3
3
  import { theme } from "./theme"
4
4
  import { typeStyle } from "./typography"
5
5
  import { space } from "./spacing"
6
- import type { StyleProps } from "./types"
6
+ import type { StyleProps, TransitionProps } from "./types"
7
+ import { splitTransition, transitionEndFor } from "./types"
7
8
 
8
- export interface CardProps {
9
+ export interface CardProps extends TransitionProps {
9
10
  children?: any
10
11
  // Optional heading rendered above the content.
11
12
  title?: string
@@ -25,8 +26,12 @@ export function Card(props: CardProps) {
25
26
  let radius = () => styled().borderRadius ?? theme.radius.lg
26
27
  let hasBorder = () => styled().borderWidth != null || styled().borderColor != null
27
28
 
29
+ let split = () => splitTransition(props.transition)
30
+
28
31
  return (
29
32
  <view
33
+ transition={split().root}
34
+ onTransitionEnd={transitionEndFor("root", props.onTransitionEnd)}
30
35
  ref={props.ref}
31
36
  repaintBoundary
32
37
  flexDirection="column"
@@ -39,7 +44,7 @@ export function Card(props: CardProps) {
39
44
  rotate={styled().rotate}
40
45
  opacity={styled().opacity}
41
46
  >
42
- <d-rect color={bg()} radius={radius()} />
47
+ <d-rect transition={split().background} onTransitionEnd={transitionEndFor("background", props.onTransitionEnd)} color={bg()} radius={radius()} />
43
48
  <Show when={props.title != null}>
44
49
  <text color={theme.color.text} {...typeStyle("title")}>
45
50
  {props.title}
@@ -49,6 +54,8 @@ export function Card(props: CardProps) {
49
54
  <Show when={hasBorder()}>
50
55
  <d-rect
51
56
  drawStyle="stroke"
57
+ transition={split().border}
58
+ onTransitionEnd={transitionEndFor("border", props.onTransitionEnd)}
52
59
  color={styled().borderColor ?? theme.color.border}
53
60
  strokeWidth={styled().borderWidth ?? theme.borderWidth.sm}
54
61
  radius={radius()}