@solidrt/components 0.0.19 → 0.0.21

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
@@ -70,6 +70,13 @@ Most components group props into two objects, plus top-level event handlers:
70
70
  `moduleSize`/`margin`/`radius`/`level` (L/M/Q/H). Paints black-on-white by
71
71
  default (NOT the theme) to stay scannable; override `color`/`background` only
72
72
  if contrast holds. Deps on `qrcode-generator`.
73
+ - `Icon` - thin themed wrapper over the core `<svg>` document primitive. `src`
74
+ is an SVG string (an imported `.svg` asset, a `lucide-static` string export, or
75
+ an inline literal); `size` sets a square box (default 24); `color` drives
76
+ `currentColor` (default `theme.color.text`). Carries no icon set of its own and
77
+ no icon-name registry: pass the SVG string in, so any currentColor set (Lucide,
78
+ Feather, Heroicons) works and only used icons are bundled. Multi-color
79
+ documents keep their own fills. Reach for `<svg>` directly for a non-square box.
73
80
  - `SafeArea` - pads children clear of system UI (notches, status bars); top and
74
81
  bottom on by default, pass `false`/a number per edge.
75
82
  - `theme` / `setTheme` / `darkTheme` / `lightTheme` - shared REACTIVE appearance
package/README.md CHANGED
@@ -536,6 +536,29 @@ import { QrCode } from "@solidrt/components"
536
536
  | `radius` | `number` | `8` | Corner radius of the background panel. |
537
537
  | `layout` | `LayoutProps` | - | Layout of the outer box. |
538
538
 
539
+ ### Icon
540
+
541
+ A thin themed wrapper over the core `<svg>` primitive. `src` is a whole SVG document as a string; the component draws it in a square 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 `<svg>` directly.
542
+
543
+ 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:
544
+
545
+ ```jsx
546
+ import { Icon } from "@solidrt/components"
547
+ import House from "lucide-static/icons/house.svg"
548
+
549
+ <Icon src={House} />
550
+ <Icon src={House} size={32} color={theme.color.primary} />
551
+ ```
552
+
553
+ **Props**
554
+
555
+ | Prop | Type | Default | Description |
556
+ | -------- | ------------- | ------------------ | -------------------------------------------------------------- |
557
+ | `src` | `string` | - | The SVG document to draw. |
558
+ | `size` | `number` | `24` | Square box side in pixels. |
559
+ | `color` | `string` | `theme.color.text` | Drives `currentColor`; explicit fills/strokes still win. |
560
+ | `layout` | `LayoutProps` | - | Layout of the box. |
561
+
539
562
  ## License
540
563
 
541
564
  MIT. Copyright (c) 2026 Antoine van Wel.
@@ -0,0 +1,11 @@
1
+ # @solidrt/components examples
2
+
3
+ Single-concept SolidRT patterns using @solidrt/components. Each file is a
4
+ complete, runnable app (ends in `render(() => <App />)`) demonstrating exactly
5
+ one thing - copy one and adapt it. For core primitives see
6
+ `@solidrt/core/examples`; for the component prop model see
7
+ `@solidrt/components/AGENTS.md`.
8
+
9
+ ## Theme
10
+ - `theme-toggle.tsx` - a `Switch` flipping `setTheme` between `darkTheme` and
11
+ `lightTheme`; every component recolors reactively, no remount.
@@ -0,0 +1,37 @@
1
+ // A Switch toggling between the two built-in themes. Every component reads
2
+ // theme.* reactively, so flipping setTheme() recolors the whole tree with no
3
+ // remount - the Switch itself included.
4
+ import { render, createSignal, createEffect, env } from "@solidrt/core"
5
+ import { Window, View, Text, Switch, Card, theme, setTheme, darkTheme, lightTheme } from "@solidrt/components"
6
+
7
+ function App() {
8
+ // Start from the OS light/dark preference; env.systemTheme is
9
+ // "dark" | "light" | "unknown" (unknown falls back to dark, the default).
10
+ let [dark, setDark] = createSignal(() => env.systemTheme !== "light")
11
+
12
+ createEffect(
13
+ () => dark(),
14
+ (on) => setTheme(on ? darkTheme : lightTheme),
15
+ )
16
+
17
+ return (
18
+ <Window
19
+ title="Theme toggle"
20
+ layout={{ flexDirection: "column", alignItems: "center", justifyContent: "center" }}
21
+ style={{ backgroundColor: theme.color.background }}
22
+ >
23
+ <Card title={dark() ? "Dark theme" : "Light theme"} layout={{ width: 280 }}>
24
+ <View
25
+ layout={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between" }}
26
+ >
27
+ <Text layout={{ fontSize: 14 }} style={{ color: theme.color.text }}>
28
+ Dark mode
29
+ </Text>
30
+ <Switch value={dark()} onChange={setDark} />
31
+ </View>
32
+ </Card>
33
+ </Window>
34
+ )
35
+ }
36
+
37
+ render(() => <App />)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solidrt/components",
3
- "version": "0.0.19",
3
+ "version": "0.0.21",
4
4
  "license": "MIT",
5
5
  "author": "Antoine van Wel",
6
6
  "type": "module",
@@ -10,6 +10,7 @@
10
10
  },
11
11
  "files": [
12
12
  "src/",
13
+ "examples/",
13
14
  "AGENTS.md"
14
15
  ],
15
16
  "dependencies": {
@@ -17,6 +18,6 @@
17
18
  },
18
19
  "peerDependencies": {
19
20
  "@solidjs/signals": "2.0.0-beta.15",
20
- "@solidrt/core": "0.0.19"
21
+ "@solidrt/core": "0.0.21"
21
22
  }
22
23
  }
package/src/badge.tsx CHANGED
@@ -1,12 +1,17 @@
1
1
  import { Show } from "@solidrt/core"
2
2
  import type { LayoutProps } from "@solidrt/core"
3
3
  import { theme } from "./theme"
4
+ import { typeStyle, lightOnDark } from "./typography"
4
5
  import type { StyleProps } from "./types"
5
6
 
7
+ export type BadgeVariant = "primary" | "neutral" | "danger"
8
+
6
9
  export interface BadgeProps {
7
10
  // A string/number renders as the themed pill label; anything else is rendered
8
11
  // as-is (an icon, a dot, ...).
9
12
  children?: any
13
+ // Visual role: primary (accent), neutral (subtle surface), danger.
14
+ variant?: BadgeVariant
10
15
  layout?: LayoutProps
11
16
  style?: StyleProps
12
17
  }
@@ -19,10 +24,22 @@ const RADIUS = 999
19
24
  // onPrimary text by default; override the fill via style.backgroundColor and the
20
25
  // label color via style.color.
