@solidrt/components 0.0.63 → 0.0.65

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 (49) hide show
  1. package/AGENTS.md +18 -14
  2. package/README.md +126 -21
  3. package/docs/badge.md +1 -1
  4. package/docs/checkbox.md +1 -1
  5. package/docs/context-menu.md +1 -1
  6. package/docs/item.md +1 -1
  7. package/docs/light.md +12 -0
  8. package/docs/motion.md +13 -0
  9. package/docs/press.md +19 -0
  10. package/docs/progress-bar.md +1 -1
  11. package/docs/radio.md +1 -1
  12. package/docs/segmented-control.md +1 -1
  13. package/docs/select.md +1 -1
  14. package/docs/slider.md +1 -1
  15. package/docs/surface.md +19 -0
  16. package/docs/switch.md +1 -1
  17. package/docs/theme.md +34 -6
  18. package/docs/tooltip.md +1 -1
  19. package/docs/types.md +1 -1
  20. package/docs/window.md +2 -0
  21. package/examples/lit.tsx +182 -0
  22. package/package.json +3 -3
  23. package/src/badge.tsx +25 -6
  24. package/src/button.tsx +44 -29
  25. package/src/card.tsx +76 -20
  26. package/src/checkbox.tsx +23 -19
  27. package/src/context-menu.tsx +21 -9
  28. package/src/editor-field.tsx +12 -7
  29. package/src/focus-nav.ts +17 -6
  30. package/src/glow.ts +35 -0
  31. package/src/index.ts +16 -1
  32. package/src/item.tsx +35 -13
  33. package/src/light.ts +274 -0
  34. package/src/motion.tsx +13 -1
  35. package/src/nav-shell.tsx +19 -10
  36. package/src/press.ts +2 -1
  37. package/src/pressable.tsx +6 -4
  38. package/src/progress-bar.tsx +25 -7
  39. package/src/radio.tsx +34 -22
  40. package/src/scroll-view.tsx +8 -7
  41. package/src/segmented-control.tsx +104 -23
  42. package/src/select.tsx +40 -34
  43. package/src/slider.tsx +74 -22
  44. package/src/surface.tsx +287 -0
  45. package/src/switch.tsx +48 -26
  46. package/src/theme.ts +201 -40
  47. package/src/tooltip.tsx +18 -14
  48. package/src/types.ts +66 -2
  49. package/src/window.tsx +26 -3
package/src/theme.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createStore } from "@solidjs/signals"
2
- import type { FontWeight } from "@solidrt/core"
3
- import type { StyleProps } from "./types"
2
+ import type { FontWeight, WindowShaderProps } from "@solidrt/core"
3
+ import type { ElevationLevel, Material, MaterialRole, StyleProps } from "./types"
4
4
 
