@solidrt/components 0.0.51 → 0.0.52

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/AGENTS.md +34 -7
  2. package/README.md +47 -10
  3. package/demos/README.md +24 -0
  4. package/demos/assets/icon.png +0 -0
  5. package/demos/assets/icon.svg +23 -0
  6. package/demos/package.json +9 -0
  7. package/demos/src/gallery.tsx +866 -0
  8. package/demos/tsconfig.json +15 -0
  9. package/docs/button.md +1 -1
  10. package/docs/focus-nav.md +1 -1
  11. package/docs/icon.md +1 -1
  12. package/docs/item.md +1 -1
  13. package/docs/policy.md +1 -1
  14. package/docs/scroll-view.md +22 -1
  15. package/docs/split-view.md +1 -1
  16. package/docs/theme.md +14 -2
  17. package/docs/types.md +4 -0
  18. package/package.json +4 -3
  19. package/src/badge.tsx +21 -14
  20. package/src/button.tsx +13 -7
  21. package/src/card.tsx +10 -3
  22. package/src/checkbox.tsx +8 -2
  23. package/src/context-menu.tsx +9 -6
  24. package/src/divider.tsx +8 -3
  25. package/src/editor-field.tsx +18 -11
  26. package/src/field.tsx +4 -2
  27. package/src/icon.tsx +8 -4
  28. package/src/image.tsx +10 -3
  29. package/src/index.ts +10 -1
  30. package/src/item.tsx +12 -7
  31. package/src/nav-shell.tsx +6 -16
  32. package/src/policy.ts +9 -4
  33. package/src/pressable.tsx +11 -2
  34. package/src/progress-bar.tsx +4 -3
  35. package/src/qrcode.tsx +7 -5
  36. package/src/radio.tsx +12 -4
  37. package/src/rich-text-editor.tsx +5 -3
  38. package/src/scroll-view.tsx +76 -7
  39. package/src/segmented-control.tsx +14 -5
  40. package/src/select.tsx +23 -12
  41. package/src/slider.tsx +31 -5
  42. package/src/spinner.tsx +5 -2
  43. package/src/split-view.tsx +3 -4
  44. package/src/switch.tsx +8 -2
  45. package/src/text-input.tsx +4 -2
  46. package/src/text.tsx +22 -2
  47. package/src/theme.ts +72 -20
  48. package/src/tooltip.tsx +16 -5
  49. package/src/types.ts +119 -1
  50. package/src/view.tsx +11 -2
package/src/select.tsx CHANGED
@@ -5,10 +5,11 @@ import { theme } from "./theme"
5
5
  import { policy } from "./policy"
6
6
  import { space } from "./spacing"
7
7
  import { typeStyle } from "./typography"
8
- import type { Option, StyleProps } from "./types"
8
+ import type { Option, StyleProps, TransitionProps } from "./types"
9
+ import { splitTransition, transitionEndFor } from "./types"
9
10
  import { Icon } from "./icon"
10
11
 
