@phreshos/react-ui 0.1.16 → 0.1.18

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/README.md +158 -8
  2. package/dist/appearance-provider.d.ts +6 -8
  3. package/dist/appearance-provider.js +34 -19
  4. package/dist/button.d.ts +11 -5
  5. package/dist/button.js +28 -36
  6. package/dist/checkbox.d.ts +11 -0
  7. package/dist/checkbox.js +10 -0
  8. package/dist/color.d.ts +33 -0
  9. package/dist/color.js +84 -5
  10. package/dist/control-surface.d.ts +20 -0
  11. package/dist/control-surface.js +11 -0
  12. package/dist/control.d.ts +55 -0
  13. package/dist/control.js +87 -0
  14. package/dist/document-scrollbars.js +1 -1
  15. package/dist/field-style.d.ts +3 -0
  16. package/dist/field-style.js +11 -0
  17. package/dist/flex.js +2 -2
  18. package/dist/grid.js +2 -2
  19. package/dist/input.d.ts +4 -0
  20. package/dist/input.js +11 -0
  21. package/dist/main.d.ts +13 -3
  22. package/dist/main.js +9 -0
  23. package/dist/material-options.d.ts +24 -0
  24. package/dist/material-options.js +22 -0
  25. package/dist/material-paint.d.ts +11 -0
  26. package/dist/material-paint.js +76 -0
  27. package/dist/motion-style.d.ts +21 -0
  28. package/dist/motion-style.js +45 -0
  29. package/dist/panel.d.ts +1 -1
  30. package/dist/radio.d.ts +14 -0
  31. package/dist/radio.js +17 -0
  32. package/dist/select.d.ts +17 -0
  33. package/dist/select.js +28 -0
  34. package/dist/slider.d.ts +7 -0
  35. package/dist/slider.js +36 -0
  36. package/dist/surface-edge.d.ts +10 -0
  37. package/dist/surface-edge.js +59 -0
  38. package/dist/surface.d.ts +23 -44
  39. package/dist/surface.js +47 -86
  40. package/dist/switch.d.ts +10 -0
  41. package/dist/switch.js +10 -0
  42. package/dist/text-control.d.ts +9 -0
  43. package/dist/text-control.js +0 -0
  44. package/dist/textarea.d.ts +6 -0
  45. package/dist/textarea.js +17 -0
  46. package/dist/toggle-indicator.d.ts +16 -0
  47. package/dist/toggle-indicator.js +48 -0
  48. package/package.json +3 -3
  49. package/dist/surface-material.d.ts +0 -14
  50. package/dist/surface-material.js +0 -128
package/README.md CHANGED
@@ -25,22 +25,90 @@ applications own composition.
25
25
  | Bun | `bun add @phreshos/react-ui` |
26
26
  | Yarn | `yarn add @phreshos/react-ui` |
27
27
 
28
- `@phreshos/core`, React, and React DOM are peer dependencies.
28
+ Core is a built-in runtime dependency. React and React DOM remain peer
29
+ dependencies because the application and React UI must share one React
30
+ runtime.
29
31
 
30
32
  ```tsx
31
- import { standardAppearance } from "@phreshos/core"
32
- import { AppearanceProvider, Button, Surface } from "@phreshos/react-ui"
33
+ import { Button, Surface } from "@phreshos/react-ui"
33
34
 
34
- <AppearanceProvider appearance={standardAppearance} theme="light">
35
- <Surface>
36
- <Button>Continue</Button>
37
- </Surface>
38
- </AppearanceProvider>
35
+ <Surface>
36
+ <Button>Continue</Button>
37
+ </Surface>
39
38
  ```
40
39
 
