@solidrt/components 0.0.50 → 0.0.51

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/AGENTS.md +66 -85
  2. package/README.md +387 -314
  3. package/docs/badge.md +10 -0
  4. package/docs/button.md +21 -0
  5. package/docs/card.md +11 -0
  6. package/docs/checkbox.md +9 -0
  7. package/docs/context-menu.md +17 -0
  8. package/docs/density.md +13 -0
  9. package/docs/divider.md +10 -0
  10. package/docs/field.md +11 -0
  11. package/docs/focus-nav.md +18 -0
  12. package/docs/icon.md +13 -0
  13. package/docs/image.md +21 -0
  14. package/docs/index.md +13 -0
  15. package/docs/item.md +25 -0
  16. package/docs/modal.md +15 -0
  17. package/docs/nav-shell.md +18 -0
  18. package/docs/policy.md +21 -0
  19. package/docs/portal.md +15 -0
  20. package/docs/pressable.md +17 -0
  21. package/docs/progress-bar.md +10 -0
  22. package/docs/qrcode.md +10 -0
  23. package/docs/radio.md +13 -0
  24. package/docs/rich-text-document.md +5 -0
  25. package/docs/rich-text-editor.md +24 -0
  26. package/docs/safe-area.md +12 -0
  27. package/docs/scroll-view.md +14 -0
  28. package/docs/segmented-control.md +13 -0
  29. package/docs/select.md +15 -0
  30. package/docs/slider.md +9 -0
  31. package/docs/spacing.md +7 -0
  32. package/docs/spinner.md +10 -0
  33. package/docs/split-view.md +14 -0
  34. package/docs/switch.md +13 -0
  35. package/docs/text-input.md +23 -0
  36. package/docs/text.md +15 -0
  37. package/docs/theme.md +56 -0
  38. package/docs/tooltip.md +11 -0
  39. package/docs/types.md +5 -0
  40. package/docs/typography.md +5 -0
  41. package/docs/view.md +14 -0
  42. package/docs/window.md +15 -0
  43. package/package.json +6 -3
  44. package/src/badge.tsx +10 -8
  45. package/src/button.tsx +40 -28
  46. package/src/card.tsx +12 -10
  47. package/src/checkbox.tsx +21 -9
  48. package/src/context-menu.tsx +5 -3
  49. package/src/density.tsx +39 -0
  50. package/src/divider.tsx +3 -1
  51. package/src/editor-field.tsx +361 -0
  52. package/src/field.tsx +45 -0
  53. package/src/index.ts +9 -1
  54. package/src/item.tsx +116 -0
  55. package/src/nav-shell.tsx +1 -1
  56. package/src/policy.ts +0 -8
  57. package/src/press.ts +37 -8
  58. package/src/pressable.tsx +4 -1
  59. package/src/progress-bar.tsx +4 -2
  60. package/src/radio.tsx +14 -12
  61. package/src/rich-text-document.ts +247 -0
  62. package/src/rich-text-editor.tsx +149 -0
  63. package/src/segmented-control.tsx +19 -14
  64. package/src/select.tsx +37 -19
  65. package/src/slider.tsx +1 -1
  66. package/src/spacing.ts +1 -1
  67. package/src/spinner.tsx +6 -4
  68. package/src/switch.tsx +2 -1
  69. package/src/text-input.tsx +50 -262
  70. package/src/theme.ts +163 -76
  71. package/src/tooltip.tsx +7 -4