5
5
  export type TextStyle = {
6
6
  size: number
@@ -13,23 +13,58 @@ export type TextVariant = "caption" | "label" | "body" | "title" | "heading"
13
13
 
14
14
  // The components whose chrome accepts a theme-level paint override (see
15
15
  // Theme["components"]). Plain containers (View, Pressable, ...) are not
16
- // themed, so they take no override either.
17
- export type ThemedComponent =
18
- | "button"
19
- | "card"
20
- | "badge"
21
- | "switch"
22
- | "checkbox"
23
- | "radio"
24
- | "item"
25
- | "select"
26
- | "segmentedControl"
27
- | "textInput"
28
- | "richTextEditor"
29
- | "tooltip"
30
- | "divider"
31
- | "progressBar"
32
- | "spinner"
16
+ // themed, so they take no override either. A runtime list, so defineTheme
17
+ // can write every key (see there).
18
+ const THEMED_COMPONENTS = [
19
+ "button",
20
+ "card",
21
+ "badge",
22
+ "switch",
23
+ "checkbox",
24
+ "radio",
25
+ "slider",
26
+ "item",
27
+ "select",
28
+ "segmentedControl",
29
+ "textInput",
30
+ "richTextEditor",
31
+ "tooltip",
32
+ "divider",
33
+ "progressBar",
34
+ "spinner",
35
+ "contextMenu",
36
+ "navShell",
37
+ ] as const
38
+ export type ThemedComponent = (typeof THEMED_COMPONENTS)[number]
39
+
40
+ // The roles and levels, as runtime lists so defineTheme writes every key.
41
+ const MATERIAL_ROLES = ["surface", "control", "accent", "overlay"] as const satisfies readonly MaterialRole[]
42
+ const ELEVATION_LEVELS = ["flat", "raised", "floating", "overlay"] as const satisfies readonly ElevationLevel[]
43
+
44
+ /**
45
+ * The theme's one light, in screen space (x right, y down, z into the
46
+ * screen), the way a 3D scene's directional light is given by the way it
47
+ * travels: `[0.3, 1, 1]` comes from above, slightly left, in front. Every
48
+ * lit face derives from it: shadows fall along its x/y and stretch as it
49
+ * lowers, the sheen and the bevel run from the lit edge to the shaded one,
50
+ * and faces take on its `color`. `ambient` (0..1) is the fill light on the
51
+ * shaded side: the more of it, the fainter the shadows and the flatter the
52
+ * sheen. Fixed per theme, or moved at event rate with `setTheme({ light })`
53
+ * (a drag, a time of day); every face re-derives its paint on each move, so
54
+ * it is not a per-frame animation.
55
+ */
56
+ export type Light = {
57
+ direction: [x: number, y: number, z: number]
58
+ color: string
59
+ ambient: number
60
+ }
61
+
62
+ /**
63
+ * What shadows are cast in: their `color` (black, or a deep tint for a
64
+ * colored scheme) and a `strength` factor, since the alpha that reads on a
65
+ * light ground vanishes on a dark one (the lit dark preset uses 1.7).
66
+ */
67
+ export type ShadowTone = { color: string; strength: number }
33
68
 
34
69
  // A resolved theme: every value is a plain string/number ready to be read by
35
70
  // a component. Authoring happens through defineTheme, which is where
@@ -80,13 +115,20 @@ export type Theme = {
80
115
  // selected glyphs; translucent so the text stays readable. Defaults to
81
116
  // overlayPressed (a neutral scheme-aware tint that needs no color math).
82
117
  selection: string
118
+ // The cap riding a track: the Switch knob, the Slider thumb. Unset, the
119
+ // knob is onPrimary and the thumb primary. A palette whose onPrimary is
120
+ // dark (text on a bright accent) sets a light cap here, or its off knob
121
+ // sinks into the track; the lit presets set one, lit like any face.
122
+ thumb?: string
83
123
  }
84
124
  // Gaps and paddings, multiples of one base unit (sm 1x, md 2x, lg 4x,
85
125
  // xl 5x); read through space() where density should apply.
86
126
  spacing: { sm: number; md: number; lg: number; xl: number }
87
127
  // Corner radii. md is THE control radius (Button, TextInput, Select,
88
128
  // SegmentedControl); sm is one step under it (Checkbox, Item, menus,
89
- // Tooltip), lg one step over (Card), full is the pill.
129
+ // Tooltip), lg one step over (Card), full the pill: Badge, and through
130
+ // pill() every part shaped by its own height (Switch, Slider, Radio,
131
+ // ProgressBar), which a full of 0 squares.
90
132
  radius: { sm: number; md: number; lg: number; full: number }
91
133
  borderWidth: { sm: number; focus: number }
92
134
  // Motion durations (ms). Every built-in transition the components declare
@@ -94,7 +136,8 @@ export type Theme = {
94
136
  // fast is press/hover feedback, base the color/opacity fades (state
95
137
  // changes, the theme cross-fade, popup enter/exit), slow the travel of a
96
138
  // control's moving parts (switch knob, segmented indicator, progress
97
- // fill). See motion.tsx; policy.motion gates them.
139
+ // fill) and of a face's shadows as it sinks or lifts. See motion.tsx;
140
+ // policy.motion gates them.
98
141
  motion: { fast: number; base: number; slow: number }
99
142
  // Default extents of the components that have one: the panes and rails an
100
143
  // app lays its screens around, and the smallest sensible popup/track. Each
@@ -104,26 +147,57 @@ export type Theme = {
104
147
  // Semantic control glyphs, as SVG document strings (the Icon currency).
105
148
  // Components draw their built-in vector paths by default; a theme that sets
106
149
  // a slot swaps that glyph everywhere it appears. The package still bundles
107
- // no icon set.
150
+ // no icon set. Both keys always present (see defineTheme).
108
151
  icons: { chevronDown?: string; check?: string }
109
152
  // Per-component paint overrides: merged between a component's themed
110
153
  // defaults and the instance's style prop, so a theme can restyle every
111
154
  // Button (say, pill corners) without wrapping the component. Instance
112
- // style still wins.
155
+ // style still wins. `material` and `elevation` in one restyle that
156
+ // component's faces. Every key always present (see defineTheme).
113
157
  components: { [K in ThemedComponent]?: StyleProps }
158
+ // The light every face is lit by (see Light).
159
+ light: Light
160
+ // What the faces' shadows are cast in (see ShadowTone).
161
+ shadow: ShadowTone
162
+ // The material of each role's faces (see MaterialRole, Material). The
163
+ // stock presets give every role `{}`, the flat look; the lit presets a
164
+ // sheen and a bevel per role. Per component, theme.components.<name>
165
+ // .material merges over the role's, an instance's style.material over
166
+ // that, and style.glow overrides the glow layer alone (null clears it).
167
+ // Every role always present (see defineTheme).
168
+ material: { [R in MaterialRole]: Material }
169
+ // The height of each elevation level, in the light's units: a face at
170
+ // height h casts its key shadow h px along the light's slant and blurs it
171
+ // 2.5 h px, and a tight contact shadow under it. The stock presets are 0
172
+ // throughout (no face casts); the lit presets use flat 0, raised 2,
173
+ // floating 6, overlay 14. Every level always present (see defineTheme).
174
+ elevation: { [L in ElevationLevel]: number }
175
+ // The theme's window finish: a core window shader declaration (a linked
176
+ // program and its params) that Window declares on the window as it is,
177
+ // plus `uScale`, the display scale. Strength and layer switches are the
178
+ // theme's own params; an app adjusts one by spreading the current ones
179
+ // (setTheme merges one level deep, so a partial replaces the whole
180
+ // params object it names). The theme's
181
+ // module links the program itself, once, at init: the client brings the
182
+ // GPU up before the bundle runs. The package ships no finish; the stock
183
+ // presets have none. Every key always present (see defineTheme):
184
+ // `program` unset means no finish.
185
+ finish: Partial<WindowShaderProps>
114
186
  }
115
187
 
116
188
  // -- Authoring ---------------------------------------------------------------
117
189
 
118
- // A color in a theme definition: one value, or a [light, dark] pair resolved
190
+ // A value in a theme definition: one value, or a [light, dark] pair resolved
119
191
  // by defineTheme's scheme argument. Pairs are opt-in per token; a definition
120
192
  // without any needs no scheme at all (a game ships one look, not two).
121
- export type ThemeColor = string | [light: string, dark: string]
193
+ export type ThemeValue<T> = T | [light: T, dark: T]
194
+ export type ThemeColor = ThemeValue<string>
122
195
 
123
196
  export type ThemeDefinition = {
124
- color: { [K in Exclude<keyof Theme["color"], "ring" | "selection">]: ThemeColor } & {
197
+ color: { [K in Exclude<keyof Theme["color"], "ring" | "selection" | "thumb">]: ThemeColor } & {
125
198
  ring?: ThemeColor
126
199
  selection?: ThemeColor
200
+ thumb?: ThemeColor
127
201
  }
128
202
  text?: {
129
203
  fontFamily?: string
@@ -148,6 +222,16 @@ export type ThemeDefinition = {
148
222
  size?: Partial<Theme["size"]>
149
223
  icons?: Theme["icons"]
150
224
  components?: Theme["components"]
225
+ // The light; a field left out takes LIGHT's (from above, slightly left,
226
+ // in front; white; ambient 0.5).
227
+ light?: { direction?: Light["direction"]; color?: ThemeColor; ambient?: ThemeValue<number> }
228
+ // The shadow tone; a field left out takes SHADOW's (black, strength 1).
229
+ shadow?: { color?: ThemeColor; strength?: ThemeValue<number> }
230
+ // The material per role; a role left out is `{}`, flat.
231
+ material?: { [R in MaterialRole]?: Material }
232
+ // The height per level; a level left out is 0.
233
+ elevation?: { [L in ElevationLevel]?: number }
234
+ finish?: WindowShaderProps
151
235
  }
152
236
 
153
237
  const SPACING_BASE = 4
@@ -169,6 +253,10 @@ const BORDER_WIDTH = { sm: 1, focus: 2 }
169
253
  // long enough that a part's travel reads as movement rather than a flicker.
170
254
  const MOTION = { fast: 100, base: 150, slow: 250 }
171
255
  const SIZE = { navRail: 72, navSidebar: 220, splitViewList: 320, menuMinWidth: 120, slider: 200 }
256
+ // The default light: from above, slightly left, in front, white, with half
257
+ // ambient - a neutral key that leaves every base color as it is.
258
+ const LIGHT: Light = { direction: [0.3, 1, 1], color: "#ffffff", ambient: 0.5 }
259
+ const SHADOW: ShadowTone = { color: "#000000", strength: 1 }
172
260
 
173
261
  // Line height and weight per role; body is the base text, label is body at
174
262
  // an emphasized weight (form labels, key/value keys, tags), caption the one
@@ -184,9 +272,16 @@ const ROLE_DEFAULTS: { [K in TextVariant]: { step: number; lineHeight: number; w
184
272
  heading: { step: 2, lineHeight: 1.3, weight: 700 },
185
273
  }
186
274
 
275
+ // The side of a [light, dark] pair for the scheme; a bare value as it is.
276
+ function pick<T>(name: string, value: ThemeValue<T>, scheme?: "light" | "dark"): T {
277
+ if (!Array.isArray(value)) return value
278
+ if (!scheme) throw new Error(`Theme value "${name}" is a [light, dark] pair; pass a scheme to defineTheme`)
279
+ return value[scheme === "light" ? 0 : 1]
280
+ }
281
+
187
282
  /**
188
283
  * Resolves a theme definition into a Theme. `scheme` picks the side of every
189
- * [light, dark] color pair; a definition without pairs needs no scheme
284
+ * [light, dark] pair; a definition without pairs needs no scheme
190
285
  * (modes are a per-theme choice, not a framework requirement). The type
191
286
  * scale expands from text.base and text.ratio, with text.roles overriding
192
287
  * per role. Throws on a pair without a scheme (throw-in-dev policy).
@@ -197,13 +292,22 @@ export function defineTheme(def: ThemeDefinition, scheme?: "light" | "dark"): Th
197
292
  let k = key as keyof Theme["color"]
198
293
  let value = def.color[k]
199
294
  if (value == null) continue
200
- if (Array.isArray(value)) {
201
- if (!scheme) throw new Error(`Theme color "${key}" is a [light, dark] pair; pass a scheme to defineTheme`)
202
- color[k] = value[scheme === "light" ? 0 : 1]
203
- } else color[k] = value
295
+ color[k] = pick(key, value, scheme)
204
296
  }
205
297
  if (def.color.ring == null) color.ring = color.text
206
298
  if (def.color.selection == null) color.selection = color.overlayPressed
299
+ // Optional keys are still written (thumb here; icons, components,
300
+ // material and elevation below): setTheme merges a category with
301
+ // Object.assign, which skips absent keys, so a resolved theme carries
302
+ // every key and a preset that sets none of them still clears what an
303
+ // earlier preset set.
304
+ if (!("thumb" in color)) color.thumb = undefined
305
+ let components = {} as Theme["components"]
306
+ for (let name of THEMED_COMPONENTS) components[name] = def.components?.[name]
307
+ let material = {} as Theme["material"]
308
+ for (let role of MATERIAL_ROLES) material[role] = { ...def.material?.[role] }
309
+ let elevation = {} as Theme["elevation"]
310
+ for (let level of ELEVATION_LEVELS) elevation[level] = def.elevation?.[level] ?? 0
207
311
  let base = def.text?.base ?? 14
208
312
  let ratio = def.text?.ratio ?? 1.26
209
313
  let role = (name: TextVariant): TextStyle => {
@@ -235,8 +339,26 @@ export function defineTheme(def: ThemeDefinition, scheme?: "light" | "dark"): Th
235
339
  borderWidth: { ...BORDER_WIDTH, ...def.borderWidth },
236
340
  motion: { ...MOTION, ...def.motion },
237
341
  size: { ...SIZE, ...def.size },
238
- icons: def.icons ?? {},
239
- components: def.components ?? {},
342
+ icons: { chevronDown: def.icons?.chevronDown, check: def.icons?.check },
343
+ components,
344
+ light: {
345
+ direction: def.light?.direction ?? LIGHT.direction,
346
+ color: def.light?.color != null ? pick("light.color", def.light.color, scheme) : LIGHT.color,
347
+ ambient: def.light?.ambient != null ? pick("light.ambient", def.light.ambient, scheme) : LIGHT.ambient,
348
+ },
349
+ shadow: {
350
+ color: def.shadow?.color != null ? pick("shadow.color", def.shadow.color, scheme) : SHADOW.color,
351
+ strength: def.shadow?.strength != null ? pick("shadow.strength", def.shadow.strength, scheme) : SHADOW.strength,
352
+ },
353
+ material,
354
+ elevation,
355
+ finish: {
356
+ program: def.finish?.program,
357
+ params: def.finish?.params,
358
+ textures: def.finish?.textures,
359
+ previous: def.finish?.previous,
360
+ vertexCount: def.finish?.vertexCount,
361
+ },
240
362
  }
241
363
  }
242
364
 
@@ -256,12 +378,15 @@ const DEFAULT: ThemeDefinition = {
256
378
  // renders thin on low-DPI and its contrast depends on what sits behind
257
379
  // it. Precomputed - core's mixColors delegates to flux:rendertree, and a
258
380
  // preset is data that must not need the render engine at import time
259
- // (the website token build imports this module headless). If text or
260
- // background changes, recompute: mixColors(text, background, 0.4).
261
- // The dark tone sits a step above the 4.5:1 AA floor (about 5.4:1)
262
- // instead of at the mix: the body text already sits low, and the
263
- // strict mix falls under it.
264
- textMuted: ["#707376", "#828993"],
381
+ // (the website token build imports this module headless). Both tones
382
+ // are tuned a step above the 4.5:1 AA floor against surfaceAlt, the
383
+ // least contrasting surface muted text sits on (list rows, tracks,
384
+ // fields) - tuning against `background` alone left them at 4.1:1 and
385
+ // 4.3:1 there. Light lands at 5.2:1 on surfaceAlt (6.0:1 on the
386
+ // background), dark at 5.4:1 (6.8:1), and both keep a clear step of
387
+ // de-emphasis under `text`. If a surface changes, recheck the WCAG
388
+ // ratios against the new surfaceAlt.
389
+ textMuted: ["#606366", "#939aa4"],
265
390
  border: ["rgba(0,0,0,0.15)", "rgba(255,255,255,0.14)"],
266
391
  // Accent tuned to the puzzle mark's mid blue.
267
392
  primary: "#547ebf",
@@ -284,8 +409,30 @@ const DEFAULT: ThemeDefinition = {
284
409
  text: { roles: { caption: { size: 12 } } },
285
410
  }
286
411
 
412
+ // The lit presets: the same palette under a light, with a sheen and a bevel
413
+ // per role and the elevation levels raised. Strengths tuned by eye on a
414
+ // low-DPI display, dark and light: the accent faces catch the most light,
415
+ // overlays the least. The cap (Switch knob, Slider thumb) is a light face
416
+ // in both schemes, lit like any other. The shadow strength on dark is what
417
+ // a shadow needs there to read at all.
418
+ const LIT: ThemeDefinition = {
419
+ ...DEFAULT,
420
+ color: { ...DEFAULT.color, thumb: "#f4f6fa" },
421
+ light: { ambient: 0.35 },
422
+ shadow: { strength: [1, 1.7] },
423
+ material: {
424
+ surface: { sheen: 0.5, bevel: 0.6 },
425
+ control: { sheen: 0.6, bevel: 0.7 },
426
+ accent: { sheen: 1, bevel: 0.9 },
427
+ overlay: { sheen: 0.4, bevel: 0.6 },
428
+ },
429
+ elevation: { flat: 0, raised: 2, floating: 6, overlay: 14 },
430
+ }
431
+
287
432
  export let darkTheme: Theme = defineTheme(DEFAULT, "dark")
288
433
  export let lightTheme: Theme = defineTheme(DEFAULT, "light")
434
+ export let litDarkTheme: Theme = defineTheme(LIT, "dark")
435
+ export let litLightTheme: Theme = defineTheme(LIT, "light")
289
436
 
290
437
  let [themeStore, setThemeStore] = createStore<Theme>({ ...darkTheme })
291
438
 
@@ -300,7 +447,11 @@ type ThemePartial = { [K in keyof Theme]?: Partial<Theme[K]> }
300
447
  // Switch themes with a full preset (setTheme(lightTheme)), a resolved
301
448
  // definition (setTheme(defineTheme({...}))), or apply a targeted override
302
449
  // (setTheme({ color: { primary: "#f00" } })). Merges one level deep per
303
- // category (for components, that level is the component name).
450
+ // category (for components, that level is the component name; for
451
+ // material, the role). A resolved theme carries every key of every
452
+ // category, unset ones as undefined, so it replaces the previous theme
453
+ // outright, per-component overrides and icon slots included; a partial
454
+ // keeps whatever it does not name.
304
455
  export function setTheme(partial: ThemePartial) {
305
456
  setThemeStore((s) => {
306
457
  for (let key in partial) {
@@ -309,3 +460,13 @@ export function setTheme(partial: ThemePartial) {
309
460
  }
310
461
  })
311
462
  }
463
+
464
+ /**
465
+ * A pill or circle radius that follows the theme: half of `size`, capped by
466
+ * `radius.full`, so a theme with `full: 0` squares every round part (Switch
467
+ * track and knob, Slider groove and thumb, Radio, ProgressBar) while the
468
+ * default 9999 leaves them round. For a custom control with a round part.
469
+ */
470
+ export function pill(size: number): number {
471
+ return Math.min(theme.radius.full, size / 2)
472
+ }
package/src/tooltip.tsx CHANGED
@@ -5,8 +5,9 @@ import { policy } from "./policy"
5
5
  import { space } from "./spacing"
6
6
  import { typeStyle } from "./typography"
7
7
  import type { TransitionProps } from "./types"
8
- import { splitTransition, transitionEndFor, withTransitionDefaults } from "./types"
8
+ import { splitTransition, transitionEndFor } from "./types"
9
9
  import { colorFade, popupFadeOut } from "./motion"
10
+ import { Surface } from "./surface"
10
11
 
11
12
  export interface TooltipProps extends TransitionProps {
12
13
  // The tooltip body. A string/number renders as themed text; anything else
@@ -32,7 +33,8 @@ let margin = () => theme.spacing.sm
32
33
  * mouse pointer on the wrapped content shows a bubble near it after a short
33
34
  * delay. Under the touch policy it never shows, so tooltip content must stay
34
35
  * non-essential. The bubble is portal-mounted at the window root and takes no
35
- * pointer events; it hides on leave and on press.
36
+ * pointer events; it hides on leave and on press. Its bubble is an
37
+ * overlay face, floating.
36
38
  */
37
39
  export function Tooltip(props: TooltipProps) {
38
40
  let anchor: { id: number } | undefined
@@ -94,25 +96,27 @@ export function Tooltip(props: TooltipProps) {
94
96
  opacity={pos() ? 1 : 0}
95
97
  transition={popupFadeOut()}
96
98
  >
97
- <d-rect
98
- transition={withTransitionDefaults(split().background, colorFade())}
99
- onTransitionEnd={transitionEndFor("background", props.onTransitionEnd)}
100
- color={theme.components.tooltip?.backgroundColor ?? theme.color.surfaceAlt}
99
+ <Surface
100
+ role="overlay"
101
+ elevation={theme.components.tooltip?.elevation ?? "floating"}
102
+ fill={theme.components.tooltip?.backgroundColor ?? theme.color.surfaceAlt}
101
103
  radius={theme.components.tooltip?.borderRadius ?? theme.radius.sm}
104
+ material={theme.components.tooltip?.material}
105
+ glow={theme.components.tooltip?.glow}
106
+ outline={{
107
+ color: theme.components.tooltip?.borderColor ?? theme.color.border,
108
+ width: theme.components.tooltip?.borderWidth ?? theme.borderWidth.sm,
109
+ }}
110
+ fillTransition={split().background}
111
+ onFillTransitionEnd={transitionEndFor("background", props.onTransitionEnd)}
112
+ outlineTransition={split().border}
113
+ onOutlineTransitionEnd={transitionEndFor("border", props.onTransitionEnd)}
102
114
  />
103
115
  <Show when={isText()} fallback={content()}>
104
116
  <text transition={colorFade()} color={theme.color.text} {...typeStyle("body")}>
105
117
  {content()}
106
118
  </text>
107
119
  </Show>
108
- <d-rect
109
- drawStyle="stroke"
110
- transition={withTransitionDefaults(split().border, colorFade())}
111
- onTransitionEnd={transitionEndFor("border", props.onTransitionEnd)}
112
- color={theme.components.tooltip?.borderColor ?? theme.color.border}
113
- strokeWidth={theme.components.tooltip?.borderWidth ?? theme.borderWidth.sm}
114
- radius={theme.components.tooltip?.borderRadius ?? theme.radius.sm}
115
- />
116
120
  </view>,
117
121
  )
118
122
  }
package/src/types.ts CHANGED
@@ -201,6 +201,56 @@ export function transitionEndFor<P extends string>(
201
201
  // into Taffy and changing them triggers a relayout; style properties are
202
202
  // paint-only and can change without affecting layout.
203
203
 
204
+ /**
205
+ * Emitted light around a filled part: a zero-offset halo, `radius` logical
206
+ * px of blur. Without `color` the part glows in its own fill color (a danger
207
+ * button red, a primary one in the accent); a part whose fill has no color
208
+ * to take (a ghost button, a gradient) then casts nothing, so a theme-wide
209
+ * glow never grows a black halo. A layer of a Material; Theme["material"]
210
+ * says where the roles land.
211
+ */
212
+ export type Glow = { radius: number; color?: Color }
213
+
214
+ /**
215
+ * The four kinds of face a component's chrome can be. A component picks a
216
+ * role, never a material: the theme assigns each role its Material and the
217
+ * Surface primitive draws it. `surface` is what things sit on (Card, Item,
218
+ * the NavShell strips, the panes), `control` the body of a control (a
219
+ * secondary Button, a field, a Select trigger, the tracks and grooves),
220
+ * `accent` the accent-colored fills (a primary Button, Switch on, the
221
+ * Slider fill, a Badge) and `overlay` what floats over the screen (a
222
+ * Tooltip, a dropdown, a menu).
223
+ */
224
+ export type MaterialRole = "surface" | "control" | "accent" | "overlay"
225
+
226
+ /**
227
+ * How far a face stands off the ground, as a named level the theme maps to
228
+ * a height (Theme["elevation"]): its shadows' offset and blur grow with it.
229
+ * `flat` casts nothing; Card and the raised parts of controls sit at
230
+ * `raised` by default, popups at `floating` or `overlay`.
231
+ */
232
+ export type ElevationLevel = "flat" | "raised" | "floating" | "overlay"
233
+
234
+ /**
235
+ * A frosted backdrop: the face blurs and saturates what is painted beneath
236
+ * it (a core `backdropFilter`) and paints `tint` over that, its own fill at
237
+ * a translucency when unset. A deliberate overlay, never a surface material:
238
+ * every glass face captures the pixels under it each frame it paints, which
239
+ * a tiled GPU pays for per panel (okf/notes/backdrop-filter-cost.md).
240
+ */
241
+ export type Glass = { blur: number; tint?: Color }
242
+
243
+ /**
244
+ * The stack of optional layers a face is drawn with; `{}` is the flat look,
245
+ * a plain fill. `sheen` (0..1) is a fill gradient along the theme light,
246
+ * lit edge to shaded edge; `bevel` (0..1) a one-pixel stroke from the light
247
+ * color on the lit edge to black on the shaded one; `glass` the frosted
248
+ * backdrop; `glow` the emitted-light halo. Layers compose: glass with a
249
+ * glowing edge is two layers, not a fifth kind. Shadows are not a layer,
250
+ * they follow the elevation.
251
+ */
252
+ export type Material = { sheen?: number; bevel?: number; glass?: Glass; glow?: Glow }
253
+
204
254
  // Paint-only props. None of these change the box Taffy computes. Borders are
205
255
  // drawn as a stroke overlay (not part of the box model), and the transform is
206
256
  // applied at paint time, so both live here.
@@ -231,13 +281,27 @@ export interface StyleProps {
231
281
  // clip, scroll); a single number or [tl, tr, br, bl].
232
282
  clipRadius?: number | [number, number, number, number]
233
283
  opacity?: number
284
+ // A halo on the component's accent part: the fill of Button, Badge,
285
+ // Checkbox and Switch, the Slider and ProgressBar fills, the Radio dot,
286
+ // the SegmentedControl indicator. Unset, the role's material glow applies
287
+ // (theme.material); null clears it here.
288
+ glow?: Glow | null
289
+ // Layers merged over the role's material for this component's faces (a
290
+ // matte card in a lit theme: `material: { sheen: 0, bevel: 0 }`).
291
+ material?: Partial<Material>
292
+ // The elevation of the component's raised face (a Card, a Button, an
293
+ // Item), replacing its default level: `elevation: "flat"` grounds a card,
294
+ // `"raised"` lifts a list row (priced in okf/notes/lit-list-cost.md).
295
+ elevation?: ElevationLevel
234
296
  }
235
297
 
236
298
  // One choice in an options list, shared by the single-choice controls
237
299
  // (Select, SegmentedControl). Lives here so those components stay independent
238
300
  // of each other: shared shapes go through this module, never a sibling import.
239
- export interface Option {
240
- value: unknown
301
+ // Generic in the value, so a control's `onChange` hands back the type its
302
+ // options were declared with.
303
+ export interface Option<T = unknown> {
304
+ value: T
241
305
  label: string
242
306
  }
243
307
 
package/src/window.tsx CHANGED
@@ -1,23 +1,46 @@
1
- import type { LayoutProps, PointerProps } from "@solidrt/core"
1
+ import { displayScale } from "@solidrt/core"
2
+ import type { LayoutProps, PointerProps, WindowShaderProps } from "@solidrt/core"
2
3
  import type { StyleProps } from "./types"
3
4
  import { colorFade } from "./motion"
5
+ import { theme } from "./theme"
4
6
 
5
7
  export interface WindowProps extends PointerProps {
6
8
  children?: any
7
9
  title?: string
8
10
  fullscreen?: boolean
11
+ // The window's own finish, a core window shader declaration forwarded
12
+ // as-is. Unset, the window takes the theme's finish (theme.finish);
13
+ // null opts out of that too.
14
+ shader?: WindowShaderProps | null
9
15
  layout?: LayoutProps
10
16
  style?: StyleProps
11
17
  }
12
18
 
19
+ // The theme's finish: its declaration with uScale (the display scale, a
20
+ // per-client fact the theme cannot know; for effects counted in logical
21
+ // px) merged into the params, or nothing while the theme has no program.
22
+ function themeFinish(): WindowShaderProps | null {
23
+ let f = theme.finish
24
+ if (f.program == null) return null
25
+ return {
26
+ program: f.program,
27
+ params: { ...f.params, uScale: displayScale() },
28
+ textures: f.textures,
29
+ previous: f.previous,
30
+ vertexCount: f.vertexCount,
31
+ }
32
+ }
33
+
13
34
  // A window is the root surface: it can't be transformed or bordered, so only
14
- // the paint-only backgroundColor from style applies here.
35
+ // the paint-only backgroundColor from style applies here. Its finish is the
36
+ // theme's, or the app's own (`shader`).
15
37
  export function Window(props: WindowProps) {
16
38
  return (
17
39
  <window
18
40
  {...props.layout}
19
41
  title={props.title}
20
42
  fullscreen={props.fullscreen}
43
+ shader={props.shader === undefined ? themeFinish() : props.shader}
21
44
  onPointerEnter={props.onPointerEnter}
22
45
  onPointerLeave={props.onPointerLeave}
23
46
  onPointerDown={props.onPointerDown}
@@ -37,4 +60,4 @@ export function Window(props: WindowProps) {
37
60
  {props.children}
38
61
  </window>
39
62
  )
40
- }
63
+ }