11
- export interface SelectProps {
12
+ export interface SelectProps extends TransitionProps {
12
13
  options: Option[]
13
14
  // Controlled selected value. If omitted, the select is uncontrolled.
14
15
  value?: unknown
@@ -21,9 +22,10 @@ export interface SelectProps {
21
22
  style?: StyleProps
22
23
  }
23
24
 
24
- const GAP = 4
25
+ // Distance between the trigger and the dropdown.
26
+ let gap = () => theme.spacing.sm
25
27
  // Minimum distance kept between the dropdown and the window edges.
26
- const MARGIN = 4
28
+ let margin = () => theme.spacing.sm
27
29
 
28
30
  /**
29
31
  * A single-choice picker whose presentation forks on the interaction policy:
@@ -62,12 +64,13 @@ export function Select(props: SelectProps) {
62
64
  paddingLeft={space("md")}
63
65
  paddingRight={space("md")}
64
66
  {...press.handlers}
67
+ focusable
65
68
  >
66
69
  <d-rect
67
70
  color={
68
71
  press.pressed()
69
72
  ? theme.color.overlayPressed
70
- : press.hovered() && policy.interaction !== "touch"
73
+ : (press.hovered() && policy.interaction !== "touch") || (press.focused() && policy.focusRing)
71
74
  ? theme.color.overlayHover
72
75
  : "transparent"
73
76
  }
@@ -89,9 +92,9 @@ export function Select(props: SelectProps) {
89
92
  let b = menu && getBoundingBox(menu)
90
93
  if (!a || !b) return
91
94
  if (a.width !== minWidth()) setMinWidth(a.width)
92
- let x = Math.round(Math.min(Math.max(a.x, MARGIN), env.windowSize.width - b.width - MARGIN))
93
- let below = a.y + a.height + GAP
94
- let y = Math.round(below + b.height > env.windowSize.height - MARGIN ? a.y - b.height - GAP : below)
95
+ let x = Math.round(Math.min(Math.max(a.x, margin()), env.windowSize.width - b.width - margin()))
96
+ let below = a.y + a.height + gap()
97
+ let y = Math.round(below + b.height > env.windowSize.height - margin() ? a.y - b.height - gap() : below)
95
98
  let cur = pos()
96
99
  if (!cur || cur.x !== x || cur.y !== y) setPos({ x, y })
97
100
  })
@@ -158,10 +161,11 @@ export function Select(props: SelectProps) {
158
161
  let style = () => ({
159
162
  borderColor: theme.color.border,
160
163
  borderWidth: theme.borderWidth.sm,
161
- borderRadius: theme.radius.sm,
164
+ borderRadius: theme.radius.md,
162
165
  backgroundColor: theme.color.surface,
163
166
  ...theme.components.select,
164
167
  ...props.style,
168
+ ...(press.focused() && policy.focusRing ? { borderWidth: theme.borderWidth.focus, borderColor: theme.color.ring } : {}),
165
169
  })
166
170
  // Hover feedback: the theme overlay tint drawn over the trigger fill.
167
171
  let overlay = () =>
@@ -170,8 +174,12 @@ export function Select(props: SelectProps) {
170
174
  : "transparent"
171
175
 
172
176
 
177
+ let split = () => splitTransition(props.transition)
178
+
173
179
  return (
174
180
  <view
181
+ transition={split().root}
182
+ onTransitionEnd={transitionEndFor("root", props.onTransitionEnd)}
175
183
  ref={(n: { id: number }) => {
176
184
  press.ref(n)
177
185
  trigger = n
@@ -181,8 +189,8 @@ export function Select(props: SelectProps) {
181
189
  alignItems="center"
182
190
  justifyContent="space-between"
183
191
  gap={theme.spacing.md}
184
- paddingTop={space("sm")}
185
- paddingBottom={space("sm")}
192
+ paddingTop={space("md")}
193
+ paddingBottom={space("md")}
186
194
  paddingLeft={space("md")}
187
195
  paddingRight={space("md")}
188
196
  {...props.layout}
@@ -192,9 +200,10 @@ export function Select(props: SelectProps) {
192
200
  rotate={style().rotate}
193
201
  opacity={style().opacity}
194
202
  {...press.handlers}
203
+ focusable={!props.disabled}
195
204
  pointerEvents={props.disabled ? "none" : undefined}
196
205
  >
197
- <d-rect color={style().backgroundColor ?? "transparent"} radius={style().borderRadius} />
206
+ <d-rect transition={split().background} onTransitionEnd={transitionEndFor("background", props.onTransitionEnd)} color={style().backgroundColor ?? "transparent"} radius={style().borderRadius} />
198
207
  <d-rect color={overlay()} radius={style().borderRadius} />
199
208
  <Show
200
209
  when={selected()}
@@ -227,6 +236,8 @@ export function Select(props: SelectProps) {
227
236
  <Show when={(style().borderWidth ?? 0) > 0}>
228
237
  <d-rect
229
238
  drawStyle="stroke"
239
+ transition={split().border}
240
+ onTransitionEnd={transitionEndFor("border", props.onTransitionEnd)}
230
241
  color={style().borderColor ?? "transparent"}
231
242
  strokeWidth={style().borderWidth}
232
243
  radius={style().borderRadius}
package/src/slider.tsx CHANGED
@@ -1,10 +1,12 @@
1
- import { arena, createSignal, getBoundingBox, onLayout, onSettled } from "@solidrt/core"
2
- import type { LayoutProps, PointerEvent } from "@solidrt/core"
1
+ import { arena, createMemo, createSignal, focusedNode, getBoundingBox, onLayout, onSettled, Show } from "@solidrt/core"
2
+ import type { KeyEvent, LayoutProps, PointerEvent } from "@solidrt/core"
3
3
  import { theme } from "./theme"
4
+ import { policy } from "./policy"
4
5
  import { densityScale } from "./density"
5
- import type { StyleProps } from "./types"
6
+ import type { StyleProps, TransitionProps } from "./types"
7
+ import { splitTransition, transitionEndFor } from "./types"
6
8
 
7
- export interface SliderProps {
9
+ export interface SliderProps extends TransitionProps {
8
10
  // Controlled value. If omitted, the slider is uncontrolled.
9
11
  value?: number
10
12
  defaultValue?: number
@@ -27,6 +29,8 @@ let clamp = (x: number, lo: number, hi: number) => (x < lo ? lo : x > hi ? hi :
27
29
  // A horizontal slider. The groove fills up to the thumb; dragging or pressing
28
30
  // the track sets the value from the pointer x. Moves arrive on the frozen down
29
31
  // path, so a drag keeps updating when the pointer drifts off the track.
32
+ // Focused (spatial nav), arrow keys step the value (by `step`, else 1% of the
33
+ // range) and the thumb draws the focus ring under the focusRing policy.
30
34
  // Controlled via value/onChange, or uncontrolled via defaultValue.
31
35
  export function Slider(props: SliderProps) {
32
36
  let min = () => props.min ?? 0
@@ -95,14 +99,31 @@ export function Slider(props: SliderProps) {
95
99
  if (active === e.pointerId) endDrag()
96
100
  }
97
101
 
102
+ // focusedNode() is read first, unconditionally, so the memo depends on it
103
+ // even when the ref has not set `track` yet (see createPress).
104
+ let focused = createMemo(() => {
105
+ let id = focusedNode()
106
+ return id != null && id === track?.id
107
+ })
108
+ let handleKeyDown = (e: KeyEvent) => {
109
+ if (props.disabled) return
110
+ let dir = e.key === "ArrowRight" || e.key === "ArrowUp" ? 1 : e.key === "ArrowLeft" || e.key === "ArrowDown" ? -1 : 0
111
+ if (dir === 0) return
112
+ e.stopPropagation()
113
+ let inc = props.step ?? (max() - min()) / 100
114
+ commit(clamp(value() + dir * inc, min(), max()))
115
+ }
116
+
98
117
  return (
99
118
  <view
119
+ transition={splitTransition(props.transition).root}
120
+ onTransitionEnd={transitionEndFor("root", props.onTransitionEnd)}
100
121
  ref={(n: { id: number }) => (track = n)}
101
122
  repaintBoundary
102
123
  flexDirection="row"
103
124
  alignItems="center"
104
125
  height={height()}
105
- width={200}
126
+ width={theme.size.slider}
106
127
  {...props.layout}
107
128
  x={props.style?.x}
108
129
  y={props.style?.y}
@@ -110,15 +131,20 @@ export function Slider(props: SliderProps) {
110
131
  rotate={props.style?.rotate}
111
132
  opacity={props.style?.opacity}
112
133
  pointerEvents={props.disabled ? "none" : "auto"}
134
+ focusable={!props.disabled}
113
135
  onPointerDown={handleDown}
114
136
  onPointerMove={handleMove}
115
137
  onPointerUp={handleUp}
138
+ onKeyDown={handleKeyDown}
116
139
  >
117
140
  <view ref={(n: { id: number }) => (groove = n)} position="relative" flex={1} height={GROOVE}>
118
141
  <d-rect color={theme.color.surfaceAlt} radius={GROOVE / 2} />
119
142
  <d-rect color={theme.color.primary} w={fillPx()} h={GROOVE} radius={GROOVE / 2} />
120
143
  <view position="absolute" left={0} top={(GROOVE - thumb()) / 2} x={fillPx() - thumb() / 2}>
121
144
  <d-oval w={thumb()} h={thumb()} color={theme.color.primary} />
145
+ <Show when={focused() && policy.focusRing}>
146
+ <d-oval drawStyle="stroke" w={thumb()} h={thumb()} color={theme.color.ring} strokeWidth={theme.borderWidth.focus} />
147
+ </Show>
122
148
  </view>
123
149
  </view>
124
150
  </view>
package/src/spinner.tsx CHANGED
@@ -2,9 +2,10 @@ import { createSignal, onFrame, Show } from "@solidrt/core"
2
2
  import type { LayoutProps } from "@solidrt/core"
3
3
  import { theme } from "./theme"
4
4
  import { policy } from "./policy"
5
- import type { StyleProps } from "./types"
5
+ import type { StyleProps, TransitionProps } from "./types"
6
+ import { splitTransition, transitionEndFor } from "./types"
6
7
 
7
- export interface SpinnerProps {
8
+ export interface SpinnerProps extends TransitionProps {
8
9
  // Overall diameter in pixels.
9
10
  size?: number
10
11
  // Arc stroke width in pixels.
@@ -53,6 +54,8 @@ export function Spinner(props: SpinnerProps) {
53
54
 
54
55
  return (
55
56
  <view
57
+ transition={splitTransition(props.transition).root}
58
+ onTransitionEnd={transitionEndFor("root", props.onTransitionEnd)}
56
59
  width={size()}
57
60
  height={size()}
58
61
  {...props.layout}
@@ -1,6 +1,7 @@
1
1
  import { Show } from "@solidrt/core"
2
2
  import type { LayoutProps } from "@solidrt/core"
3
3
  import { policy } from "./policy"
4
+ import { theme } from "./theme"
4
5
 
5
6
  export interface SplitViewProps {
6
7
  // The list (or primary) pane.
@@ -10,13 +11,11 @@ export interface SplitViewProps {
10
11
  // Single-pane mode only: show the detail instead of the list. The app owns
11
12
  // this navigation state; two-pane mode ignores it.
12
13
  showDetail?: boolean
13
- // Width of the list pane in two-pane mode.
14
+ // Width of the list pane in two-pane mode; defaults to theme.size.splitViewList.
14
15
  listWidth?: number
15
16
  layout?: LayoutProps
16
17
  }
17
18
 
18
- const LIST_WIDTH = 320
19
-
20
19
  /**
21
20
  * A list-detail container driven by the layout policy: two-pane shows the list
22
21
  * beside the detail, single-pane shows one pane at a time per `showDetail`.
@@ -45,7 +44,7 @@ export function SplitView(props: SplitViewProps) {
45
44
  }
46
45
  >
47
46
  <view flexDirection="row" {...props.layout}>
48
- <view width={props.listWidth ?? LIST_WIDTH} flexDirection="column">
47
+ <view width={props.listWidth ?? theme.size.splitViewList} flexDirection="column">
49
48
  {props.list}
50
49
  </view>
51
50
  <view flex={1} flexDirection="column">
package/src/switch.tsx CHANGED
@@ -2,10 +2,12 @@ import { createSignal, Show } from "@solidrt/core"
2
2
  import type { LayoutProps } from "@solidrt/core"
3
3
  import { createPress } from "./press"
4
4
  import { theme } from "./theme"
5
+ import { policy } from "./policy"
5
6
  import { densityScale } from "./density"
6
- import type { StyleProps } from "./types"
7
+ import type { StyleProps, TransitionProps } from "./types"
8
+ import { splitTransition, transitionEndFor } from "./types"
7
9
 
8
- export interface SwitchProps {
10
+ export interface SwitchProps extends TransitionProps {
9
11
  // Controlled on/off. If omitted, the switch is uncontrolled.
10
12
  value?: boolean
11
13
  // Initial value for uncontrolled use.
@@ -45,10 +47,13 @@ export function Switch(props: SwitchProps) {
45
47
  borderRadius: h() / 2,
46
48
  ...theme.components.switch,
47
49
  ...props.style,
50
+ ...(press.focused() && policy.focusRing ? { borderWidth: theme.borderWidth.focus, borderColor: theme.color.ring } : {}),
48
51
  })
49
52
 
50
53
  return (
51
54
  <view
55
+ transition={splitTransition(props.transition).root}
56
+ onTransitionEnd={transitionEndFor("root", props.onTransitionEnd)}
52
57
  ref={press.ref}
53
58
  repaintBoundary
54
59
  width={w()}
@@ -60,6 +65,7 @@ export function Switch(props: SwitchProps) {
60
65
  rotate={style().rotate}
61
66
  opacity={style().opacity}
62
67
  {...press.handlers}
68
+ focusable={!props.disabled}
63
69
  pointerEvents={props.disabled ? "none" : undefined}
64
70
  >
65
71
  <d-rect color={style().backgroundColor ?? "transparent"} radius={style().borderRadius} />
@@ -2,10 +2,10 @@ import { untrack } from "@solidrt/core"
2
2
  import { createTextBuffer } from "@solidrt/core/text-input"
3
3
  import type { LayoutProps, TextInputHints } from "@solidrt/core"
4
4
  import { EditorField } from "./editor-field"
5
- import type { StyleProps } from "./types"
5
+ import type { StyleProps, TransitionProps } from "./types"
6
6
  import { theme } from "./theme"
7
7
 
8
- export interface TextInputProps {
8
+ export interface TextInputProps extends TransitionProps {
9
9
  value?: string
10
10
  defaultValue?: string
11
11
  onInput?: (value: string) => void
@@ -46,6 +46,8 @@ export function TextInput(props: TextInputProps) {
46
46
  let value = (): string => ""
47
47
  return (
48
48
  <EditorField
49
+ transition={props.transition}
50
+ onTransitionEnd={props.onTransitionEnd}
49
51
  buffer={(step) => {
50
52
  let buffer = createTextBuffer({
51
53
  value: () => props.value,
package/src/text.tsx CHANGED
@@ -1,6 +1,7 @@
1
1
  import { createMemo } from "@solidrt/core"
2
2
  import type { PointerProps } from "@solidrt/core"
3
- import type { StyleProps, TextLayoutProps } from "./types"
3
+ import type { StyleProps, TextLayoutProps, TransitionProps, TransitionViewProp } from "./types"
4
+ import { splitTransition, transitionEndFor } from "./types"
4
5
  import { theme, type TextVariant } from "./theme"
5
6
  import { policy } from "./policy"
6
7
  import { typeWeight } from "./typography"
@@ -9,7 +10,7 @@ import { typeWeight } from "./typography"
9
10
  // make sense as a text fill; style.color takes raw values for anything else.
10
11
  export type TextColor = "text" | "textMuted" | "primary" | "onPrimary" | "danger"
11
12
 
12
- export interface TextProps extends PointerProps {
13
+ export interface TextProps extends PointerProps, TransitionProps<TransitionViewProp | "color"> {
13
14
  children?: any
14
15
  // Typography role from the theme's type scale; defaults to "body". Explicit
15
16
  // layout font props override the role's fields individually. fontSize
@@ -54,8 +55,25 @@ export function Text(props: TextProps) {
54
55
  return out
55
56
  })
56
57
 
58
+ // The text node owns `color`; everything else a Text animates is on the
59
+ // wrapper view. A shorthand or `all` reaches both.
60
+ let split = () => {
61
+ let t = splitTransition(props.transition)
62
+ if (t.root == null || typeof t.root === "string") return { root: t.root, text: t.root }
63
+ let { color, ...rest } = t.root as Record<string, unknown>
64
+ let text: Record<string, unknown> = {}
65
+ if (color !== undefined) text.color = color
66
+ if (rest.all !== undefined) text.all = rest.all
67
+ return {
68
+ root: Object.keys(rest).length ? (rest as typeof t.root) : undefined,
69
+ text: Object.keys(text).length ? (text as typeof t.root) : undefined,
70
+ }
71
+ }
72
+
57
73
  return (
58
74
  <view
75
+ transition={split().root}
76
+ onTransitionEnd={transitionEndFor("root", props.onTransitionEnd)}
59
77
  ref={props.ref}
60
78
  {...box()}
61
79
  x={props.style?.x}
@@ -77,6 +95,8 @@ export function Text(props: TextProps) {
77
95
  pointerEvents={props.pointerEvents}
78
96
  >
79
97
  <text
98
+ transition={split().text}
99
+ onTransitionEnd={transitionEndFor("root", props.onTransitionEnd)}
80
100
  color={color()}
81
101
  fontFamily={props.layout?.fontFamily ?? theme.text.fontFamily}
82
102
  fontSize={size()}
package/src/theme.ts CHANGED
@@ -38,6 +38,8 @@ export type Theme = {
38
38
  text: {
39
39
  // Passed through to the core font stack: "sans" | "mono" | a family name.
40
40
  fontFamily: string
41
+ // The monospace family for code (RichTextEditor inline code).
42
+ monoFamily: string
41
43
  caption: TextStyle
42
44
  label: TextStyle
43
45
  body: TextStyle
@@ -69,10 +71,24 @@ export type Theme = {
69
71
  // caller-set style.backgroundColor. Non-touch interaction policies only.
70
72
  overlayHover: string
71
73
  overlayPressed: string
74
+ // The focus ring (spatial nav under the focusRing policy), drawn at
75
+ // borderWidth.focus by every focusable control. Defaults to text so it
76
+ // stays visible on primary-filled controls.
77
+ ring: string
72
78
  }
79
+ // Gaps and paddings, multiples of one base unit (sm 1x, md 2x, lg 4x,
80
+ // xl 5x); read through space() where density should apply.
73
81
  spacing: { sm: number; md: number; lg: number; xl: number }
74
- radius: { sm: number; md: number; lg: number }
75
- borderWidth: { sm: number }
82
+ // Corner radii. md is THE control radius (Button, TextInput, Select,
83
+ // SegmentedControl); sm is one step under it (Checkbox, Item, menus,
84
+ // Tooltip), lg one step over (Card), full is the pill.
85
+ radius: { sm: number; md: number; lg: number; full: number }
86
+ borderWidth: { sm: number; focus: number }
87
+ // Default extents of the components that have one: the panes and rails an
88
+ // app lays its screens around, and the smallest sensible popup/track. Each
89
+ // is a per-instance layout override away (listWidth, layout.width, ...);
90
+ // the theme sets the app-wide default.
91
+ size: { navRail: number; navSidebar: number; splitViewList: number; menuMinWidth: number; slider: number }
76
92
  // Semantic control glyphs, as SVG document strings (the Icon currency).
77
93
  // Components draw their built-in vector paths by default; a theme that sets
78
94
  // a slot swaps that glyph everywhere it appears. The package still bundles
@@ -93,9 +109,10 @@ export type Theme = {
93
109
  export type ThemeColor = string | [light: string, dark: string]
94
110
 
95
111
  export type ThemeDefinition = {
96
- color: { [K in keyof Theme["color"]]: ThemeColor }
112
+ color: { [K in Exclude<keyof Theme["color"], "ring">]: ThemeColor } & { ring?: ThemeColor }
97
113
  text?: {
98
114
  fontFamily?: string
115
+ monoFamily?: string
99
116
  // The body font size; the other roles derive from it. Default 14.
100
117
  base?: number
101
118
  // The step between adjacent roles (caption, label, body, title, heading
@@ -105,23 +122,43 @@ export type ThemeDefinition = {
105
122
  // and weights.
106
123
  roles?: { [K in TextVariant]?: Partial<TextStyle> }
107
124
  }
108
- spacing?: Partial<Theme["spacing"]>
109
- radius?: Partial<Theme["radius"]>
125
+ // One base unit (the steps derive from it, see deriveSpacing) or explicit
126
+ // steps. Default 4.
127
+ spacing?: number | Partial<Theme["spacing"]>
128
+ // One base (the control radius; the steps derive from it, see
129
+ // deriveRadius) or explicit steps. Default 8.
130
+ radius?: number | Partial<Theme["radius"]>
110
131
  borderWidth?: Partial<Theme["borderWidth"]>
132
+ size?: Partial<Theme["size"]>
111
133
  icons?: Theme["icons"]
112
134
  components?: Theme["components"]
113
135
  }
114
136
 
115
- const SPACING = { sm: 4, md: 8, lg: 16, xl: 20 }
116
- const RADIUS = { sm: 4, md: 8, lg: 12 }
117
- const BORDER_WIDTH = { sm: 1 }
137
+ const SPACING_BASE = 4
118
138
 
119
- // Line height and weight per role; body is the base text, caption and label
120
- // sit under it (secondary and emphasized small text), title and heading
121
- // above it (card and page headings).
139
+ // The spacing scale from its base unit.
140
+ function deriveSpacing(base: number): Theme["spacing"] {
141
+ return { sm: base, md: base * 2, lg: base * 4, xl: base * 5 }
142
+ }
143
+ const RADIUS_BASE = 8
144
+ const RADIUS_FULL = 9999
145
+
146
+ // The radius scale from its base: sm half, lg one and a half, full the pill.
147
+ function deriveRadius(base: number): Theme["radius"] {
148
+ return { sm: Math.round(base / 2), md: base, lg: Math.round(base * 1.5), full: RADIUS_FULL }
149
+ }
150
+ const BORDER_WIDTH = { sm: 1, focus: 2 }
151
+ const SIZE = { navRail: 72, navSidebar: 220, splitViewList: 320, menuMinWidth: 120, slider: 200 }
152
+
153
+ // Line height and weight per role; body is the base text, label is body at
154
+ // an emphasized weight (form labels, key/value keys, tags), caption the one
155
+ // small role (badges, tab labels, timestamps), title and heading sit above
156
+ // body (card and page headings). De-emphasis is a color (textMuted), not a
157
+ // size: caption is small text to be glanced at, so keep it in the full text
158
+ // color rather than stacking small and muted.
122
159
  const ROLE_DEFAULTS: { [K in TextVariant]: { step: number; lineHeight: number; weight: TextStyle["weight"] } } = {
123
- caption: { step: -2, lineHeight: 1.3, weight: 400 },
124
- label: { step: -1, lineHeight: 1.3, weight: 600 },
160
+ caption: { step: -1, lineHeight: 1.3, weight: 400 },
161
+ label: { step: 0, lineHeight: 1.5, weight: 600 },
125
162
  body: { step: 0, lineHeight: 1.5, weight: 400 },
126
163
  title: { step: 1, lineHeight: 1.4, weight: 700 },
127
164
  heading: { step: 2, lineHeight: 1.3, weight: 700 },
@@ -139,11 +176,13 @@ export function defineTheme(def: ThemeDefinition, scheme?: "light" | "dark"): Th
139
176
  for (let key in def.color) {
140
177
  let k = key as keyof Theme["color"]
141
178
  let value = def.color[k]
179
+ if (value == null) continue
142
180
  if (Array.isArray(value)) {
143
181
  if (!scheme) throw new Error(`Theme color "${key}" is a [light, dark] pair; pass a scheme to defineTheme`)
144
182
  color[k] = value[scheme === "light" ? 0 : 1]
145
183
  } else color[k] = value
146
184
  }
185
+ if (def.color.ring == null) color.ring = color.text
147
186
  let base = def.text?.base ?? 14
148
187
  let ratio = def.text?.ratio ?? 1.26
149
188
  let role = (name: TextVariant): TextStyle => {
@@ -158,6 +197,7 @@ export function defineTheme(def: ThemeDefinition, scheme?: "light" | "dark"): Th
158
197
  return {
159
198
  text: {
160
199
  fontFamily: def.text?.fontFamily ?? "sans",
200
+ monoFamily: def.text?.monoFamily ?? "mono",
161
201
  caption: role("caption"),
162
202
  label: role("label"),
163
203
  body: role("body"),
@@ -165,9 +205,14 @@ export function defineTheme(def: ThemeDefinition, scheme?: "light" | "dark"): Th
165
205
  heading: role("heading"),
166
206
  },
167
207
  color,
168
- spacing: { ...SPACING, ...def.spacing },
169
- radius: { ...RADIUS, ...def.radius },
208
+ spacing:
209
+ typeof def.spacing === "number"
210
+ ? deriveSpacing(def.spacing)
211
+ : { ...deriveSpacing(SPACING_BASE), ...def.spacing },
212
+ radius:
213
+ typeof def.radius === "number" ? deriveRadius(def.radius) : { ...deriveRadius(RADIUS_BASE), ...def.radius },
170
214
  borderWidth: { ...BORDER_WIDTH, ...def.borderWidth },
215
+ size: { ...SIZE, ...def.size },
171
216
  icons: def.icons ?? {},
172
217
  components: def.components ?? {},
173
218
  }
@@ -180,7 +225,10 @@ const DEFAULT: ThemeDefinition = {
180
225
  background: ["#ffffff", "#0b0f17"],
181
226
  surface: ["#f6f8fa", "#161b22"],
182
227
  surfaceAlt: ["#eaeef2", "#21262d"],
183
- text: ["#1f2328", "#e6edf3"],
228
+ // Dark body text sits well under white (about 10:1 on the background,
229
+ // tuned by eye on a low-DPI display): full brightness glares on a dark
230
+ // ground, and the low-DPI weight compensation thickens it further.
231
+ text: ["#1f2328", "#b1bac4"],
184
232
  // Muted is an opaque tone between text and background, mixed in oklab
185
233
  // (like Material 3's tonal colors, not an alpha overlay): alpha text
186
234
  // renders thin on low-DPI and its contrast depends on what sits behind
@@ -188,7 +236,10 @@ const DEFAULT: ThemeDefinition = {
188
236
  // preset is data that must not need the render engine at import time
189
237
  // (the website token build imports this module headless). If text or
190
238
  // background changes, recompute: mixColors(text, background, 0.4).
191
- textMuted: ["#707376", "#848b92"],
239
+ // The dark tone sits a step above the 4.5:1 AA floor (about 5.4:1)
240
+ // instead of at the mix: the body text already sits low, and the
241
+ // strict mix falls under it.
242
+ textMuted: ["#707376", "#828993"],
192
243
  border: ["rgba(0,0,0,0.15)", "rgba(255,255,255,0.14)"],
193
244
  // Accent tuned to the puzzle mark's mid blue.
194
245
  primary: "#547ebf",
@@ -202,9 +253,10 @@ const DEFAULT: ThemeDefinition = {
202
253
  overlayHover: ["rgba(0,0,0,0.08)", "rgba(255,255,255,0.08)"],
203
254
  overlayPressed: ["rgba(0,0,0,0.14)", "rgba(255,255,255,0.14)"],
204
255
  },
205
- // base 14 and ratio 1.26 derive title 18 and heading 22; the two small
206
- // roles sit tighter than the ratio, so they are pinned.
207
- text: { roles: { caption: { size: 11 }, label: { size: 12 } } },
256
+ // base 14 and ratio 1.26 derive title 18 and heading 22; caption sits
257
+ // one step under body but the ratio lands on 11, too small to read on a
258
+ // low-DPI display or at TV distance, so it is pinned.
259
+ text: { roles: { caption: { size: 12 } } },
208
260
  }
209
261
 
210
262
  export let darkTheme: Theme = defineTheme(DEFAULT, "dark")
package/src/tooltip.tsx CHANGED
@@ -4,8 +4,10 @@ import { theme } from "./theme"
4
4
  import { policy } from "./policy"
5
5
  import { space } from "./spacing"
6
6
  import { typeStyle } from "./typography"
7
+ import type { TransitionProps } from "./types"
8
+ import { splitTransition, transitionEndFor } from "./types"
7
9
 
8
- export interface TooltipProps {
10
+ export interface TooltipProps extends TransitionProps {
9
11
  // The tooltip body. A string/number renders as themed text; anything else
10
12
  // renders as-is.
11
13
  content?: any
@@ -19,9 +21,10 @@ export interface TooltipProps {
19
21
  }
20
22
 
21
23
  const DELAY = 500
22
- const GAP = 6
24
+ // Distance between the anchor and the bubble.
25
+ let gap = () => theme.spacing.sm
23
26
  // Minimum distance kept between the bubble and the window edges.
24
- const MARGIN = 4
27
+ let margin = () => theme.spacing.sm
25
28
 
26
29
  /**
27
30
  * A hover-only affordance: under desktop/hybrid interaction policies, resting a
@@ -61,8 +64,8 @@ export function Tooltip(props: TooltipProps) {
61
64
  let b = bubble && getBoundingBox(bubble)
62
65
  if (!a || !b) return
63
66
  let x = a.x + a.width / 2 - b.width / 2
64
- x = Math.round(Math.min(Math.max(x, MARGIN), env.windowSize.width - b.width - MARGIN))
65
- let y = Math.round(props.placement === "bottom" ? a.y + a.height + GAP : a.y - b.height - GAP)
67
+ x = Math.round(Math.min(Math.max(x, margin()), env.windowSize.width - b.width - margin()))
68
+ let y = Math.round(props.placement === "bottom" ? a.y + a.height + gap() : a.y - b.height - gap())
66
69
  let cur = pos()
67
70
  if (!cur || cur.x !== x || cur.y !== y) setPos({ x, y })
68
71
  })
@@ -86,6 +89,8 @@ export function Tooltip(props: TooltipProps) {
86
89
  pointerEvents="none"
87
90
  >
88
91
  <d-rect
92
+ transition={split().background}
93
+ onTransitionEnd={transitionEndFor("background", props.onTransitionEnd)}
89
94
  color={theme.components.tooltip?.backgroundColor ?? theme.color.surfaceAlt}
90
95
  radius={theme.components.tooltip?.borderRadius ?? theme.radius.sm}
91
96
  />
@@ -96,6 +101,8 @@ export function Tooltip(props: TooltipProps) {
96
101
  </Show>
97
102
  <d-rect
98
103
  drawStyle="stroke"
104
+ transition={split().border}
105
+ onTransitionEnd={transitionEndFor("border", props.onTransitionEnd)}
99
106
  color={theme.components.tooltip?.borderColor ?? theme.color.border}
100
107
  strokeWidth={theme.components.tooltip?.borderWidth ?? theme.borderWidth.sm}
101
108
  radius={theme.components.tooltip?.borderRadius ?? theme.radius.sm}
@@ -104,8 +111,12 @@ export function Tooltip(props: TooltipProps) {
104
111
  )
105
112
  }
106
113
 
114
+ let split = () => splitTransition(props.transition)
115
+
107
116
  return (
108
117
  <view
118
+ transition={split().root}
119
+ onTransitionEnd={transitionEndFor("root", props.onTransitionEnd)}
109
120
  ref={(n: { id: number }) => (anchor = n)}
110
121
  onPointerEnter={enter}
111
122
  onPointerLeave={hide}