package/src/spinner.tsx CHANGED
@@ -24,7 +24,9 @@ const THICKNESS = 3
24
24
  export function Spinner(props: SpinnerProps) {
25
25
  let size = () => props.size ?? SIZE
26
26
  let thickness = () => props.thickness ?? THICKNESS
27
- let color = () => props.style?.color ?? theme.color.primary
27
+ // Theme-level per-component overrides merged under the instance style.
28
+ let styled = () => ({ ...theme.components.spinner, ...props.style })
29
+ let color = () => styled().color ?? theme.color.primary
28
30
  let speed = () => props.speed ?? 1
29
31
 
30
32
  // tick is in milliseconds (like performance.now()). The frame loop is mounted
@@ -55,9 +57,9 @@ export function Spinner(props: SpinnerProps) {
55
57
  height={size()}
56
58
  {...props.layout}
57
59
  rotate={angle()}
58
- x={props.style?.x}
59
- y={props.style?.y}
60
- opacity={props.style?.opacity}
60
+ x={styled().x}
61
+ y={styled().y}
62
+ opacity={styled().opacity}
61
63
  >
62
64
  <Show when={policy.motion !== "none"}>
63
65
  <Animate />
package/src/switch.tsx CHANGED
@@ -2,7 +2,7 @@ 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 { densityScale } from "./policy"
5
+ import { densityScale } from "./density"
6
6
  import type { StyleProps } from "./types"
7
7
 
8
8
  export interface SwitchProps {
@@ -43,6 +43,7 @@ export function Switch(props: SwitchProps) {
43
43
  let style = () => ({
44
44
  backgroundColor: on() ? theme.color.primary : theme.color.surfaceAlt,
45
45
  borderRadius: h() / 2,
46
+ ...theme.components.switch,
46
47
  ...props.style,
47
48
  })
48
49
 
@@ -1,30 +1,9 @@
1
- import {
2
- createEffect,
3
- createMemo,
4
- createSignal,
5
- onCleanup,
6
- focusedNode,
7
- measureText,
8
- setFocus,
9
- startTextInput,
10
- textInputActive,
11
- } from "@solidrt/core"
12
- import { createCaretScroll, createTextBuffer } from "@solidrt/core/text-input"
13
- import type { Color, Gradient, KeyEvent, LayoutProps, TextInputHints } from "@solidrt/core"
14
- import { registerNavAction } from "./focus-nav"
1
+ import { untrack } from "@solidrt/core"
2
+ import { createTextBuffer } from "@solidrt/core/text-input"
3
+ import type { LayoutProps, TextInputHints } from "@solidrt/core"
4
+ import { EditorField } from "./editor-field"
15
5
  import type { StyleProps } from "./types"
16
6
  import { theme } from "./theme"
17
- import { policy } from "./policy"
18
- import { space } from "./spacing"
19
-
20
- // Caret thickness. Shared so the drawn caret and the scroll offset's reserved
21
- // edge column cannot drift apart.
22
- const CARET_WIDTH = 1
23
-
24
- // Shaping width handed to the detached value/placeholder text: effectively
25
- // unbounded, so a single line never wraps. The viewport clips it and scrollX
26
- // slides it.
27
- const TEXT_SHAPE_WIDTH = 1e9
28
7
 
29
8
  export interface TextInputProps {
30
9
  value?: string
@@ -38,6 +17,15 @@ export interface TextInputProps {
38
17
  maxLength?: number
39
18
  disabled?: boolean
40
19
  autoFocus?: boolean
20
+ /**
21
+ * Multi-line editing: lines wrap at the field's width, Enter inserts a
22
+ * newline (onSubmit never fires), Up/Down move by line. Without a
23
+ * `layout.height` the field grows with its content (up to `maxRows` rows,
24
+ * then scrolls); with one it is a fixed box that scrolls to the caret.
25
+ */
26
+ multiline?: boolean
27
+ /** Multiline without an explicit height: rows to grow to before scrolling. Default unbounded. */
28
+ maxRows?: number
41
29
  /**
42
30
  * IME behavior for the field's text sessions (keyboard type,
43
31
  * capitalization, autocorrect). Identifier-like fields want
@@ -50,244 +38,44 @@ export interface TextInputProps {
50
38
  style?: StyleProps
51
39
  }
52
40
 
53
- // Single-line. The caret moves through the text (Left/Right/Home/End), edits
54
- // happen at the caret, and the inner box scrolls to keep it in view. Printable
55
- // text arrives via onTextInput (post-IME commit). onKeyDown handles caret
56
- // movement, Backspace/Delete, Enter/select, Escape - and stops those keys
57
- // from bubbling further. Focused and editing are distinct (see
58
- // activateField): navigation focuses, select begins editing, Enter while
59
- // editing submits. Range selection (shift-movement,
60
- // highlight, click-to-position) is not wired yet. Outside-click-to-blur is the
61
- // caller's job.
41
+ // A plain string field over the shared EditorField shell: the buffer is
42
+ // core's createTextBuffer and every line is one d-text of the value's slice.
43
+ // Detached text takes its width from the box it is drawn in; +1 so the ink
44
+ // width rounding never wraps a line's own text.
62
45
  export function TextInput(props: TextInputProps) {
63
- let [caretOn, setCaretOn] = createSignal(true)
64
-
65
- let node: { id: number } | undefined
66
- let viewport: { id: number } | undefined
67
- let blinkId: any = null
68
-
69
- // Derived from core's reactive focus (setFocus is the only writer); the
70
- // onFocus/onBlur handlers below keep only their side effects (blink timer,
71
- // caller callbacks). focusedNode() is read FIRST, unconditionally: the
72
- // memo may first compute before the ref has set `node`, and
73
- // short-circuiting past the read would leave it dependency-free, frozen
74
- // false forever.
75
- let focused = createMemo(() => {
76
- let id = focusedNode()
77
- return id != null && id === node?.id
78
- })
79
-
80
- let buffer = createTextBuffer({
81
- value: () => props.value,
82
- defaultValue: props.defaultValue,
83
- onInput: (v) => props.onInput?.(v),
84
- maxLength: () => props.maxLength,
85
- })
86
- let value = buffer.value
87
-
88
- // autoFocus runs in an effect, not the ref: setFocus fires onFocus and reads
89
- // the node's onTextInput handler to toggle the keyboard, and those handlers
90
- // are only registered after the element's props are applied. The ref can fire
91
- // before that, so focusing there would no-op.
92
- createEffect(
93
- () => props.autoFocus,
94
- (autoFocus) => {
95
- if (autoFocus && node) setFocus(node.id)
96
- },
97
- )
98
-
99
- let handlePointerDown = () => {
100
- if (props.disabled) return
101
- if (node) setFocus(node.id)
102
- }
103
-
104
- let handleFocus = () => {
105
- setCaretOn(true)
106
- if (blinkId == null) {
107
- blinkId = setInterval(() => setCaretOn((v) => !v), 500)
108
- }
109
- props.onFocus?.()
110
- }
111
-
112
- let handleBlur = () => {
113
- if (blinkId != null) {
114
- clearInterval(blinkId)
115
- blinkId = null
116
- }
117
- props.onBlur?.()
118
- }
119
-
120
- // Keys the input consumes stop propagating: an ancestor (or an app-global
121
- // shortcut on the window) must not also act on an ArrowLeft that moved the
122
- // caret. Anything else (e.g. ctrl+s) bubbles on.
123
- let handleKeyDown = (e: KeyEvent) => {
124
- if (props.disabled) return
125
- let consumed = true
126
- if (e.key === "Backspace") {
127
- buffer.deleteBackward()
128
- setCaretOn(true)
129
- } else if (e.key === "Delete") {
130
- buffer.deleteForward()
131
- setCaretOn(true)
132
- } else if (e.key === "ArrowLeft") {
133
- buffer.move("left")
134
- setCaretOn(true)
135
- } else if (e.key === "ArrowRight") {
136
- buffer.move("right")
137
- setCaretOn(true)
138
- } else if (e.key === "Home") {
139
- buffer.move("start")
140
- setCaretOn(true)
141
- } else if (e.key === "End") {
142
- buffer.move("end")
143
- setCaretOn(true)
144
- } else if (e.key === "Enter" || e.code === "Select") {
145
- // The remote center key's `key` is "Unidentified"; match its code.
146
- activateField()
147
- } else if (e.key === "Escape") {
148
- if (node) setFocus(null)
149
- } else {
150
- consumed = false
151
- }
152
- if (consumed) e.stopPropagation()
153
- }
154
-
155
- let handleTextInput = (e: any) => {
156
- if (props.disabled) return
157
- buffer.insertText(e.text ?? "")
158
- setCaretOn(true)
159
- }
160
-
161
- // Select on the focused field: focused and editing are distinct states. A
162
- // field reached by navigation is focused but has no text session yet -
163
- // select begins one (raising the on-screen keyboard where used, e.g. a TV
164
- // with no keyboard attached); while editing, it submits. On platforms
165
- // where the session starts invisibly at focus (desktop, physical
166
- // keyboard) the first branch never runs and Enter submits as always.
167
- // Registered as the nav action too, for a controller's south button.
168
- let activateField = () => {
169
- if (props.disabled) return
170
- if (!textInputActive()) {
171
- startTextInput()
172
- } else {
173
- props.onSubmit?.(value())
174
- setFocus(null)
175
- }
176
- }
177
-
178
- let unregisterNav: (() => void) | null = null
179
-
180
- onCleanup(() => {
181
- if (blinkId != null) clearInterval(blinkId)
182
- unregisterNav?.()
183
- })
184
-
185
- // Style overrides fall back to theme defaults. The border doubles as the
186
- // focus ring: primary while focused, when the focus-ring policy asks for a
187
- // visible indicator.
188
- let textColor = () => props.style?.color ?? theme.color.text
189
- let surfaceColor = () => props.style?.backgroundColor ?? theme.color.surface
190
- let borderColor = () =>
191
- props.style?.borderColor ?? (focused() && policy.focusRing ? theme.color.primary : theme.color.border)
192
- let borderWidth = () => props.style?.borderWidth ?? theme.borderWidth.sm
193
- let borderRadius = () => props.style?.borderRadius ?? theme.radius.sm
194
-
195
- let showPlaceholder = () => !focused() && value().length === 0 && (props.placeholder ?? "").length > 0
196
- let showCaret = () => focused() && caretOn() && !showPlaceholder()
197
-
198
- // Everything inside the viewport is detached: the value is one d-text shaped
199
- // at an unbounded width and the caret a d-rect at the measured before-caret
200
- // width, so typing, caret movement, blink and scroll never touch layout. The
201
- // viewport carries an explicit height (detached content takes no layout
202
- // slot) equal to the one-line paragraph height, which keeps the text where
203
- // the old centered attached row sat. createCaretScroll keeps the caret in
204
- // view and flushes the offset before paint; scrollX is a paint-time
205
- // translate that also applies to detached children.
206
- // All one-line metrics derive from the scaled body size, so the field, the
207
- // caret, and the scroll math grow together under policy.textScale.
208
- let fontSize = () => theme.text.body.size * policy.textScale
209
- let rowHeight = () => Math.round(fontSize() * theme.text.body.lineHeight)
210
- let caretX = () => measureText(value().slice(0, buffer.caret()), { fontSize: fontSize() }).width
211
- let scrollX = createCaretScroll(
212
- () => viewport,
213
- () => ({
214
- text: value(),
215
- fontSize: fontSize(),
216
- caret: buffer.caret(),
217
- // Constant, not tied to caret visibility: the caret's footprint does not
218
- // change as it blinks, so reserving the column only when shown would swing
219
- // the scroll offset every blink and shift text that exactly fills the box.
220
- caretWidth: CARET_WIDTH,
221
- }),
222
- )
223
-
224
- let textStyle = (color: Color | Gradient) => ({
225
- w: TEXT_SHAPE_WIDTH,
226
- fontSize: fontSize(),
227
- lineHeight: theme.text.body.lineHeight,
228
- color,
229
- maxLines: 1,
230
- })
231
-
46
+ let value = (): string => ""
232
47
  return (
233
- <view
234
- ref={(n: { id: number }) => {
235
- node = n
236
- unregisterNav?.()
237
- unregisterNav = registerNavAction(n.id, activateField)
238
- props.ref?.(n)
48
+ <EditorField
49
+ buffer={(step) => {
50
+ let buffer = createTextBuffer({
51
+ value: () => props.value,
52
+ // A one-shot initial value by contract (see createTextBuffer): read
53
+ // once, deliberately untracked.
54
+ defaultValue: untrack(() => props.defaultValue),
55
+ onInput: (v) => props.onInput?.(v),
56
+ maxLength: () => props.maxLength,
57
+ step,
58
+ })
59
+ value = buffer.value
60
+ return buffer
239
61
  }}
240
- textInputHints={props.hints}
241
- focusable
242
- flexDirection="row"
243
- alignItems="center"
244
- paddingLeft={space("md")}
245
- paddingRight={space("md")}
246
- paddingTop={space("sm")}
247
- paddingBottom={space("sm")}
248
- {...props.layout}
249
- x={props.style?.x}
250
- y={props.style?.y}
251
- scale={props.style?.scale}
252
- rotate={props.style?.rotate}
253
- opacity={props.style?.opacity}
254
- onPointerDown={handlePointerDown}
255
- onFocus={handleFocus}
256
- onBlur={handleBlur}
257
- onKeyDown={handleKeyDown}
258
- onTextInput={handleTextInput}
259
- >
260
- <d-rect color={surfaceColor()} radius={borderRadius()} />
261
- <d-rect
262
- drawStyle="stroke"
263
- color={borderColor()}
264
- strokeWidth={borderWidth()}
265
- radius={borderRadius()}
266
- />
267
- <view
268
- ref={(n: { id: number }) => (viewport = n)}
269
- flex={1}
270
- height={rowHeight()}
271
- overflow="hidden"
272
- scrollX={scrollX()}
273
- >
274
- {showPlaceholder() ? (
275
- <d-text {...textStyle(theme.color.textMuted)}>{props.placeholder ?? ""}</d-text>
276
- ) : (
277
- <>
278
- <d-text {...textStyle(textColor())}>{value()}</d-text>
279
- {showCaret() ? (
280
- <d-rect
281
- color={textColor()}
282
- x={caretX()}
283
- y={(rowHeight() - fontSize()) / 2}
284
- w={CARET_WIDTH}
285
- h={fontSize()}
286
- />
287
- ) : null}
288
- </>
289
- )}
290
- </view>
291
- </view>
62
+ renderLine={({ line, font, color }) => (
63
+ <d-text y={line().y} w={line().width + 1} {...font()} color={color()} maxLines={1}>
64
+ {value().slice(line().start, line().end)}
65
+ </d-text>
66
+ )}
67
+ onSubmit={props.onSubmit}
68
+ onFocus={props.onFocus}
69
+ onBlur={props.onBlur}
70
+ placeholder={props.placeholder}
71
+ disabled={props.disabled}
72
+ autoFocus={props.autoFocus}
73
+ multiline={props.multiline}
74
+ maxRows={props.maxRows}
75
+ hints={props.hints}
76
+ ref={props.ref}
77
+ layout={props.layout}
78
+ style={{ ...theme.components.textInput, ...props.style }}
79
+ />
292
80
  )
293
- }
81
+ }
package/src/theme.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { createStore, mixColors } from "@solidrt/core"
1
+ import { createStore } from "@solidjs/signals"
2
+ import type { StyleProps } from "./types"
2
3
 
3
4
  export type TextStyle = {
4
5
  size: number
@@ -9,6 +10,30 @@ export type TextStyle = {
9
10
  // The type scale's role names. <Text variant> and theme.text are keyed by these.
10
11
  export type TextVariant = "caption" | "label" | "body" | "title" | "heading"
11
12
 
13
+ // The components whose chrome accepts a theme-level paint override (see
14
+ // Theme["components"]). Plain containers (View, Pressable, ...) are not
15
+ // themed, so they take no override either.
16
+ export type ThemedComponent =
17
+ | "button"
18
+ | "card"
19
+ | "badge"
20
+ | "switch"
21
+ | "checkbox"
22
+ | "radio"
23
+ | "item"
24
+ | "select"
25
+ | "segmentedControl"
26
+ | "textInput"
27
+ | "richTextEditor"
28
+ | "tooltip"
29
+ | "divider"
30
+ | "progressBar"
31
+ | "spinner"
32
+
33
+ // A resolved theme: every value is a plain string/number ready to be read by
34
+ // a component. Authoring happens through defineTheme, which is where
35
+ // [light, dark] pairs and the type-scale expansion live; a Theme itself has
36
+ // no notion of modes.
12
37
  export type Theme = {
13
38
  text: {
14
39
  // Passed through to the core font stack: "sans" | "mono" | a family name.
@@ -26,117 +51,179 @@ export type Theme = {
26
51
  surface: string
27
52
  // Subtle raised/track fill (switch off-state, slider track, ...).
28
53
  surfaceAlt: string
29
- // Hover tint for surface-colored controls (non-touch interaction policies).
30
- surfaceHover: string
31
54
  text: string
32
55
  textMuted: string
33
56
  border: string
34
57
  primary: string
35
- // Hover tint for primary-colored controls.
36
- primaryHover: string
37
58
  onPrimary: string
38
59
  // Lower-emphasis accent: the puzzle mark's darker blue.
39
60
  secondary: string
40
- // Hover tint for secondary-colored controls.
41
- secondaryHover: string
42
61
  onSecondary: string
43
62
  // Validation / destructive.
44
63
  danger: string
45
- // Hover tint for danger-colored controls.
46
- dangerHover: string
47
64
  // Overlay dim behind modals.
48
65
  scrim: string
66
+ // Hover/pressed feedback tints: translucent colors DRAWN OVER a control's
67
+ // own fill (one token pair for every control, instead of a hover variant
68
+ // per fill color), so feedback works over any background, including a
69
+ // caller-set style.backgroundColor. Non-touch interaction policies only.
70
+ overlayHover: string
71
+ overlayPressed: string
49
72
  }
50
73
  spacing: { sm: number; md: number; lg: number; xl: number }
51
74
  radius: { sm: number; md: number; lg: number }
52
75
  borderWidth: { sm: number }
76
+ // Semantic control glyphs, as SVG document strings (the Icon currency).
77
+ // Components draw their built-in vector paths by default; a theme that sets
78
+ // a slot swaps that glyph everywhere it appears. The package still bundles
79
+ // no icon set.
80
+ icons: { chevronDown?: string; check?: string }
81
+ // Per-component paint overrides: merged between a component's themed
82
+ // defaults and the instance's style prop, so a theme can restyle every
83
+ // Button (say, pill corners) without wrapping the component. Instance
84
+ // style still wins.
85
+ components: { [K in ThemedComponent]?: StyleProps }
53
86
  }
54
87
 
55
- // Scheme-independent tokens, shared by both presets. The type scale: body is
56
- // the base text style; caption and label sit under it (secondary and
57
- // emphasized small text), title and heading above it (card and page headings).
58
- const TEXT: Theme["text"] = {
59
- fontFamily: "sans",
60
- caption: { size: 11, lineHeight: 1.3, weight: 400 },
61
- label: { size: 12, lineHeight: 1.3, weight: 600 },
62
- body: { size: 14, lineHeight: 1.5, weight: 400 },
63
- title: { size: 18, lineHeight: 1.4, weight: 700 },
64
- heading: { size: 22, lineHeight: 1.3, weight: 700 },
88
+ // -- Authoring ---------------------------------------------------------------
89
+
90
+ // A color in a theme definition: one value, or a [light, dark] pair resolved
91
+ // by defineTheme's scheme argument. Pairs are opt-in per token; a definition
92
+ // without any needs no scheme at all (a game ships one look, not two).
93
+ export type ThemeColor = string | [light: string, dark: string]
94
+
95
+ export type ThemeDefinition = {
96
+ color: { [K in keyof Theme["color"]]: ThemeColor }
97
+ text?: {
98
+ fontFamily?: string
99
+ // The body font size; the other roles derive from it. Default 14.
100
+ base?: number
101
+ // The step between adjacent roles (caption, label, body, title, heading
102
+ // sit at base * ratio^(-2..2), rounded to whole px). Default 1.26.
103
+ ratio?: number
104
+ // Per-role overrides of the derived size and the default line heights
105
+ // and weights.
106
+ roles?: { [K in TextVariant]?: Partial<TextStyle> }
107
+ }
108
+ spacing?: Partial<Theme["spacing"]>
109
+ radius?: Partial<Theme["radius"]>
110
+ borderWidth?: Partial<Theme["borderWidth"]>
111
+ icons?: Theme["icons"]
112
+ components?: Theme["components"]
65
113
  }
114
+
66
115
  const SPACING = { sm: 4, md: 8, lg: 16, xl: 20 }
67
116
  const RADIUS = { sm: 4, md: 8, lg: 12 }
68
117
  const BORDER_WIDTH = { sm: 1 }
69
118
 
70
- export let darkTheme: Theme = {
71
- text: TEXT,
72
- color: {
73
- background: "#0b0f17",
74
- surface: "#161b22",
75
- surfaceAlt: "#21262d",
76
- surfaceHover: "#262c34",
77
- text: "#e6edf3",
78
- // Muted is an opaque tone between text and background, mixed in LAB (like
79
- // Material 3's tonal colors, not an alpha overlay): alpha text renders
80
- // thin on low-DPI and its contrast depends on what sits behind it.
81
- textMuted: mixColors("#e6edf3", "#0b0f17", 0.4),
82
- border: "rgba(255,255,255,0.14)",
83
- // Accent tuned to the puzzle mark's mid blue; hover lifts toward its
84
- // lightest segment tone.
85
- primary: "#547ebf",
86
- primaryHover: "#7ea9ea",
87
- onPrimary: "#ffffff",
88
- // The darker shade of the same puzzle segment; hover lifts toward primary.
89
- secondary: "#2b5696",
90
- secondaryHover: "#3a68ab",
91
- onSecondary: "#ffffff",
92
- danger: "#f85149",
93
- dangerHover: "#ff7b72",
94
- scrim: "rgba(0,0,0,0.6)",
95
- },
96
- spacing: SPACING,
97
- radius: RADIUS,
98
- borderWidth: BORDER_WIDTH,
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).
122
+ 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 },
125
+ body: { step: 0, lineHeight: 1.5, weight: 400 },
126
+ title: { step: 1, lineHeight: 1.4, weight: 700 },
127
+ heading: { step: 2, lineHeight: 1.3, weight: 700 },
128
+ }
129
+
130
+ /**
131
+ * Resolves a theme definition into a Theme. `scheme` picks the side of every
132
+ * [light, dark] color pair; a definition without pairs needs no scheme
133
+ * (modes are a per-theme choice, not a framework requirement). The type
134
+ * scale expands from text.base and text.ratio, with text.roles overriding
135
+ * per role. Throws on a pair without a scheme (throw-in-dev policy).
136
+ */
137
+ export function defineTheme(def: ThemeDefinition, scheme?: "light" | "dark"): Theme {
138
+ let color = {} as Theme["color"]
139
+ for (let key in def.color) {
140
+ let k = key as keyof Theme["color"]
141
+ let value = def.color[k]
142
+ if (Array.isArray(value)) {
143
+ if (!scheme) throw new Error(`Theme color "${key}" is a [light, dark] pair; pass a scheme to defineTheme`)
144
+ color[k] = value[scheme === "light" ? 0 : 1]
145
+ } else color[k] = value
146
+ }
147
+ let base = def.text?.base ?? 14
148
+ let ratio = def.text?.ratio ?? 1.26
149
+ let role = (name: TextVariant): TextStyle => {
150
+ let d = ROLE_DEFAULTS[name]
151
+ return {
152
+ size: Math.round(base * ratio ** d.step),
153
+ lineHeight: d.lineHeight,
154
+ weight: d.weight,
155
+ ...def.text?.roles?.[name],
156
+ }
157
+ }
158
+ return {
159
+ text: {
160
+ fontFamily: def.text?.fontFamily ?? "sans",
161
+ caption: role("caption"),
162
+ label: role("label"),
163
+ body: role("body"),
164
+ title: role("title"),
165
+ heading: role("heading"),
166
+ },
167
+ color,
168
+ spacing: { ...SPACING, ...def.spacing },
169
+ radius: { ...RADIUS, ...def.radius },
170
+ borderWidth: { ...BORDER_WIDTH, ...def.borderWidth },
171
+ icons: def.icons ?? {},
172
+ components: def.components ?? {},
173
+ }
99
174
  }
100
175
 
101
- export let lightTheme: Theme = {
102
- text: TEXT,
176
+ // -- The built-in presets: one definition, resolved twice ---------------------
177
+
178
+ const DEFAULT: ThemeDefinition = {
103
179
  color: {
104
- background: "#ffffff",
105
- surface: "#f6f8fa",
106
- surfaceAlt: "#eaeef2",
107
- surfaceHover: "#e0e5eb",
108
- text: "#1f2328",
109
- textMuted: mixColors("#1f2328", "#ffffff", 0.4),
110
- border: "rgba(0,0,0,0.15)",
111
- // Accent tuned to the puzzle mark's mid blue; hover deepens toward its
112
- // darker segment tone.
180
+ background: ["#ffffff", "#0b0f17"],
181
+ surface: ["#f6f8fa", "#161b22"],
182
+ surfaceAlt: ["#eaeef2", "#21262d"],
183
+ text: ["#1f2328", "#e6edf3"],
184
+ // Muted is an opaque tone between text and background, mixed in oklab
185
+ // (like Material 3's tonal colors, not an alpha overlay): alpha text
186
+ // renders thin on low-DPI and its contrast depends on what sits behind
187
+ // it. Precomputed - core's mixColors delegates to flux:rendertree, and a
188
+ // preset is data that must not need the render engine at import time
189
+ // (the website token build imports this module headless). If text or
190
+ // background changes, recompute: mixColors(text, background, 0.4).
191
+ textMuted: ["#707376", "#848b92"],
192
+ border: ["rgba(0,0,0,0.15)", "rgba(255,255,255,0.14)"],
193
+ // Accent tuned to the puzzle mark's mid blue.
113
194
  primary: "#547ebf",
114
- primaryHover: "#3f5494",
115
195
  onPrimary: "#ffffff",
116
- // The darker shade of the same puzzle segment; hover deepens it further.
196
+ // The darker shade of the same puzzle segment.
117
197
  secondary: "#2b5696",
118
- secondaryHover: "#1f4176",
119
198
  onSecondary: "#ffffff",
120
- danger: "#cf222e",
121
- dangerHover: "#a40e26",
122
- scrim: "rgba(0,0,0,0.4)",
199
+ danger: ["#cf222e", "#f85149"],
200
+ scrim: ["rgba(0,0,0,0.4)", "rgba(0,0,0,0.6)"],
201
+ // Feedback darkens on a light scheme and lightens on a dark one.
202
+ overlayHover: ["rgba(0,0,0,0.08)", "rgba(255,255,255,0.08)"],
203
+ overlayPressed: ["rgba(0,0,0,0.14)", "rgba(255,255,255,0.14)"],
123
204
  },
124
- spacing: SPACING,
125
- radius: RADIUS,
126
- borderWidth: BORDER_WIDTH,
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 } } },
127
208
  }
128
209
 
129
- // Backed by a Solid store so reads are tracked: calling setTheme at runtime
130
- // recolors the live UI without remounting. Components read theme.* through
131
- // thunks/JSX expressions, so they pick this up with no call-site changes.
132
- let [theme, setThemeStore] = createStore<Theme>({ ...darkTheme })
133
- export { theme }
210
+ export let darkTheme: Theme = defineTheme(DEFAULT, "dark")
211
+ export let lightTheme: Theme = defineTheme(DEFAULT, "light")
212
+
213
+ let [themeStore, setThemeStore] = createStore<Theme>({ ...darkTheme })
214
+
215
+ // The shared theme, backed by a Solid store so reads are tracked: calling
216
+ // setTheme at runtime recolors the live UI without remounting. Components
217
+ // read theme.* through thunks/JSX expressions, so they pick this up with no
218
+ // call-site changes.
219
+ export let theme: Theme = themeStore
134
220
 
135
221
  type ThemePartial = { [K in keyof Theme]?: Partial<Theme[K]> }
136
222
 
137
- // Switch themes with a full preset (setTheme(lightTheme)) or apply a targeted
138
- // override (setTheme({ color: { primary: "#f00" } })). Merges one level deep per
139
- // category, matching the previous Object.assign behavior.
223
+ // Switch themes with a full preset (setTheme(lightTheme)), a resolved
224
+ // definition (setTheme(defineTheme({...}))), or apply a targeted override
225
+ // (setTheme({ color: { primary: "#f00" } })). Merges one level deep per
226
+ // category (for components, that level is the component name).
140
227
  export function setTheme(partial: ThemePartial) {
141
228
  setThemeStore((s) => {
142
229
  for (let key in partial) {