21
26
  export function Badge(props: BadgeProps) {
22
- let bg = () => props.style?.backgroundColor ?? theme.color.primary
23
- let fg = () => props.style?.color ?? theme.color.onPrimary
27
+ let colors = () => {
28
+ let c = theme.color
29
+ switch (props.variant ?? "primary") {
30
+ case "neutral":
31
+ return { bg: c.surfaceAlt, fg: c.text }
32
+ case "danger":
33
+ return { bg: c.danger, fg: c.onPrimary }
34
+ default:
35
+ return { bg: c.primary, fg: c.onPrimary }
36
+ }
37
+ }
38
+ let bg = () => props.style?.backgroundColor ?? colors().bg
39
+ let fg = () => props.style?.color ?? colors().fg
24
40
  let radius = () => props.style?.borderRadius ?? RADIUS
25
41
  let isText = () => typeof props.children === "string" || typeof props.children === "number"
42
+ let labelOnDark = () => lightOnDark(fg(), bg())
26
43
 
27
44
  return (
28
45
  <view
@@ -38,10 +55,11 @@ export function Badge(props: BadgeProps) {
38
55
  y={props.style?.y}
39
56
  scale={props.style?.scale}
40
57
  rotate={props.style?.rotate}
58
+ opacity={props.style?.opacity}
41
59
  >
42
60
  <d-rect color={bg()} radius={radius()} />
43
61
  <Show when={isText()} fallback={props.children}>
44
- <text color={fg()} fontSize={12} fontWeight={600}>
62
+ <text color={fg()} {...typeStyle("label", labelOnDark())}>
45
63
  {props.children}
46
64
  </text>
47
65
  </Show>
package/src/button.tsx CHANGED
@@ -1,14 +1,21 @@
1
1
  import { Show } from "@solidrt/core"
2
2
  import { Pressable, type PressState } from "./pressable"
3
3
  import { theme } from "./theme"
4
- import { policy, densityScale } from "./policy"
4
+ import { policy } from "./policy"
5
+ import { space } from "./spacing"
6
+ import { typeStyle, lightOnDark } from "./typography"
5
7
  import type { LayoutProps } from "@solidrt/core"
6
8
  import type { StyleProps } from "./types"
7
9
 
10
+ export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger"
11
+
8
12
  export interface ButtonProps {
9
13
  // A string/number is rendered as the themed label; anything else is rendered
10
14
  // as-is, so a button can hold custom content (an icon, a row, ...).
11
15
  children?: any
16
+ // Visual role: primary (accent fill), secondary (surface fill with border),
17
+ // ghost (no fill until hover), danger (destructive accent fill).
18
+ variant?: ButtonVariant
12
19
  onPress?: () => void
13
20
  disabled?: boolean
14
21
  layout?: LayoutProps
@@ -22,16 +29,42 @@ export interface ButtonProps {
22
29
  // layout. A caller-set backgroundColor disables the hover tint: we cannot know
23
30
  // its hover variant.
24
31
  export function Button(props: ButtonProps) {
32
+ // Fill, hover fill, and label color per variant, read reactively from the
33
+ // theme. Only secondary draws a border.
34
+ let colors = () => {
35
+ let c = theme.color
36
+ switch (props.variant ?? "primary") {
37
+ case "secondary":
38
+ return { fill: c.surface, hover: c.surfaceHover, label: c.text, border: c.border }
39
+ case "ghost":
40
+ return { fill: "transparent", hover: c.surfaceHover, label: c.text, border: undefined }
41
+ case "danger":
42
+ return { fill: c.danger, hover: c.dangerHover, label: c.onPrimary, border: undefined }
43
+ default:
44
+ return { fill: c.primary, hover: c.primaryHover, label: c.onPrimary, border: undefined }
45
+ }
46
+ }
25
47
  let bg = (s: PressState) =>
26
48
  props.style?.backgroundColor ??
27
49
  (props.disabled
28
- ? theme.color.surface
50
+ ? props.variant === "ghost"
51
+ ? "transparent"
52
+ : theme.color.surface
29
53
  : s.hovered && policy.interaction !== "touch"
30
- ? theme.color.primaryHover
31
- : theme.color.primary)
54
+ ? colors().hover
55
+ : colors().fill)
32
56
  let radius = () => props.style?.borderRadius ?? theme.radius.sm
33
- let label = () => (props.disabled ? theme.color.textMuted : theme.color.onPrimary)
57
+ let label = () => (props.disabled ? theme.color.textMuted : colors().label)
34
58
  let isText = () => typeof props.children === "string" || typeof props.children === "number"
59
+ // The label's polarity against the idle fill: onPrimary on a saturated fill
60
+ // is light-on-dark even in a light theme, so it needs the low-DPI weight
61
+ // compensation there too.
62
+ let labelOnDark = () =>
63
+ lightOnDark(
64
+ label(),
65
+ props.style?.backgroundColor ??
66
+ (props.disabled ? (props.variant === "ghost" ? "transparent" : theme.color.surface) : colors().fill),
67
+ )
35
68
 
36
69
  return (
37
70
  <Pressable
@@ -41,13 +74,15 @@ export function Button(props: ButtonProps) {
41
74
  flexDirection: "row",
42
75
  alignItems: "center",
43
76
  justifyContent: "center",
44
- paddingTop: Math.round(theme.spacing.sm * densityScale()),
45
- paddingBottom: Math.round(theme.spacing.sm * densityScale()),
46
- paddingLeft: Math.round(theme.spacing.md * densityScale()),
47
- paddingRight: Math.round(theme.spacing.md * densityScale()),
77
+ paddingTop: space("sm"),
78
+ paddingBottom: space("sm"),
79
+ paddingLeft: space("md"),
80
+ paddingRight: space("md"),
48
81
  ...props.layout,
49
82
  }}
50
83
  style={(s: PressState) => ({
84
+ borderColor: colors().border,
85
+ borderWidth: colors().border != null ? theme.borderWidth.sm : undefined,
51
86
  ...props.style,
52
87
  backgroundColor: bg(s),
53
88
  borderRadius: radius(),
@@ -58,11 +93,7 @@ export function Button(props: ButtonProps) {
58
93
  })}
59
94
  >
60
95
  <Show when={isText()} fallback={props.children}>
61
- <text
62
- color={label()}
63
- fontSize={theme.text.body.size}
64
- lineHeight={theme.text.body.lineHeight}
65
- >
96
+ <text color={label()} {...typeStyle("body", labelOnDark())}>
66
97
  {props.children}
67
98
  </text>
68
99
  </Show>
package/src/card.tsx CHANGED
@@ -1,6 +1,8 @@
1
1
  import { Show } from "@solidrt/core"
2
2
  import type { LayoutProps } from "@solidrt/core"
3
3
  import { theme } from "./theme"
4
+ import { typeStyle } from "./typography"
5
+ import { space } from "./spacing"
4
6
  import type { StyleProps } from "./types"
5
7
 
6
8
  export interface CardProps {
@@ -12,8 +14,6 @@ export interface CardProps {
12
14
  style?: StyleProps
13
15
  }
14
16
 
15
- const RADIUS = 12
16
-
17
17
  // A themed surface container: a padded column box with a subtle border and
18
18
  // rounded corners, reading its colors from the theme so it recolors live.
19
19
  // Override any paint via style, spacing/sizing via layout.
@@ -21,23 +21,25 @@ export function Card(props: CardProps) {
21
21
  let bg = () => props.style?.backgroundColor ?? theme.color.surface
22
22
  let border = () => props.style?.borderColor ?? theme.color.border
23
23
  let width = () => props.style?.borderWidth ?? theme.borderWidth.sm
24
- let radius = () => props.style?.borderRadius ?? RADIUS
24
+ let radius = () => props.style?.borderRadius ?? theme.radius.lg
25
25
 
26
26
  return (
27
27
  <view
28
28
  ref={props.ref}
29
+ repaintBoundary
29
30
  flexDirection="column"
30
- gap={16}
31
- padding={20}
31
+ gap={space("lg")}
32
+ padding={space("xl")}
32
33
  {...props.layout}
33
34
  x={props.style?.x}
34
35
  y={props.style?.y}
35
36
  scale={props.style?.scale}
36
37
  rotate={props.style?.rotate}
38
+ opacity={props.style?.opacity}
37
39
  >
38
40
  <d-rect color={bg()} radius={radius()} />
39
41
  <Show when={props.title != null}>
40
- <text color={theme.color.text} fontSize={18} fontWeight={700}>
42
+ <text color={theme.color.text} {...typeStyle("title")}>
41
43
  {props.title}
42
44
  </text>
43
45
  </Show>
@@ -2,7 +2,9 @@ import { createSignal, onCleanup, createPortal, onLayout, getBoundingBox, Show,
2
2
  import type { LayoutProps, PointerEvent } from "@solidrt/core"
3
3
  import { Pressable, type PressState } from "./pressable"
4
4
  import { theme } from "./theme"
5
- import { policy, densityScale } from "./policy"
5
+ import { policy } from "./policy"
6
+ import { space } from "./spacing"
7
+ import { typeStyle } from "./typography"
6
8
 
7
9
  export interface ContextMenuItem {
8
10
  label: string
@@ -70,12 +72,7 @@ export function ContextMenu(props: ContextMenuProps) {
70
72
  item.onSelect?.()
71
73
  }
72
74
 
73
- let bodyText = (color: string) => ({
74
- fontSize: theme.text.body.size,
75
- lineHeight: theme.text.body.lineHeight,
76
- color,
77
- maxLines: 1,
78
- })
75
+ let bodyText = (color: string) => ({ ...typeStyle("body"), color, maxLines: 1 })
79
76
 
80
77
  let ItemRow = (p: { item: ContextMenuItem; padY: number }) => (
81
78
  <Pressable
@@ -86,8 +83,8 @@ export function ContextMenu(props: ContextMenuProps) {
86
83
  alignItems: "center",
87
84
  paddingTop: p.padY,
88
85
  paddingBottom: p.padY,
89
- paddingLeft: Math.round(theme.spacing.md * densityScale()),
90
- paddingRight: Math.round(theme.spacing.md * densityScale()),
86
+ paddingLeft: space("md"),
87
+ paddingRight: space("md"),
91
88
  }}
92
89
  style={(s: PressState) => ({
93
90
  backgroundColor:
@@ -132,7 +129,7 @@ export function ContextMenu(props: ContextMenuProps) {
132
129
  >
133
130
  <d-rect color={theme.color.surface} radius={theme.radius.sm} />
134
131
  <For each={props.items}>
135
- {(item: ContextMenuItem) => <ItemRow item={item} padY={Math.round(theme.spacing.sm * densityScale())} />}
132
+ {(item: ContextMenuItem) => <ItemRow item={item} padY={space("sm")} />}
136
133
  </For>
137
134
  <d-rect
138
135
  drawStyle="stroke"
package/src/icon.tsx ADDED
@@ -0,0 +1,37 @@
1
+ import type { LayoutProps } from "@solidrt/core"
2
+ import { theme } from "./theme"
3
+
4
+ export interface IconProps {
5
+ // An SVG document as a string: an imported `.svg` asset, a `lucide-static`
6
+ // string export, or an inline template literal. Monochrome icons that stroke/
7
+ // fill with `currentColor` (Lucide, Feather, Heroicons, ...) get recolored by
8
+ // `color`; a multi-color document keeps its own fills.
9
+ src: string
10
+ // Rendered box in pixels, square. Defaults to 24 (the common icon grid).
11
+ size?: number
12
+ // Drives `currentColor` in the document. Defaults to the theme text color.
13
+ color?: string
14
+ layout?: LayoutProps
15
+ }
16
+
17
+ const SIZE = 24
18
+
19
+ // A themed wrapper over the core <svg> document primitive: a square box sized to
20
+ // `size` and colored from the theme by default. This is the only value it adds
21
+ // over `<svg src>` directly, so reach for the primitive when you need a
22
+ // non-square box or want no theme coupling.
23
+ export function Icon(props: IconProps) {
24
+ let size = () => props.size ?? SIZE
25
+
26
+ return (
27
+ <view repaintBoundary>
28
+ <svg
29
+ width={size()}
30
+ height={size()}
31
+ src={props.src}
32
+ color={props.color ?? theme.color.text}
33
+ {...props.layout}
34
+ />
35
+ </view>
36
+ )
37
+ }
package/src/image.tsx CHANGED
@@ -32,6 +32,7 @@ export function Image(props: ImageProps) {
32
32
  y={props.style?.y}
33
33
  scale={props.style?.scale}
34
34
  rotate={props.style?.rotate}
35
+ opacity={props.style?.opacity}
35
36
  onPointerEnter={props.onPointerEnter}
36
37
  onPointerLeave={props.onPointerLeave}
37
38
  onPointerDown={props.onPointerDown}
package/src/index.ts CHANGED
@@ -1,19 +1,19 @@
1
1
  export { Window, type WindowProps } from "./window"
2
2
  export { View, type ViewProps } from "./view"
3
- export { Text, type TextProps } from "./text"
3
+ export { Text, type TextProps, type TextColor } from "./text"
4
4
  export { Image, type ImageProps } from "./image"
5
5
  export { SafeArea } from "./safe-area"
6
6
  export { TextInput, type TextInputProps } from "./text-input"
7
7
  export { ScrollView, type ScrollViewProps } from "./scroll-view"
8
8
  export { Pressable, type PressableProps, type PressState } from "./pressable"
9
- export { Button, type ButtonProps } from "./button"
9
+ export { Button, type ButtonProps, type ButtonVariant } from "./button"
10
10
  export { Switch, type SwitchProps } from "./switch"
11
11
  export { Checkbox, type CheckboxProps } from "./checkbox"
12
12
  export { RadioGroup, Radio, type RadioGroupProps, type RadioProps } from "./radio"
13
13
  export { Slider, type SliderProps } from "./slider"
14
14
  export { Card, type CardProps } from "./card"
15
15
  export { Divider, type DividerProps } from "./divider"
16
- export { Badge, type BadgeProps } from "./badge"
16
+ export { Badge, type BadgeProps, type BadgeVariant } from "./badge"
17
17
  export { Spinner, type SpinnerProps } from "./spinner"
18
18
  export { ProgressBar, type ProgressBarProps } from "./progress-bar"
19
19
  export { Portal, type PortalProps } from "./portal"
@@ -24,7 +24,16 @@ export { ContextMenu, type ContextMenuProps, type ContextMenuItem } from "./cont
24
24
  export { NavShell, type NavShellProps, type NavItem } from "./nav-shell"
25
25
  export { SplitView, type SplitViewProps } from "./split-view"
26
26
  export { QrCode, type QrCodeProps } from "./qrcode"
27
- export { theme, setTheme, darkTheme, lightTheme, type Theme } from "./theme"
27
+ export { Icon, type IconProps } from "./icon"
28
+ export {
29
+ theme,
30
+ setTheme,
31
+ darkTheme,
32
+ lightTheme,
33
+ type Theme,
34
+ type TextStyle,
35
+ type TextVariant,
36
+ } from "./theme"
28
37
  export {
29
38
  policy,
30
39
  setPolicy,
@@ -39,4 +48,6 @@ export {
39
48
  type NavigationPolicy,
40
49
  type LayoutPolicy,
41
50
  } from "./policy"
51
+ export { typeStyle, typeWeight, lightOnDark } from "./typography"
52
+ export { space } from "./spacing"
42
53
  export type { StyleProps, TextLayoutProps } from "./types"
package/src/nav-shell.tsx CHANGED
@@ -2,7 +2,9 @@ import { createSignal, Switch, Match, For } from "@solidrt/core"
2
2
  import type { LayoutProps } from "@solidrt/core"
3
3
  import { Pressable, type PressState } from "./pressable"
4
4
  import { theme } from "./theme"
5
- import { policy, densityScale } from "./policy"
5
+ import { policy } from "./policy"
6
+ import { space } from "./spacing"
7
+ import { typeStyle } from "./typography"
6
8
 
7
9
  export interface NavItem {
8
10
  value: unknown
@@ -66,7 +68,7 @@ export function NavShell(props: NavShellProps) {
66
68
  style={(s: PressState) => ({ backgroundColor: itemBg(p.item, s), borderRadius: theme.radius.sm })}
67
69
  >
68
70
  {p.item.icon}
69
- <text color={labelColor(p.item)} fontSize={11} lineHeight={1.3}>
71
+ <text color={labelColor(p.item)} {...typeStyle("caption")}>
70
72
  {p.item.label}
71
73
  </text>
72
74
  </Pressable>
@@ -112,8 +114,8 @@ export function NavShell(props: NavShellProps) {
112
114
  flexDirection: "row",
113
115
  alignItems: "center",
114
116
  gap: theme.spacing.md,
115
- paddingTop: Math.round(theme.spacing.sm * densityScale()) + 2,
116
- paddingBottom: Math.round(theme.spacing.sm * densityScale()) + 2,
117
+ paddingTop: space("sm") + 2,
118
+ paddingBottom: space("sm") + 2,
117
119
  paddingLeft: theme.spacing.md,
118
120
  paddingRight: theme.spacing.md,
119
121
  marginLeft: theme.spacing.sm,
@@ -124,8 +126,7 @@ export function NavShell(props: NavShellProps) {
124
126
  {item.icon}
125
127
  <text
126
128
  color={item.value === value() ? theme.color.primary : theme.color.text}
127
- fontSize={theme.text.body.size}
128
- lineHeight={theme.text.body.lineHeight}
129
+ {...typeStyle("body")}
129
130
  >
130
131
  {item.label}
131
132
  </text>
package/src/policy.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { capabilities, createSignal } from "@solidrt/core"
1
+ import { capabilities, env, createSignal } from "@solidrt/core"
2
2
  import type { Capabilities } from "@solidrt/core"
3
3
 
4
4
  // Policies: how components should behave. Derived from capabilities by a
@@ -20,6 +20,15 @@ export type Policies = {
20
20
  // keyboard presence; the runtime cannot yet tell keyboard focus from pointer
21
21
  // focus (no Tab traversal), so this is per-session, not per-focus-source.
22
22
  focusRing: boolean
23
+ // Multiplier on type-scale font sizes (Dynamic Type). Follows the OS
24
+ // preference (env.textScale); override via setPolicy to pin it.
25
+ textScale: number
26
+ // Base weight compensation for light-on-dark text on this display, in
27
+ // steps of 100 (other steps decode as 400): low-DPI rendering thins
28
+ // inverted-polarity glyphs. Applied by typeWeight to light-on-dark runs
29
+ // only (per-run polarity, or the theme's palette polarity as the default),
30
+ // with one extra step for small font sizes; see typography.ts.
31
+ textWeightDelta: number
23
32
  // Application policies: recommendations derived from the window size class.
24
33
  // The application owns the final decision; accept them by consuming
25
34
  // policy.navigation / policy.layout, or override via setPolicy.
@@ -48,6 +57,8 @@ export function defaultPolicyResolver(caps: Capabilities): Policies {
48
57
  density: interaction === "desktop" ? "compact" : "comfortable",
49
58
  motion: "normal",
50
59
  focusRing: caps.keyboardNav,
60
+ textScale: env.textScale,
61
+ textWeightDelta: env.displayScale < 1.5 ? 100 : 0,
51
62
  navigation:
52
63
  caps.windowSizeClass === "expanded" ? "sidebar" : caps.windowSizeClass === "medium" ? "rail" : "bottomTabs",
53
64
  layout: caps.windowSizeClass === "expanded" ? "twoPane" : "singlePane",
@@ -76,6 +87,12 @@ export let policy = {
76
87
  get focusRing(): boolean {
77
88
  return overrides().focusRing ?? resolved().focusRing
78
89
  },
90
+ get textScale(): number {
91
+ return overrides().textScale ?? resolved().textScale
92
+ },
93
+ get textWeightDelta(): number {
94
+ return overrides().textWeightDelta ?? resolved().textWeightDelta
95
+ },
79
96
  get navigation(): NavigationPolicy {
80
97
  return overrides().navigation ?? resolved().navigation
81
98
  },
package/src/pressable.tsx CHANGED
@@ -54,11 +54,13 @@ export function Pressable(props: PressableProps) {
54
54
  return (
55
55
  <view
56
56
  ref={props.ref}
57
+ repaintBoundary
57
58
  {...props.layout}
58
59
  x={style()?.x}
59
60
  y={style()?.y}
60
61
  scale={style()?.scale}
61
62
  rotate={style()?.rotate}
63
+ opacity={style()?.opacity}
62
64
  onPointerEnter={handleEnter}
63
65
  onPointerLeave={handleLeave}
64
66
  onPointerDown={handleDown}
package/src/qrcode.tsx CHANGED
@@ -27,45 +27,51 @@ const MODULE_SIZE = 6
27
27
  const MARGIN = 16
28
28
  const RADIUS = 8
29
29
 
30
- // Render a QR for `data` as primitives: merge horizontal runs of same-color
31
- // modules per row into a single sized box, on a light quiet-zone panel. The
32
- // module grid recomputes only when the data or error-correction level changes.
30
+ // Render a QR for `data` as primitives: merge horizontal runs of dark modules
31
+ // per row into a single d-rect, placed at explicit coordinates on a light
32
+ // quiet-zone panel. Everything inside the panel is detached, so a data change
33
+ // repaints without touching layout; the panel view itself has a fixed size
34
+ // (module count * module size + margins) that only changes when the data
35
+ // crosses a QR version boundary.
33
36
  export function QrCode(props: QrCodeProps) {
34
- let rows = createMemo(() => {
37
+ let grid = createMemo(() => {
35
38
  let qr = qrcode(0, props.level ?? "M")
36
39
  qr.addData(props.data)
37
40
  qr.make()
38
41
  let n = qr.getModuleCount()
39
42
 
40
- let out: { dark: boolean; len: number }[][] = []
43
+ let runs: { x: number; y: number; len: number }[] = []
41
44
  for (let y = 0; y < n; y++) {
42
- let runs: { dark: boolean; len: number }[] = []
43
45
  let x = 0
44
46
  while (x < n) {
45
- let dark = qr.isDark(y, x)
47
+ if (!qr.isDark(y, x)) {
48
+ x++
49
+ continue
50
+ }
46
51
  let len = 1
47
- while (x + len < n && qr.isDark(y, x + len) === dark) len++
48
- runs.push({ dark, len })
52
+ while (x + len < n && qr.isDark(y, x + len)) len++
53
+ runs.push({ x, y, len })
49
54
  x += len
50
55
  }
51
- out.push(runs)
52
56
  }
53
- return out
57
+ return { n, runs }
54
58
  })
55
59
 
56
60
  let size = () => props.moduleSize ?? MODULE_SIZE
61
+ let margin = () => props.margin ?? MARGIN
62
+ let side = () => grid().n * size() + 2 * margin()
57
63
 
58
64
  return (
59
- <view flexDirection="column" padding={props.margin ?? MARGIN} {...props.layout}>
65
+ <view repaintBoundary width={side()} height={side()} {...props.layout}>
60
66
  <d-rect color={props.background ?? "#ffffff"} radius={props.radius ?? RADIUS} />
61
- {rows().map((runs) => (
62
- <view flexDirection="row">
63
- {runs.map((run) => (
64
- <view width={run.len * size()} height={size()}>
65
- {run.dark ? <d-rect color={props.color ?? "#000000"} /> : null}
66
- </view>
67
- ))}
68
- </view>
67
+ {grid().runs.map((run) => (
68
+ <d-rect
69
+ x={margin() + run.x * size()}
70
+ y={margin() + run.y * size()}
71
+ w={run.len * size()}
72
+ h={size()}
73
+ color={props.color ?? "#000000"}
74
+ />
69
75
  ))}
70
76
  </view>
71
77
  )
package/src/radio.tsx CHANGED
@@ -3,6 +3,7 @@ import type { LayoutProps } from "@solidrt/core"
3
3
  import { Pressable } from "./pressable"
4
4
  import { theme } from "./theme"
5
5
  import { densityScale } from "./policy"
6
+ import { typeStyle } from "./typography"
6
7
  import type { StyleProps } from "./types"
7
8
 
8
9
  // Shared selection state for a group. Created and consumed within this module, so
@@ -91,7 +92,7 @@ export function Radio(props: RadioProps) {
91
92
  </Show>
92
93
  </view>
93
94
  <Show when={isText()} fallback={props.children}>
94
- <text color={theme.color.text} fontSize={theme.text.body.size} lineHeight={theme.text.body.lineHeight}>
95
+ <text color={theme.color.text} {...typeStyle("body")}>
95
96
  {props.children}
96
97
  </text>
97
98
  </Show>
@@ -65,6 +65,7 @@ export function ScrollView(props: ScrollViewProps) {
65
65
  y={props.style?.y}
66
66
  scale={props.style?.scale}
67
67
  rotate={props.style?.rotate}
68
+ opacity={props.style?.opacity}
68
69
  onPointerEnter={props.onPointerEnter}
69
70
  onPointerLeave={props.onPointerLeave}
70
71
  onPointerDown={props.onPointerDown}
package/src/select.tsx CHANGED
@@ -2,7 +2,9 @@ import { createSignal, createPortal, onLayout, getBoundingBox, Show, For, env }
2
2
  import type { LayoutProps } from "@solidrt/core"
3
3
  import { Pressable, type PressState } from "./pressable"
4
4
  import { theme } from "./theme"
5
- import { policy, densityScale } from "./policy"
5
+ import { policy } from "./policy"
6
+ import { space } from "./spacing"
7
+ import { typeStyle } from "./typography"
6
8
  import type { StyleProps } from "./types"
7
9
 
8
10
  export interface SelectOption {
@@ -47,12 +49,7 @@ export function Select(props: SelectProps) {
47
49
  props.onChange?.(v)
48
50
  }
49
51
 
50
- let bodyText = (color: string) => ({
51
- fontSize: theme.text.body.size,
52
- lineHeight: theme.text.body.lineHeight,
53
- color,
54
- maxLines: 1,
55
- })
52
+ let bodyText = (color: string) => ({ ...typeStyle("body"), color, maxLines: 1 })
56
53
 
57
54
  // One option row, shared by both presentations; only the vertical padding
58
55
  // differs (the sheet gets taller touch targets).
@@ -64,8 +61,8 @@ export function Select(props: SelectProps) {
64
61
  alignItems: "center",
65
62
  paddingTop: p.padY,
66
63
  paddingBottom: p.padY,
67
- paddingLeft: Math.round(theme.spacing.md * densityScale()),
68
- paddingRight: Math.round(theme.spacing.md * densityScale()),
64
+ paddingLeft: space("md"),
65
+ paddingRight: space("md"),
69
66
  }}
70
67
  style={(s: PressState) => ({
71
68
  backgroundColor:
@@ -111,7 +108,7 @@ export function Select(props: SelectProps) {
111
108
  >
112
109
  <d-rect color={theme.color.surface} radius={theme.radius.sm} />
113
110
  <For each={props.options}>
114
- {(o: SelectOption) => <OptionRow option={o} padY={Math.round(theme.spacing.sm * densityScale())} />}
111
+ {(o: SelectOption) => <OptionRow option={o} padY={space("sm")} />}
115
112
  </For>
116
113
  <d-rect
117
114
  drawStyle="stroke"
@@ -159,10 +156,10 @@ export function Select(props: SelectProps) {
159
156
  alignItems: "center",
160
157
  justifyContent: "space-between",
161
158
  gap: theme.spacing.md,
162
- paddingTop: Math.round(theme.spacing.sm * densityScale()),
163
- paddingBottom: Math.round(theme.spacing.sm * densityScale()),
164
- paddingLeft: Math.round(theme.spacing.md * densityScale()),
165
- paddingRight: Math.round(theme.spacing.md * densityScale()),
159
+ paddingTop: space("sm"),
160
+ paddingBottom: space("sm"),
161
+ paddingLeft: space("md"),
162
+ paddingRight: space("md"),
166
163
  ...props.layout,
167
164
  }}
168
165
  style={(s: PressState) => ({
package/src/slider.tsx CHANGED
@@ -90,6 +90,7 @@ export function Slider(props: SliderProps) {
90
90
  return (
91
91
  <view
92
92
  ref={(n: { id: number }) => (track = n)}
93
+ repaintBoundary
93
94
  flexDirection="row"
94
95
  alignItems="center"
95
96
  height={height()}
@@ -99,6 +100,7 @@ export function Slider(props: SliderProps) {
99
100
  y={props.style?.y}
100
101
  scale={props.style?.scale}
101
102
  rotate={props.style?.rotate}
103
+ opacity={props.style?.opacity}
102
104
  pointerEvents={props.disabled ? "none" : "auto"}
103
105
  onPointerDown={handleDown}
104
106
  onPointerMove={handleMove}
package/src/spacing.ts ADDED
@@ -0,0 +1,11 @@
1
+ import { theme, type Theme } from "./theme"
2
+ import { densityScale } from "./policy"
3
+
4
+ // Density-scaled spacing: a theme.spacing token multiplied by the density
5
+ // policy's metric scale, rounded to whole pixels. Use it for gaps and paddings
6
+ // that should tighten under compact/dense density; read theme.spacing directly
7
+ // only for distances that must not move with density. Reactive when called
8
+ // inside a tracked scope, like any theme/policy read.
9
+ export function space(token: keyof Theme["spacing"]): number {
10
+ return Math.round(theme.spacing[token] * densityScale())
11
+ }
package/src/spinner.tsx CHANGED
@@ -58,6 +58,7 @@ export function Spinner(props: SpinnerProps) {
58
58
  rotate={angle()}
59
59
  x={props.style?.x}
60
60
  y={props.style?.y}
61
+ opacity={props.style?.opacity}
61
62
  >
62
63
  <Show when={policy.motion !== "none"}>
63
64
  <Animate />
@@ -1,15 +1,21 @@
1
1
  import { createEffect, createSignal, onCleanup } from "@solidjs/signals"
2
- import { setFocus } from "@solidrt/core"
2
+ import { measureText, setFocus } from "@solidrt/core"
3
3
  import { createCaretScroll, createTextBuffer } from "@solidrt/core/text-input"
4
4
  import type { LayoutProps } from "@solidrt/core"
5
5
  import type { StyleProps } from "./types"
6
6
  import { theme } from "./theme"
7
- import { policy, densityScale } from "./policy"
7
+ import { policy } from "./policy"
8
+ import { space } from "./spacing"
8
9
 
9
10
  // Caret thickness. Shared so the drawn caret and the scroll offset's reserved
10
11
  // edge column cannot drift apart.
11
12
  const CARET_WIDTH = 1
12
13
 
14
+ // Shaping width handed to the detached value/placeholder text: effectively
15
+ // unbounded, so a single line never wraps. The viewport clips it and scrollX
16
+ // slides it.
17
+ const TEXT_SHAPE_WIDTH = 1e9
18
+
13
19
  export interface TextInputProps {
14
20
  value?: string
15
21
  defaultValue?: string
@@ -134,25 +140,24 @@ export function TextInput(props: TextInputProps) {
134
140
  let showPlaceholder = () => !focused() && value().length === 0 && (props.placeholder ?? "").length > 0
135
141
  let showCaret = () => focused() && caretOn() && !showPlaceholder()
136
142
 
137
- // The text is split at the caret into two nodes with a zero-size anchor view
138
- // between them. Flow places the anchor at the caret x (the before-text width),
139
- // and the caret is a detached d-rect inside it: detached nodes take no layout
140
- // slot, so the anchor stays zero-width and the after-text is not shifted. The
141
- // anchor stays mounted while the caret blinks; only a detached d-view toggles
142
- // inside it, so turning the caret on and off never relays the row. The
143
- // anchor sits at the row's vertical center (alignItems center, zero height),
144
- // so the caret is offset up by half its height to straddle it. While the
145
- // placeholder shows, value() is "" so the slices and the scroll offset are 0
146
- // with no special case. The viewport node is the inner scroll container;
147
- // createCaretScroll reads its laid-out width after layout, keeps the caret in
148
- // view, and flushes the offset before paint.
149
- let beforeCaret = () => value().slice(0, buffer.caret())
150
- let afterCaret = () => value().slice(buffer.caret())
143
+ // Everything inside the viewport is detached: the value is one d-text shaped
144
+ // at an unbounded width and the caret a d-rect at the measured before-caret
145
+ // width, so typing, caret movement, blink and scroll never touch layout. The
146
+ // viewport carries an explicit height (detached content takes no layout
147
+ // slot) equal to the one-line paragraph height, which keeps the text where
148
+ // the old centered attached row sat. createCaretScroll keeps the caret in
149
+ // view and flushes the offset before paint; scrollX is a paint-time
150
+ // translate that also applies to detached children.
151
+ // All one-line metrics derive from the scaled body size, so the field, the
152
+ // caret, and the scroll math grow together under policy.textScale.
153
+ let fontSize = () => theme.text.body.size * policy.textScale
154
+ let rowHeight = () => Math.round(fontSize() * theme.text.body.lineHeight)
155
+ let caretX = () => measureText(value().slice(0, buffer.caret()), { fontSize: fontSize() }).width
151
156
  let scrollX = createCaretScroll(
152
157
  () => viewport,
153
158
  () => ({
154
159
  text: value(),
155
- fontSize: theme.text.body.size,
160
+ fontSize: fontSize(),
156
161
  caret: buffer.caret(),
157
162
  // Constant, not tied to caret visibility: the caret's footprint does not
158
163
  // change as it blinks, so reserving the column only when shown would swing
@@ -162,11 +167,11 @@ export function TextInput(props: TextInputProps) {
162
167
  )
163
168
 
164
169
  let textStyle = (color: string) => ({
165
- fontSize: theme.text.body.size,
170
+ w: TEXT_SHAPE_WIDTH,
171
+ fontSize: fontSize(),
166
172
  lineHeight: theme.text.body.lineHeight,
167
173
  color,
168
174
  maxLines: 1,
169
- flexShrink: 0,
170
175
  })
171
176
 
172
177
  return (
@@ -174,15 +179,16 @@ export function TextInput(props: TextInputProps) {
174
179
  ref={(n: { id: number }) => (node = n)}
175
180
  flexDirection="row"
176
181
  alignItems="center"
177
- paddingLeft={Math.round(theme.spacing.md * densityScale())}
178
- paddingRight={Math.round(theme.spacing.md * densityScale())}
179
- paddingTop={Math.round(theme.spacing.sm * densityScale())}
180
- paddingBottom={Math.round(theme.spacing.sm * densityScale())}
182
+ paddingLeft={space("md")}
183
+ paddingRight={space("md")}
184
+ paddingTop={space("sm")}
185
+ paddingBottom={space("sm")}
181
186
  {...props.layout}
182
187
  x={props.style?.x}
183
188
  y={props.style?.y}
184
189
  scale={props.style?.scale}
185
190
  rotate={props.style?.rotate}
191
+ opacity={props.style?.opacity}
186
192
  onPointerDown={handlePointerDown}
187
193
  onFocus={handleFocus}
188
194
  onBlur={handleBlur}
@@ -199,30 +205,25 @@ export function TextInput(props: TextInputProps) {
199
205
  <view
200
206
  ref={(n: { id: number }) => (viewport = n)}
201
207
  flex={1}
202
- flexDirection="row"
203
- alignItems="center"
208
+ height={rowHeight()}
204
209
  overflow="hidden"
205
210
  scrollX={scrollX()}
206
211
  >
207
212
  {showPlaceholder() ? (
208
- <text {...textStyle(theme.color.textMuted)}>{props.placeholder ?? ""}</text>
213
+ <d-text {...textStyle(theme.color.textMuted)}>{props.placeholder ?? ""}</d-text>
209
214
  ) : (
210
- <view flexDirection="row" alignItems="center" flexShrink={0}>
211
- <text {...textStyle(textColor())}>{beforeCaret()}</text>
212
- <view>
213
- {showCaret() ? (
214
- <d-view>
215
- <d-rect
216
- color={textColor()}
217
- y={-theme.text.body.size / 2}
218
- w={CARET_WIDTH}
219
- h={theme.text.body.size}
220
- />
221
- </d-view>
222
- ) : null}
223
- </view>
224
- <text {...textStyle(textColor())}>{afterCaret()}</text>
225
- </view>
215
+ <>
216
+ <d-text {...textStyle(textColor())}>{value()}</d-text>
217
+ {showCaret() ? (
218
+ <d-rect
219
+ color={textColor()}
220
+ x={caretX()}
221
+ y={(rowHeight() - fontSize()) / 2}
222
+ w={CARET_WIDTH}
223
+ h={fontSize()}
224
+ />
225
+ ) : null}
226
+ </>
226
227
  )}
227
228
  </view>
228
229
  </view>
package/src/text.tsx CHANGED
@@ -1,9 +1,26 @@
1
1
  import { createMemo } from "@solidjs/signals"
2
2
  import type { PointerProps } from "@solidrt/core"
3
3
  import type { StyleProps, TextLayoutProps } from "./types"
4
+ import { theme, type TextVariant } from "./theme"
5
+ import { policy } from "./policy"
6
+ import { typeWeight } from "./typography"
7
+
8
+ // Semantic text colors, resolved through the theme. Curated: only tokens that
9
+ // make sense as a text fill; style.color takes raw values for anything else.
10
+ export type TextColor = "text" | "textMuted" | "primary" | "onPrimary" | "danger"
4
11
 
5
12
  export interface TextProps extends PointerProps {
6
13
  children?: any
14
+ // Typography role from the theme's type scale; defaults to "body". Explicit
15
+ // layout font props override the role's fields individually. fontSize
16
+ // (role-derived or explicit) is multiplied by policy.textScale and
17
+ // fontWeight carries the typeWeight low-DPI compensation; use the core
18
+ // <text> primitive for text that must not scale.
19
+ variant?: TextVariant
20
+ // Semantic color from the theme; defaults to "text". style.color still wins.
21
+ color?: TextColor
22
+ // Sugar for color="textMuted".
23
+ muted?: boolean
7
24
  ref?: (node: { id: number }) => void
8
25
  layout?: TextLayoutProps
9
26
  style?: StyleProps
@@ -22,6 +39,11 @@ const FONT_KEYS = [
22
39
  ]
23
40
 
24
41
  export function Text(props: TextProps) {
42
+ let role = () => theme.text[props.variant ?? "body"]
43
+ let size = () => (props.layout?.fontSize ?? role().size) * policy.textScale
44
+ let color = () =>
45
+ props.style?.color ?? theme.color[props.color ?? (props.muted ? "textMuted" : "text")]
46
+
25
47
  let box = createMemo(() => {
26
48
  let l = props.layout
27
49
  if (!l) return {}
@@ -40,6 +62,7 @@ export function Text(props: TextProps) {
40
62
  y={props.style?.y}
41
63
  scale={props.style?.scale}
42
64
  rotate={props.style?.rotate}
65
+ opacity={props.style?.opacity}
43
66
  onPointerEnter={props.onPointerEnter}
44
67
  onPointerLeave={props.onPointerLeave}
45
68
  onPointerDown={props.onPointerDown}
@@ -54,12 +77,12 @@ export function Text(props: TextProps) {
54
77
  pointerEvents={props.pointerEvents}
55
78
  >
56
79
  <text
57
- color={props.style?.color}
58
- fontFamily={props.layout?.fontFamily}
59
- fontSize={props.layout?.fontSize}
60
- lineHeight={props.layout?.lineHeight}
80
+ color={color()}
81
+ fontFamily={props.layout?.fontFamily ?? theme.text.fontFamily}
82
+ fontSize={size()}
83
+ lineHeight={props.layout?.lineHeight ?? role().lineHeight}
61
84
  fontStyle={props.layout?.fontStyle}
62
- fontWeight={props.layout?.fontWeight}
85
+ fontWeight={typeWeight(props.layout?.fontWeight ?? role().weight, size())}
63
86
  textAlign={props.layout?.textAlign}
64
87
  maxLines={props.layout?.maxLines}
65
88
  >
package/src/theme.ts CHANGED
@@ -1,12 +1,24 @@
1
- import { createStore } from "@solidrt/core"
1
+ import { createStore, mixColors } from "@solidrt/core"
2
2
 
3
3
  export type TextStyle = {
4
4
  size: number
5
5
  lineHeight: number
6
+ weight: 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900
6
7
  }
7
8
 
9
+ // The type scale's role names. <Text variant> and theme.text are keyed by these.
10
+ export type TextVariant = "caption" | "label" | "body" | "title" | "heading"
11
+
8
12
  export type Theme = {
9
- text: { body: TextStyle }
13
+ text: {
14
+ // Passed through to the core font stack: "sans" | "mono" | a family name.
15
+ fontFamily: string
16
+ caption: TextStyle
17
+ label: TextStyle
18
+ body: TextStyle
19
+ title: TextStyle
20
+ heading: TextStyle
21
+ }
10
22
  color: {
11
23
  // Window fill.
12
24
  background: string
@@ -25,18 +37,29 @@ export type Theme = {
25
37
  onPrimary: string
26
38
  // Validation / destructive.
27
39
  danger: string
40
+ // Hover tint for danger-colored controls.
41
+ dangerHover: string
28
42
  // Overlay dim behind modals.
29
43
  scrim: string
30
44
  }
31
- spacing: { sm: number; md: number }
32
- radius: { sm: number }
45
+ spacing: { sm: number; md: number; lg: number; xl: number }
46
+ radius: { sm: number; md: number; lg: number }
33
47
  borderWidth: { sm: number }
34
48
  }
35
49
 
36
- // Scheme-independent tokens, shared by both presets.
37
- const TEXT = { body: { size: 14, lineHeight: 1.5 } }
38
- const SPACING = { sm: 4, md: 8 }
39
- const RADIUS = { sm: 4 }
50
+ // Scheme-independent tokens, shared by both presets. The type scale: body is
51
+ // the base text style; caption and label sit under it (secondary and
52
+ // emphasized small text), title and heading above it (card and page headings).
53
+ const TEXT: Theme["text"] = {
54
+ fontFamily: "sans",
55
+ caption: { size: 11, lineHeight: 1.3, weight: 400 },
56
+ label: { size: 12, lineHeight: 1.3, weight: 600 },
57
+ body: { size: 14, lineHeight: 1.5, weight: 400 },
58
+ title: { size: 18, lineHeight: 1.4, weight: 700 },
59
+ heading: { size: 22, lineHeight: 1.3, weight: 700 },
60
+ }
61
+ const SPACING = { sm: 4, md: 8, lg: 16, xl: 20 }
62
+ const RADIUS = { sm: 4, md: 8, lg: 12 }
40
63
  const BORDER_WIDTH = { sm: 1 }
41
64
 
42
65
  export let darkTheme: Theme = {
@@ -47,12 +70,16 @@ export let darkTheme: Theme = {
47
70
  surfaceAlt: "#21262d",
48
71
  surfaceHover: "#262c34",
49
72
  text: "#e6edf3",
50
- textMuted: "rgba(230,237,243,0.5)",
73
+ // Muted is an opaque tone between text and background, mixed in LAB (like
74
+ // Material 3's tonal colors, not an alpha overlay): alpha text renders
75
+ // thin on low-DPI and its contrast depends on what sits behind it.
76
+ textMuted: mixColors("#e6edf3", "#0b0f17", 0.4),
51
77
  border: "rgba(255,255,255,0.14)",
52
78
  primary: "#1f6feb",
53
79
  primaryHover: "#388bfd",
54
80
  onPrimary: "#ffffff",
55
81
  danger: "#f85149",
82
+ dangerHover: "#ff7b72",
56
83
  scrim: "rgba(0,0,0,0.6)",
57
84
  },
58
85
  spacing: SPACING,
@@ -68,12 +95,13 @@ export let lightTheme: Theme = {
68
95
  surfaceAlt: "#eaeef2",
69
96
  surfaceHover: "#e0e5eb",
70
97
  text: "#1f2328",
71
- textMuted: "rgba(31,35,40,0.5)",
98
+ textMuted: mixColors("#1f2328", "#ffffff", 0.4),
72
99
  border: "rgba(0,0,0,0.15)",
73
100
  primary: "#1f6feb",
74
101
  primaryHover: "#1a5fd0",
75
102
  onPrimary: "#ffffff",
76
103
  danger: "#cf222e",
104
+ dangerHover: "#a40e26",
77
105
  scrim: "rgba(0,0,0,0.4)",
78
106
  },
79
107
  spacing: SPACING,
package/src/tooltip.tsx CHANGED
@@ -1,7 +1,9 @@
1
1
  import { createSignal, onCleanup, createPortal, onLayout, getBoundingBox, Show, env } from "@solidrt/core"
2
2
  import type { LayoutProps, PointerEvent } from "@solidrt/core"
3
3
  import { theme } from "./theme"
4
- import { policy, densityScale } from "./policy"
4
+ import { policy } from "./policy"
5
+ import { space } from "./spacing"
6
+ import { typeStyle } from "./typography"
5
7
 
6
8
  export interface TooltipProps {
7
9
  // The tooltip body. A string/number renders as themed text; anything else
@@ -68,20 +70,21 @@ export function Tooltip(props: TooltipProps) {
68
70
  return createPortal(
69
71
  <view
70
72
  ref={(n: { id: number }) => (bubble = n)}
73
+ repaintBoundary
71
74
  position="absolute"
72
75
  top={0}
73
76
  left={0}
74
77
  x={pos()?.x ?? -10000}
75
78
  y={pos()?.y ?? 0}
76
- paddingTop={Math.round(theme.spacing.sm * densityScale())}
77
- paddingBottom={Math.round(theme.spacing.sm * densityScale())}
78
- paddingLeft={Math.round(theme.spacing.md * densityScale())}
79
- paddingRight={Math.round(theme.spacing.md * densityScale())}
79
+ paddingTop={space("sm")}
80
+ paddingBottom={space("sm")}
81
+ paddingLeft={space("md")}
82
+ paddingRight={space("md")}
80
83
  pointerEvents="none"
81
84
  >
82
85
  <d-rect color={theme.color.surfaceAlt} radius={theme.radius.sm} />
83
86
  <Show when={isText()} fallback={props.content}>
84
- <text color={theme.color.text} fontSize={theme.text.body.size} lineHeight={theme.text.body.lineHeight}>
87
+ <text color={theme.color.text} {...typeStyle("body")}>
85
88
  {props.content}
86
89
  </text>
87
90
  </Show>
package/src/types.ts CHANGED
@@ -17,6 +17,7 @@ export interface StyleProps {
17
17
  y?: number
18
18
  rotate?: number
19
19
  scale?: number
20
+ opacity?: number
20
21
  }
21
22
 
22
23
  // Text shaping affects measurement, so font props belong with layout rather
@@ -0,0 +1,57 @@
1
+ import { brightness } from "@solidrt/core"
2
+ import { theme, type TextStyle, type TextVariant } from "./theme"
3
+ import { policy } from "./policy"
4
+
5
+ // Below this effective font size, light-on-dark text on a low-DPI display
6
+ // needs an extra compensation step (edge pixels dominate small glyphs).
7
+ const SMALL_TEXT = 16
8
+
9
+ // The rendering polarity of `text` drawn on `fill`, for typeWeight/typeStyle:
10
+ // true when the text is the lighter of the two. Returns undefined (= fall
11
+ // back to the theme's default polarity) when either side is not a comparable
12
+ // color (gradients, "transparent").
13
+ export function lightOnDark(text: unknown, fill: unknown): boolean | undefined {
14
+ if (typeof text !== "string" || typeof fill !== "string" || fill === "transparent") return undefined
15
+ return brightness(text) > brightness(fill)
16
+ }
17
+
18
+ // The theme's default polarity, derived from its own palette: light text on
19
+ // a dark window background means a dark scheme. Nothing to declare per
20
+ // preset, and it cannot disagree with the colors.
21
+ function themeOnDark(): boolean {
22
+ return lightOnDark(theme.color.text, theme.color.background) ?? false
23
+ }
24
+
25
+ // A themed font weight with low-DPI rendering compensation applied. The
26
+ // renderer (Impeller) rasterizes glyphs unhinted and composites the coverage
27
+ // in nonlinear sRGB, which steals stem ink from light-on-dark text only (and
28
+ // donates it to dark-on-light); the loss grows as glyphs shrink. Compensated
29
+ // text adds policy.textWeightDelta (0 on high-DPI displays) plus one extra
30
+ // step under SMALL_TEXT px; dark-on-light text passes through untouched.
31
+ // `onDark` is the run's own polarity where the caller knows both colors (use
32
+ // the lightOnDark() helper, like Button does for its fills); omitted, it
33
+ // defaults to the theme's palette polarity. `size` is the effective
34
+ // (post-textScale) font size. Clamped to the 900 ceiling; reactive like any
35
+ // theme/policy read.
36
+ export function typeWeight(weight: number, size: number, onDark?: boolean): TextStyle["weight"] {
37
+ let delta = (onDark ?? themeOnDark()) ? policy.textWeightDelta : 0
38
+ if (delta > 0 && size < SMALL_TEXT) delta += 100
39
+ return Math.min(900, weight + delta) as TextStyle["weight"]
40
+ }
41
+
42
+ // Resolved font props for a type-scale role, with the text policies applied:
43
+ // spread onto a <text> or d-text. fontSize carries policy.textScale
44
+ // (lineHeight is relative to the size, so it scales implicitly), fontWeight
45
+ // carries the typeWeight compensation (pass `onDark` when the text sits on a
46
+ // known fill). Reactive when called inside a tracked scope, like any
47
+ // theme/policy read.
48
+ export function typeStyle(variant: TextVariant, onDark?: boolean) {
49
+ let role = theme.text[variant]
50
+ let size = role.size * policy.textScale
51
+ return {
52
+ fontFamily: theme.text.fontFamily,
53
+ fontSize: size,
54
+ lineHeight: role.lineHeight,
55
+ fontWeight: typeWeight(role.weight, size, onDark),
56
+ }
57
+ }
package/src/view.tsx CHANGED
@@ -21,6 +21,7 @@ export function View(props: ViewProps) {
21
21
  y={props.style?.y}
22
22
  scale={props.style?.scale}
23
23
  rotate={props.style?.rotate}
24
+ opacity={props.style?.opacity}
24
25
  onPointerEnter={props.onPointerEnter}
25
26
  onPointerLeave={props.onPointerLeave}
26
27
  onPointerDown={props.onPointerDown}