40
+ Without a provider, components use Core's `defaultAppearance` and reactively
41
+ follow the browser color scheme. `AppearanceProvider` independently overrides
42
+ either value for a subtree; omitted values inherit from the nearest provider.
43
+ React UI also exports that same canonical `defaultAppearance` value for callers
44
+ that need it explicitly.
41
45
  See [Appearance](https://docs.phreshos.com/system/appearance) for the contract
42
46
  interpreted by the provider and components.
43
47
 
48
+ ```tsx
49
+ import { AppearanceProvider, Button } from "@phreshos/react-ui"
50
+
51
+ <AppearanceProvider theme="dark">
52
+ <Button>Dark subtree</Button>
53
+ </AppearanceProvider>
54
+ ```
55
+
56
+ `Button`, `Input`, `Textarea`, `Select`, `Checkbox`, `Switch`, and `Radio`
57
+ use the shared Surface implementation while retaining their native behavior. Color and material
58
+ are independent inputs. `color` accepts an Appearance color and resting level such
59
+ as `background:base` or `primary:soft`, or a direct CSS color. Surface and neutral
60
+ surface hosts default to `background:base`; a component may select a semantic
61
+ default required by its own behavior, such as `primary:base` for selection.
62
+ Hover and press derive shades of that fill. The resting fill chooses the
63
+ higher-contrast text from Appearance's background and foreground once; interaction
64
+ shades keep that choice.
65
+ `size` accepts `xsmall`, `small`, `medium` (default), `large`, or `xlarge`;
66
+ spacing and radius follow Appearance. `disabled` prevents activation and focus;
67
+ `pending` prevents activation while retaining focus.
68
+
69
+ ```tsx
70
+ <Button>Cancel</Button>
71
+ <Button color="primary:base" onPress={save}>Save</Button>
72
+ <Button color="danger:soft" size="small">Delete</Button>
73
+ ```
74
+
75
+ `Surface` is the material-owning element. It renders a `div` by default, while
76
+ `as` selects another React element and preserves that element's native properties
77
+ and ref type. Surface owns its paint, opacity, frost, refraction, grain, edge,
78
+ radius, and clipping requirements. It never creates or consumes a shadow; shadow
79
+ remains an independent visual concern. `radius` accepts a size level, a number in
80
+ pixels, or a CSS radius and defaults to `medium`.
81
+
82
+ ```tsx
83
+ <Surface color="background:soft" radius="large">Derived values</Surface>
84
+ <Surface as="button" type="button" color="#345678" radius={18}>Action</Surface>
85
+ <Surface as={Grid} columns={3} gap="medium">Grid content</Surface>
86
+ ```
87
+
88
+ `as` can also select an outside React component. A valid Surface host preserves
89
+ the `style` and `children` it receives on one host element and forwards its ref
90
+ to that same element. This lets layout components carry the material without a
91
+ wrapper. The host retains ownership of its own behavior and layout properties;
92
+ Surface retains ownership of material, edge, radius, and required geometry.
93
+
94
+ `MaterialOptions` defines `opacity`, `backdrop`, `grain`, `grainAmount`,
95
+ `distortion`, and `saturation`. Surface accepts these properties directly because
96
+ it is the material-owning element; it does not accept a nested `material` prop.
97
+ Color remains a separate Surface property. Omitted material values follow
98
+ `appearance.material`. Effect options accept a scale level or a direct number;
99
+ opacity affects Surface paint only, never its content. The material edge is part
100
+ of the same Surface rather than a second public entity.
101
+
102
+ Surface-based controls expose the same separate `color` and `material` props.
103
+ For text fields and Select, `material` targets the field or trigger; for Checkbox,
104
+ Switch, and Radio, it targets the indicator. RadioGroup supplies material defaults
105
+ to its options, and a Radio can override them.
106
+
107
+ ```tsx
108
+ <Button color="primary:base" material={{ opacity: 0.6, backdrop: 0 }}>Save</Button>
109
+ <Input label="Name" radius="large" material={{ grain: "small" }} />
110
+ ```
111
+
44
112
  `Panel` composes an outer `Surface`, an optional header, and an inset content
45
113
  `Surface`. Both materials retain Surface defaults; the content inset follows
46
114
  Appearance spacing. Positioning, modality, and lifecycle belong to the caller.
@@ -56,6 +124,88 @@ import { Panel } from "@phreshos/react-ui"
56
124
  Native properties and the forwarded ref target the outer Surface.
57
125
  `contentProps` targets the inner Surface; `children` supplies its content.
58
126
 
127
+ ## Inputs
128
+
129
+ Every input uses Appearance colors and the same five `size` levels as Button.
130
+ `color` selects the fill independently from material. Input, Textarea, Select,
131
+ and Button use `background:base` when it is omitted; selection indicators use
132
+ `primary:base`. Invalid
133
+ fields use danger instead. Text fields share Surface's glass edge. Interaction shades
134
+ derive from the base color's lightness, and text or selection marks use whichever
135
+ Appearance background or foreground has higher contrast against the base fill.
136
+ That text or mark color stays unchanged across interaction shades.
137
+ These are component-owned derivations, not additional Appearance settings.
138
+ Theme names never imply particular colors. No component requires a Client or
139
+ Server SDK.
140
+
141
+ Fields distinguish hover, pointer focus, keyboard focus, and invalid state.
142
+ Shared CSS transitions use a fixed 120ms ease-out timing for colors and corner
143
+ radius. The material fill and opacity values transition on the painted layers, not on the
144
+ Surface host. Select menus combine a small placement-aware slide with an overlay
145
+ opacity fade for entry and exit, using React Aria's animation lifecycle. Reduced-motion preferences make
146
+ these changes immediate. No Appearance configuration is added for motion yet.
147
+ Blur, distortion, geometry, and Surface host opacity are not transitioned; gradients
148
+ and structurally removed effects change directly rather than adding extra
149
+ layers or keeping disabled effects alive.
150
+
151
+ Motion animates toggle presses, selection marks, switch travel, the Select
152
+ chevron, and Slider thumb feedback. Reduced-motion preferences remove spatial
153
+ feedback and make state transitions immediate. Slider values and native input
154
+ behavior are never delayed by visual animation.
155
+
156
+ | Component | Value contract | Purpose |
157
+ | --- | --- | --- |
158
+ | `Input` | `value`, `defaultValue`, `onChange(string)` | Single-line text; supports text, email, password, search, URL, and telephone types |
159
+ | `Textarea` | `value`, `defaultValue`, `onChange(string)` | Multiline text; four rows by default, vertically resizable |
160
+ | `Checkbox` | `checked`, `defaultChecked`, `onChange(boolean)` | Independent selection; `indeterminate` represents a mixed state |
161
+ | `Switch` | `checked`, `defaultChecked`, `onChange(boolean)` | An on/off setting |
162
+ | `RadioGroup` / `Radio` | Group `value`, `defaultValue`, `onChange(string)`; Radio `value` | One exclusive choice; vertical by default, optionally horizontal |
163
+ | `Select` | `value: string \| null`, `defaultValue`, `onChange(string \| null)` | One choice from `options: { value, label, disabled? }[]` |
164
+ | `Slider` | `value`, `defaultValue`, `onChange(number)` | One numeric value; `minValue`, `maxValue`, `step`, and `onChangeEnd`; horizontal by default |
165
+
166
+ Use `label` for visible labels, or `aria-label` / `aria-labelledby` for an
167
+ accessible name without visible text. `description` provides associated help.
168
+ `disabled` prevents interaction and removes the control from keyboard focus.
169
+ `name` participates in native form submission. Controlled values remain owned
170
+ by the caller; omit them and use defaults for internal state and form reset.
171
+
172
+ Text fields, Checkbox, Switch, and RadioGroup also support `readOnly`: the
173
+ value cannot change, but the control remains focusable. These fields and
174
+ Select support `required`, `invalid`, `errorMessage`, and React Aria's native
175
+ or ARIA validation behavior. A Radio's selection and validation belong to its
176
+ group. Slider represents a bounded number rather than a required text or
177
+ choice field, and has no read-only or validation-error mode. Select has no
178
+ read-only mode; disable it when selection must be unavailable.
179
+
180
+ `Input`, `Textarea`, and `Select` accept `radius`. Other inputs retain their
181
+ intrinsic indicator shapes. `className` and `style` address the field's root;
182
+ default visual properties remain component-owned. Input and Textarea refs
183
+ target their native text controls; other refs target the root div. Checkbox,
184
+ Switch, and Radio additionally accept `inputRef` for their native input.
185
+
186
+ ```tsx
187
+ import { Input, Textarea, Checkbox, Radio, RadioGroup, Switch, Select, Slider } from "@phreshos/react-ui"
188
+
189
+ <Input label="Name" name="name" required />
190
+ <Textarea label="Description" name="description" />
191
+ <Checkbox label="Remember this choice" name="remember" />
192
+ <Switch label="Notifications" name="notifications" defaultChecked />
193
+ <RadioGroup label="Layout" name="layout" defaultValue="grid">
194
+ <Radio label="Grid" value="grid" />
195
+ <Radio label="List" value="list" />
196
+ </RadioGroup>
197
+ <Select label="Sort" name="sort" options={[
198
+ { value: "name", label: "Name" },
199
+ { value: "date", label: "Date" }
200
+ ]} />
201
+ <Slider label="Volume" name="volume" defaultValue={50} minValue={0} maxValue={100} step={1} />
202
+ ```
203
+
204
+ React Aria owns focus, keyboard, form, and selection behavior. Select's popup
205
+ uses Surface defaults. Toggle indicators use Motion internally and respect
206
+ reduced-motion preferences. The preview Program demonstrates each input's
207
+ sizes, colors, state, and interaction without overriding its visual defaults.
208
+
59
209
  ## Development
60
210
 
61
211
  ```sh
@@ -1,17 +1,15 @@
1
1
  import type { ReactNode } from "react";
2
- import type { Appearance, Theme, ThemedValue } from "@phreshos/core";
3
- /** Provides unresolved Appearance and one effective Theme to a React subtree. */
2
+ import { type Appearance, type Theme, type ThemedValue } from "@phreshos/core";
3
+ /** Optionally overrides unresolved Appearance and effective Theme for a React subtree. */
4
4
  export declare function AppearanceProvider({ appearance, children, theme }: AppearanceProviderProps): import("react").JSX.Element;
5
- /** Returns the complete unresolved Appearance supplied by the nearest provider. */
5
+ /** Returns the nearest unresolved Appearance, or Core's complete default. */
6
6
  export declare function useAppearance(): Appearance;
7
- /** Returns the effective Theme supplied by the nearest provider. */
7
+ /** Returns the nearest explicit Theme, or reactively follows the browser. */
8
8
  export declare function useTheme(): Theme;
9
9
  /** Resolves one themed value only where it is consumed. */
10
10
  export declare function useResolveTheme<Value, DarkValue extends Value = never>(value: ThemedValue<Value, DarkValue>): Exclude<Value, undefined>;
11
- /** Internal optional read for primitives that also accept direct values. */
12
- export declare function useAppearanceIfAvailable(): Appearance | null;
13
11
  export interface AppearanceProviderProps {
14
- readonly appearance: Appearance;
12
+ readonly appearance?: Appearance;
15
13
  readonly children: ReactNode;
16
- readonly theme: Theme;
14
+ readonly theme?: Theme;
17
15
  }
@@ -1,34 +1,49 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { createContext, useContext } from "react";
2
+ import { createContext, useContext, useSyncExternalStore } from "react";
3
+ import { defaultAppearance } from "@phreshos/core";
3
4
  import DocumentScrollbars from "./document-scrollbars.js";
4
- const missing = Symbol("AppearanceProvider");
5
- const AppearanceContext = createContext(missing);
6
- const ThemeContext = createContext(missing);
7
- /** Provides unresolved Appearance and one effective Theme to a React subtree. */
5
+ import MotionStyle from "./motion-style.js";
6
+ const AppearanceContext = createContext(defaultAppearance);
7
+ const ThemeContext = createContext(null);
8
+ /** Optionally overrides unresolved Appearance and effective Theme for a React subtree. */
8
9
  export function AppearanceProvider({ appearance, children, theme }) {
9
- return _jsx(AppearanceContext.Provider, { value: appearance, children: _jsxs(ThemeContext.Provider, { value: theme, children: [_jsx(DocumentScrollbars, { appearance: appearance, theme: theme }), children] }) });
10
+ const inheritedAppearance = useAppearance();
11
+ const inheritedTheme = useTheme();
12
+ const resolvedAppearance = appearance ?? inheritedAppearance;
13
+ const resolvedTheme = theme ?? inheritedTheme;
14
+ return _jsx(AppearanceContext.Provider, { value: resolvedAppearance, children: _jsxs(ThemeContext.Provider, { value: resolvedTheme, children: [_jsx(MotionStyle, {}), _jsx(DocumentScrollbars, { appearance: resolvedAppearance, theme: resolvedTheme }), children] }) });
10
15
  }
11
- /** Returns the complete unresolved Appearance supplied by the nearest provider. */
16
+ /** Returns the nearest unresolved Appearance, or Core's complete default. */
12
17
  export function useAppearance() {
13
- const appearance = useContext(AppearanceContext);
14
- if (appearance === missing)
15
- throw new Error("useAppearance() requires an AppearanceProvider");
16
- return appearance;
18
+ return useContext(AppearanceContext);
17
19
  }
18
- /** Returns the effective Theme supplied by the nearest provider. */
20
+ /** Returns the nearest explicit Theme, or reactively follows the browser. */
19
21
  export function useTheme() {
20
22
  const theme = useContext(ThemeContext);
21
- if (theme === missing)
22
- throw new Error("useTheme() requires an AppearanceProvider");
23
- return theme;
23
+ const browserTheme = useBrowserTheme();
24
+ return theme ?? browserTheme;
24
25
  }
25
26
  /** Resolves one themed value only where it is consumed. */
26
27
  export function useResolveTheme(value) {
27
28
  const theme = useTheme();
28
29
  return (theme === "dark" && "dark" in value ? value.dark : value.light);
29
30
  }
30
- /** Internal optional read for primitives that also accept direct values. */
31
- export function useAppearanceIfAvailable() {
32
- const appearance = useContext(AppearanceContext);
33
- return appearance === missing ? null : appearance;
31
+ function useBrowserTheme() {
32
+ return useSyncExternalStore(subscribeBrowserTheme, browserTheme, serverTheme);
34
33
  }
34
+ function subscribeBrowserTheme(change) {
35
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function")
36
+ return () => undefined;
37
+ const preference = window.matchMedia(darkThemeQuery);
38
+ preference.addEventListener("change", change);
39
+ return () => preference.removeEventListener("change", change);
40
+ }
41
+ function browserTheme() {
42
+ return typeof window !== "undefined"
43
+ && typeof window.matchMedia === "function"
44
+ && window.matchMedia(darkThemeQuery).matches
45
+ ? "dark"
46
+ : "light";
47
+ }
48
+ function serverTheme() { return "light"; }
49
+ const darkThemeQuery = "(prefers-color-scheme: dark)";
package/dist/button.d.ts CHANGED
@@ -1,12 +1,18 @@
1
1
  import type { CSSProperties, ReactNode } from "react";
2
2
  import type { ButtonProps as AriaButtonProps } from "react-aria-components";
3
- import { type ScaleLevel } from "./scale.js";
4
- import { type RadiusProps } from "./radius.js";
5
- type NativeButtonProps = Omit<AriaButtonProps, "children" | "className" | "isDisabled" | "isPending" | "onClick" | "onPress" | "style">;
3
+ import type { ScaleLevel } from "./scale.js";
4
+ import type { RadiusProps } from "./radius.js";
5
+ import { type ControlColor } from "./control.js";
6
+ import type { MaterialOverrides } from "./material-options.js";
7
+ type NativeButtonProps = Omit<AriaButtonProps, "children" | "className" | "color" | "isDisabled" | "isPending" | "onClick" | "onPress" | "style">;
8
+ /** A semantic color from Appearance; omission uses background and foreground. */
9
+ export type ButtonColor = ControlColor;
6
10
  /** Properties accepted by the shared interactive button. */
7
- export interface ButtonProps extends NativeButtonProps, RadiusProps {
11
+ export interface ButtonProps extends NativeButtonProps, RadiusProps, MaterialOverrides {
8
12
  /** Visible Button content. */
9
13
  readonly children?: ReactNode;
14
+ /** Base color for the material. Omission keeps the Button neutral. */
15
+ readonly color?: ButtonColor;
10
16
  /** Native class name applied without replacing the component contract. */
11
17
  readonly className?: string;
12
18
  /** Prevents focus and activation. */
@@ -17,7 +23,7 @@ export interface ButtonProps extends NativeButtonProps, RadiusProps {
17
23
  readonly onPress?: () => void;
18
24
  /** Derives the Button's spacing from Appearance's concrete default. */
19
25
  readonly size?: ScaleLevel;
20
- /** Additional native styles that do not replace the Button's identity. */
26
+ /** Native styles applied after Button defaults. */
21
27
  readonly style?: CSSProperties;
22
28
  }
23
29
  /** An Appearance-aware action with normalized pointer and keyboard behavior. */
package/dist/button.js CHANGED
@@ -1,34 +1,33 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { forwardRef } from "react";
3
3
  import { Button as AriaButton } from "react-aria-components";
4
- import { scale } from "./scale.js";
5
- import { resolveRadius } from "./radius.js";
6
- import { useAppearance, useResolveTheme } from "./appearance-provider.js";
4
+ import { controlFontSizes, useControlTheme } from "./control.js";
5
+ import { SurfaceButton } from "./control-surface.js";
6
+ import { visualTransition } from "./motion-style.js";
7
7
  /** An Appearance-aware action with normalized pointer and keyboard behavior. */
8
- export const Button = forwardRef(function Button({ children, disabled = false, pending = false, onPress, radius = "medium", size = "medium", style, type = "button", ...properties }, ref) {
9
- const appearance = useAppearance();
10
- const spacing = scale(useResolveTheme(appearance.spacing), size);
11
- const borderRadius = resolveRadius(radius, appearance);
12
- const foreground = useResolveTheme(appearance.foreground);
13
- return _jsx(AriaButton, { ...properties, ref: ref, type: type, isDisabled: disabled, isPending: pending, onPress: onPress, style: ({ isFocusVisible, isHovered, isPressed }) => buttonStyle({
14
- borderRadius,
8
+ export const Button = forwardRef(function Button({ children, color, disabled = false, pending = false, onPress, radius = "medium", size = "medium", style, material, type = "button", ...properties }, ref) {
9
+ const theme = useControlTheme({ color, radius, size });
10
+ return _jsx(AriaButton, { ...properties, ref: ref, type: type, isDisabled: disabled, isPending: pending, onPress: onPress, render: (native, state) => _jsx(SurfaceButton, { native: native, material: material, paint: buttonPaint(theme, !disabled && !pending, state.isHovered, state.isPressed) }), style: ({ isFocusVisible, isHovered, isPressed }) => buttonStyle({
11
+ theme,
15
12
  disabled,
16
13
  isFocusVisible,
17
14
  isHovered,
18
15
  isPressed,
19
16
  pending,
20
17
  size,
21
- spacing,
22
- foreground,
23
18
  style
24
19
  }), children: children });
25
20
  });
26
- function buttonStyle({ borderRadius, disabled, isFocusVisible, isHovered, isPressed, pending, size, spacing, foreground, style }) {
27
- const fontSize = buttonFontSizes[size];
28
- const height = Math.max(size === "xsmall" ? 24 : 28, 20 + spacing);
21
+ function buttonStyle({ theme, disabled, isFocusVisible, isHovered, isPressed, pending, size, style }) {
22
+ const fontSize = controlFontSizes[size];
23
+ const { spacing, foreground } = theme;
24
+ const height = Math.max(24, 24 + spacing);
25
+ const interactive = !disabled && !pending;
26
+ const paint = buttonPaint(theme, interactive, isHovered, isPressed);
29
27
  return {
30
- ...style,
28
+ ...visualTransition,
31
29
  appearance: "none",
30
+ boxSizing: "border-box",
32
31
  display: "inline-grid",
33
32
  gridAutoFlow: "column",
34
33
  gridAutoColumns: "max-content",
@@ -37,34 +36,27 @@ function buttonStyle({ borderRadius, disabled, isFocusVisible, isHovered, isPres
37
36
  minWidth: 0,
38
37
  height,
39
38
  paddingBlock: 0,
40
- paddingInline: Math.max(8, spacing * 2 / 3),
39
+ paddingInline: Math.max(8, spacing),
41
40
  gap: Math.max(4, spacing / 2),
42
- border: "1px solid rgba(255, 255, 255, 0.45)",
43
- borderRadius,
44
- outline: "none",
45
- color: foreground,
46
- backgroundColor: `rgba(255, 255, 255, ${isPressed ? 0.42 : isHovered ? 0.5 : 0.3})`,
47
- boxShadow: isFocusVisible
48
- ? "0 0 0 2px rgba(255, 255, 255, 0.85), inset 0 1px 0 rgba(255, 255, 255, 0.8)"
49
- : "inset 0 1px 0 rgba(255, 255, 255, 0.8)",
41
+ border: "none",
42
+ borderRadius: theme.radius,
43
+ outline: isFocusVisible ? `2px solid ${foreground}` : "none",
44
+ outlineOffset: 2,
45
+ ...paint,
50
46
  opacity: disabled ? 0.46 : pending ? 0.68 : 1,
51
- transform: isPressed ? "scale(0.95)" : "scale(1)",
52
- transition: "background-color 100ms ease, box-shadow 100ms ease, opacity 100ms ease, transform 100ms ease",
53
47
  cursor: disabled ? "not-allowed" : pending ? "progress" : "pointer",
54
48
  font: "inherit",
55
49
  fontSize,
56
- fontWeight: 650,
50
+ fontWeight: 550,
57
51
  lineHeight: 1,
58
52
  textAlign: "center",
59
53
  textDecoration: "none",
60
54
  userSelect: "none",
61
- WebkitTapHighlightColor: "transparent"
55
+ WebkitTapHighlightColor: "transparent",
56
+ ...style
62
57
  };
63
58
  }
64
- const buttonFontSizes = Object.freeze({
65
- xsmall: 10,
66
- small: 11,
67
- medium: 12,
68
- large: 13,
69
- xlarge: 14
70
- });
59
+ function buttonPaint(theme, interactive, hovered, pressed) {
60
+ const paints = theme.colored ? theme.paints.palette : theme.paints.neutral;
61
+ return interactive && pressed ? paints.pressed : interactive && hovered ? paints.hover : paints.rest;
62
+ }
@@ -0,0 +1,11 @@
1
+ import type { CheckboxFieldProps } from "react-aria-components";
2
+ import type { ControlOverrides, ControlProps, FieldProps } from "./control.js";
3
+ import type { MaterialOverrides } from "./material-options.js";
4
+ export interface CheckboxProps extends Omit<CheckboxFieldProps, ControlOverrides | "isReadOnly" | "isSelected" | "defaultSelected" | "isIndeterminate">, ControlProps, FieldProps, MaterialOverrides {
5
+ readonly checked?: boolean;
6
+ readonly defaultChecked?: boolean;
7
+ readonly indeterminate?: boolean;
8
+ readonly readOnly?: boolean;
9
+ }
10
+ /** An independent boolean field with optional mixed-state presentation. */
11
+ export declare const Checkbox: import("react").ForwardRefExoticComponent<CheckboxProps & import("react").RefAttributes<HTMLDivElement>>;
@@ -0,0 +1,10 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { forwardRef } from "react";
3
+ import { CheckboxField, CheckboxButton } from "react-aria-components";
4
+ import { FieldFeedback, fieldStyle, useControlTheme } from "./control.js";
5
+ import { ToggleIndicator, toggleStyle } from "./toggle-indicator.js";
6
+ /** An independent boolean field with optional mixed-state presentation. */
7
+ export const Checkbox = forwardRef(function Checkbox({ label, description, errorMessage, disabled, required, invalid, readOnly, checked, defaultChecked, indeterminate, size, color, style, material, ...properties }, ref) {
8
+ const theme = useControlTheme({ size, color });
9
+ return _jsxs(CheckboxField, { ...properties, ref: ref, isDisabled: disabled, isRequired: required, isInvalid: invalid, isReadOnly: readOnly, isSelected: checked, defaultSelected: defaultChecked, isIndeterminate: indeterminate, style: state => fieldStyle(theme, state.isDisabled, style), children: [_jsx(CheckboxButton, { style: state => toggleStyle(theme, state.isDisabled, state.isReadOnly), children: state => _jsxs(_Fragment, { children: [_jsx(ToggleIndicator, { kind: "checkbox", material: material, theme: theme, selected: state.isSelected, indeterminate: state.isIndeterminate, focused: state.isFocusVisible, invalid: state.isInvalid, hovered: !state.isDisabled && !state.isReadOnly && state.isHovered, pressed: !state.isDisabled && !state.isReadOnly && state.isPressed }), label] }) }), _jsx(FieldFeedback, { theme: theme, description: description, errorMessage: errorMessage })] });
10
+ });
package/dist/color.d.ts CHANGED
@@ -1,11 +1,23 @@
1
+ import type { AppearanceColor } from "@phreshos/core";
1
2
  /** A visual treatment derived from one concrete CSS color. */
2
3
  export type ColorLevel = "subtle" | "soft" | "base" | "strong" | "intense";
3
4
  /** Every visual treatment derived from one concrete CSS color. */
4
5
  export type ColorScale = Readonly<Record<ColorLevel, string>>;
6
+ /** One Appearance color and resting level, or a direct CSS color. */
7
+ export type Color = `${AppearanceColor}:${ColorLevel}` | (string & {});
8
+ export declare const defaultColor = "background:base";
9
+ /** Distinguishes derived color levels from direct CSS colors. */
10
+ export declare function isColorLevel(value: string | undefined): value is ColorLevel;
5
11
  /** Derives visual treatments while preserving the concrete value at `base`. */
6
12
  export declare function color(value: string): ColorScale;
13
+ /** Resolves the same named treatment for solid paint and contrast calculations. */
14
+ export declare function resolveColorLevel(value: string, level: ColorLevel): string;
7
15
  /** Returns the complete visual treatments derived from one concrete color. */
8
16
  export declare function useColor(value: string): ColorScale;
17
+ /** Resolves a semantic Appearance color or preserves a direct CSS color. */
18
+ export declare function useResolveColor(value?: Color): string;
19
+ /** Resolves a color to concrete opaque paint for solid interactive states. */
20
+ export declare function useResolveSolidColor(value: Color): string;
9
21
  /** Applies material opacity without restricting the source CSS color syntax. */
10
22
  export declare function colorOpacity(value: string, opacity: number): string;
11
23
  /** Reads perceptual lightness from a resolved CSS color. */
@@ -15,3 +27,24 @@ export declare function orderColors(first: string, second: string): {
15
27
  lighter: string;
16
28
  darker: string;
17
29
  };
30
+ /** Solid controls use opaque, gamut-mapped colors for both paint and contrast. */
31
+ export declare function opaqueColor(value: string): string;
32
+ /** Changes only perceptual lightness; theme names never determine direction. */
33
+ export declare function colorShade(value: string, amount: number): string;
34
+ /** Chooses only from the two Appearance colors, against the actual painted fill. */
35
+ export declare function onColor(fill: string, background: string, foreground: string): string;
36
+ /** Solid controls start at base; interaction shades preserve its text choice. */
37
+ export declare function solidColors(base: string, background: string, foreground: string): {
38
+ rest: {
39
+ background: string;
40
+ color: string;
41
+ };
42
+ hover: {
43
+ background: string;
44
+ color: string;
45
+ };
46
+ pressed: {
47
+ background: string;
48
+ color: string;
49
+ };
50
+ };
package/dist/color.js CHANGED
@@ -1,22 +1,59 @@
1
1
  import { useMemo } from "react";
2
- import { ColorSpace, parse, sRGB, sRGB_Linear, HSL, HWB, Lab, LCH, OKLab, OKLCH, P3, A98RGB, ProPhoto, REC_2020, XYZ_D50, XYZ_D65 } from "colorjs.io/fn";
2
+ import { ColorSpace, mix, parse, serialize, to, toGamut, contrastWCAG21, sRGB, sRGB_Linear, HSL, HWB, Lab, LCH, OKLab, OKLCH, P3, A98RGB, ProPhoto, REC_2020, XYZ_D50, XYZ_D65 } from "colorjs.io/fn";
3
+ import { useAppearance, useResolveTheme } from "./appearance-provider.js";
3
4
  // Register the CSS color spaces, without bundling unrelated color-model APIs.
4
5
  for (const space of [sRGB, sRGB_Linear, HSL, HWB, Lab, LCH, OKLab, OKLCH, P3, A98RGB, ProPhoto, REC_2020, XYZ_D50, XYZ_D65])
5
6
  ColorSpace.register(space);
7
+ export const defaultColor = "background:base";
8
+ const treatments = {
9
+ subtle: { weight: 25, target: "white" },
10
+ soft: { weight: 60, target: "white" },
11
+ strong: { weight: 82, target: "black" },
12
+ intense: { weight: 68, target: "black" }
13
+ };
14
+ /** Distinguishes derived color levels from direct CSS colors. */
15
+ export function isColorLevel(value) {
16
+ return value === "subtle" || value === "soft" || value === "base" || value === "strong" || value === "intense";
17
+ }
6
18
  /** Derives visual treatments while preserving the concrete value at `base`. */
7
19
  export function color(value) {
20
+ const shade = (level) => {
21
+ const { weight, target } = treatments[level];
22
+ return `color-mix(in oklch, ${value} ${weight}%, ${target})`;
23
+ };
8
24
  return Object.freeze({
9
- subtle: `color-mix(in oklch, ${value} 25%, white)`,
10
- soft: `color-mix(in oklch, ${value} 60%, white)`,
25
+ subtle: shade("subtle"),
26
+ soft: shade("soft"),
11
27
  base: value,
12
- strong: `color-mix(in oklch, ${value} 82%, black)`,
13
- intense: `color-mix(in oklch, ${value} 68%, black)`
28
+ strong: shade("strong"),
29
+ intense: shade("intense")
14
30
  });
15
31
  }
32
+ /** Resolves the same named treatment for solid paint and contrast calculations. */
33
+ export function resolveColorLevel(value, level) {
34
+ if (level === "base")
35
+ return opaqueColor(value);
36
+ const { weight, target } = treatments[level];
37
+ return opaqueColor(serialize(mix(opaqueColor(value), target, 1 - weight / 100, { space: "oklch" })));
38
+ }
16
39
  /** Returns the complete visual treatments derived from one concrete color. */
17
40
  export function useColor(value) {
18
41
  return useMemo(() => color(value), [value]);
19
42
  }
43
+ /** Resolves a semantic Appearance color or preserves a direct CSS color. */
44
+ export function useResolveColor(value = defaultColor) {
45
+ const appearance = useAppearance();
46
+ const semantic = parseSemanticColor(value);
47
+ const source = useResolveTheme(appearance.colors[semantic?.name ?? "background"]);
48
+ return semantic ? color(source)[semantic.level] : value;
49
+ }
50
+ /** Resolves a color to concrete opaque paint for solid interactive states. */
51
+ export function useResolveSolidColor(value) {
52
+ const appearance = useAppearance();
53
+ const semantic = parseSemanticColor(value);
54
+ const source = useResolveTheme(appearance.colors[semantic?.name ?? "background"]);
55
+ return semantic ? resolveColorLevel(source, semantic.level) : opaqueColor(value);
56
+ }
20
57
  /** Applies material opacity without restricting the source CSS color syntax. */
21
58
  export function colorOpacity(value, opacity) {
22
59
  const percentage = Math.round(opacity * 10_000) / 100;
@@ -34,3 +71,45 @@ export function orderColors(first, second) {
34
71
  ? { lighter: first, darker: second }
35
72
  : { lighter: second, darker: first };
36
73
  }
74
+ /** Solid controls use opaque, gamut-mapped colors for both paint and contrast. */
75
+ export function opaqueColor(value) {
76
+ return serialize(toGamut(to({ ...parse(value), alpha: 1 }, "srgb")), { format: "rgb", precision: 6 });
77
+ }
78
+ /** Changes only perceptual lightness; theme names never determine direction. */
79
+ export function colorShade(value, amount) {
80
+ const result = to(value, "oklch");
81
+ const lightness = result.coords[0] ?? 0;
82
+ result.coords[0] = Math.max(0, Math.min(1, lightness + (lightness > 0.6 ? -amount : amount)));
83
+ return opaqueColor(serialize(result));
84
+ }
85
+ /** Chooses only from the two Appearance colors, against the actual painted fill. */
86
+ export function onColor(fill, background, foreground) {
87
+ const first = opaqueColor(background);
88
+ const second = opaqueColor(foreground);
89
+ return contrastWCAG21(fill, first) > contrastWCAG21(fill, second) ? first : second;
90
+ }
91
+ /** Solid controls start at base; interaction shades preserve its text choice. */
92
+ export function solidColors(base, background, foreground) {
93
+ const fill = resolveColorLevel(base, "base");
94
+ const color = onColor(fill, background, foreground);
95
+ const paint = (background) => ({ background, color });
96
+ return {
97
+ rest: paint(fill),
98
+ hover: paint(colorShade(fill, 0.045)),
99
+ pressed: paint(colorShade(fill, 0.085))
100
+ };
101
+ }
102
+ function parseSemanticColor(value) {
103
+ const separator = value.indexOf(":");
104
+ if (separator < 0 || value.indexOf(":", separator + 1) >= 0)
105
+ return null;
106
+ const name = value.slice(0, separator);
107
+ const level = value.slice(separator + 1);
108
+ if (!isAppearanceColor(name) || !isColorLevel(level))
109
+ return null;
110
+ return { name, level };
111
+ }
112
+ function isAppearanceColor(value) {
113
+ return value === "background" || value === "foreground" || value === "primary" || value === "secondary"
114
+ || value === "success" || value === "warning" || value === "danger" || value === "info";
115
+ }
@@ -0,0 +1,20 @@
1
+ import type { ComponentPropsWithRef, CSSProperties, ReactNode } from "react";
2
+ import type { MaterialOptions } from "./material-options.js";
3
+ type Paint = Readonly<{
4
+ background: string;
5
+ color: string;
6
+ }>;
7
+ /** A native button uses Surface without changing its native contract. */
8
+ export declare function SurfaceButton({ native, paint, material: options }: {
9
+ readonly native: ComponentPropsWithRef<"button">;
10
+ readonly paint: Paint;
11
+ readonly material?: MaterialOptions;
12
+ }): import("react").JSX.Element;
13
+ /** Void text controls use a material host while retaining native sizing. */
14
+ export declare function SurfaceField({ paint, radius, children, material: options }: Readonly<{
15
+ paint: Paint;
16
+ radius: CSSProperties["borderRadius"];
17
+ children: ReactNode;
18
+ material?: MaterialOptions;
19
+ }>): import("react").JSX.Element;
20
+ export {};
@@ -0,0 +1,11 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Surface } from "./surface.js";
3
+ /** A native button uses Surface without changing its native contract. */
4
+ export function SurfaceButton({ native, paint, material: options }) {
5
+ const { ref, style, children, ...properties } = native;
6
+ return _jsx(Surface, { ...properties, ...options, as: "button", color: paint.background, ref: ref, style: { ...style, color: paint.color }, children: children });
7
+ }
8
+ /** Void text controls use a material host while retaining native sizing. */
9
+ export function SurfaceField({ paint, radius, children, material: options }) {
10
+ return _jsx(Surface, { ...options, as: "span", color: paint.background, radius: radius, style: { color: paint.color, display: "grid", minWidth: 0 }, children: children });
11
+ }