@phreshos/react-ui 0.1.17 → 0.1.19

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 (43) hide show
  1. package/README.md +33 -33
  2. package/dist/appearance-context.d.ts +18 -0
  3. package/dist/appearance-context.js +37 -0
  4. package/dist/appearance-provider.d.ts +2 -7
  5. package/dist/appearance-provider.js +3 -38
  6. package/dist/button.d.ts +1 -1
  7. package/dist/button.js +3 -4
  8. package/dist/checkbox.d.ts +1 -1
  9. package/dist/color.js +3 -3
  10. package/dist/{control-material.d.ts → control-surface.d.ts} +3 -3
  11. package/dist/control-surface.js +11 -0
  12. package/dist/control.d.ts +1 -0
  13. package/dist/control.js +10 -7
  14. package/dist/document-scrollbars.js +2 -2
  15. package/dist/input.js +2 -2
  16. package/dist/main.d.ts +2 -3
  17. package/dist/main.js +1 -2
  18. package/dist/material-options.d.ts +5 -1
  19. package/dist/material-options.js +3 -3
  20. package/dist/material-paint.d.ts +1 -1
  21. package/dist/material-paint.js +3 -2
  22. package/dist/motion-style.d.ts +11 -19
  23. package/dist/motion-style.js +59 -20
  24. package/dist/panel.js +2 -2
  25. package/dist/radio.d.ts +1 -1
  26. package/dist/radius.d.ts +3 -2
  27. package/dist/radius.js +1 -1
  28. package/dist/select.d.ts +1 -1
  29. package/dist/select.js +7 -5
  30. package/dist/slider.js +5 -4
  31. package/dist/spacing.js +1 -1
  32. package/dist/surface-edge.d.ts +5 -2
  33. package/dist/surface-edge.js +2 -1
  34. package/dist/surface.d.ts +21 -5
  35. package/dist/surface.js +53 -11
  36. package/dist/switch.d.ts +1 -1
  37. package/dist/text-control.d.ts +1 -1
  38. package/dist/textarea.js +2 -2
  39. package/dist/toggle-indicator.js +11 -13
  40. package/package.json +6 -7
  41. package/dist/control-material.js +0 -14
  42. package/dist/material.d.ts +0 -27
  43. package/dist/material.js +0 -45
package/README.md CHANGED
@@ -54,9 +54,9 @@ import { AppearanceProvider, Button } from "@phreshos/react-ui"
54
54
  ```
55
55
 
56
56
  `Button`, `Input`, `Textarea`, `Select`, `Checkbox`, `Switch`, and `Radio`
57
- host the shared Material while retaining their native behavior. Color and material
57
+ use the shared Surface implementation while retaining their native behavior. Color and material
58
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. Material and neutral
59
+ as `background:base` or `primary:soft`, or a direct CSS color. Surface and neutral
60
60
  surface hosts default to `background:base`; a component may select a semantic
61
61
  default required by its own behavior, such as `primary:base` for selection.
62
62
  Hover and press derive shades of that fill. The resting fill chooses the
@@ -72,40 +72,34 @@ spacing and radius follow Appearance. `disabled` prevents activation and focus;
72
72
  <Button color="danger:soft" size="small">Delete</Button>
73
73
  ```
74
74
 
75
- `Material` is the raw visual substance: paint, opacity, frost, refraction, and
76
- grain. It fills the geometry of its nearest positioned container,
77
- inherits that container's radius, and adds no content, layout, interaction, or
78
- shadow. The container must establish its own geometry and isolation.
79
-
80
- ```tsx
81
- <div style={{ position: "relative", isolation: "isolate", width: 240, height: 120, borderRadius: 18 }}>
82
- <Material color="background:soft" material={{ opacity: 0.4 }} />
83
- </div>
84
- ```
85
-
86
- `Surface` is the standard geometric host for a `Material`. It establishes the
87
- required positioning and isolation on a `div`, resolves radius, renders Material,
88
- and then renders its content. It never creates or consumes a shadow. Shadow remains
89
- an independent visual concern. `radius` accepts a size level, a number in pixels,
90
- or a CSS radius and defaults to `medium`.
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`.
91
81
 
92
82
  ```tsx
93
83
  <Surface color="background:soft" radius="large">Derived values</Surface>
94
- <Surface color="#345678" radius={18}>Direct 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>
95
86
  ```
96
87
 
97
- `MaterialOptions` defines `opacity`, `backdrop`, `grain`, `grainAmount`,
98
- `distortion`, and `saturation`. It contains no color. `MaterialProps` keeps
99
- `color` beside `material?: MaterialOptions`, so paint selection never becomes a
100
- physical-material setting. Omitted material values follow `appearance.material`.
101
- Effect options accept a scale level or a direct number; opacity affects material
102
- only, never the host's children.
103
-
104
- Radius belongs to host geometry, while Surface's border belongs to its geometric
105
- boundary. Material may determine the border treatment, but it neither owns nor
106
- renders that border.
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.
107
93
 
108
- Material-bearing controls expose the same separate `color` and `material` props.
94
+ `MaterialOptions` defines `opacity`, `backdrop`, `grain`, `grainAmount`,
95
+ `distortion`, and `saturation`. Every material-bearing component, including
96
+ Surface, exposes these values through its `material` prop. Color remains a
97
+ separate 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.
109
103
  For text fields and Select, `material` targets the field or trigger; for Checkbox,
110
104
  Switch, and Radio, it targets the indicator. RadioGroup supplies material defaults
111
105
  to its options, and a Radio can override them.
@@ -145,11 +139,11 @@ Theme names never imply particular colors. No component requires a Client or
145
139
  Server SDK.
146
140
 
147
141
  Fields distinguish hover, pointer focus, keyboard focus, and invalid state.
148
- Shared CSS transitions use a fixed 120ms ease-out timing for colors and corner
149
- radius. Material fill and opacity transition on the painted layers, not on the
142
+ Shared CSS transitions use `appearance.transaction` for colors and corner
143
+ radius. The material fill and opacity values transition on the painted layers, not on the
150
144
  Surface host. Select menus combine a small placement-aware slide with an overlay
151
145
  opacity fade for entry and exit, using React Aria's animation lifecycle. Reduced-motion preferences make
152
- these changes immediate. No Appearance configuration is added for motion yet.
146
+ these changes immediate without changing the Appearance value.
153
147
  Blur, distortion, geometry, and Surface host opacity are not transitioned; gradients
154
148
  and structurally removed effects change directly rather than adding extra
155
149
  layers or keeping disabled effects alive.
@@ -222,6 +216,12 @@ bun run verify
222
216
  `verify` checks the contracts, tests the components, builds the package, and
223
217
  validates its published shape.
224
218
 
219
+ `check` performs static checks, `build` creates distributable output, and `test`
220
+ runs Vitest assertions from `tests/`. Run `build` before testing built artifacts.
221
+ `verify` runs `check`, `build`, and `test` in order. Operational tooling belongs
222
+ in `scripts/`; tests and their fixtures belong in `tests/`. Verification uses
223
+ the committed dependency graph without local package substitutions.
224
+
225
225
  ## Related repositories
226
226
 
227
227
  - [`@phreshos/core`](https://github.com/PhreshOS/core) owns Appearance, Theme,
@@ -0,0 +1,18 @@
1
+ import { type Appearance, type Theme, type ThemedValue } from "@phreshos/core";
2
+ export declare const AppearanceContext: import("react").Context<Readonly<{
3
+ colors: ThemedValue<import("@phreshos/core").AppearanceColors>;
4
+ spacing: number;
5
+ radius: number;
6
+ shadow: ThemedValue<import("@phreshos/core").AppearanceShadow>;
7
+ material: ThemedValue<import("@phreshos/core").AppearanceMaterial>;
8
+ transaction: import("@phreshos/core").AppearanceTransaction;
9
+ signInWallpaper: ThemedValue<string | null>;
10
+ desktopWallpaper: ThemedValue<string | null>;
11
+ }>>;
12
+ export declare const ThemeContext: import("react").Context<Theme | null>;
13
+ /** Returns the nearest unresolved Appearance, or Core's complete default. */
14
+ export declare function useAppearance(): Appearance;
15
+ /** Returns the nearest explicit Theme, or reactively follows the browser. */
16
+ export declare function useTheme(): Theme;
17
+ /** Selects the active branch of one complete ThemedValue. */
18
+ export declare function useThemedValue<Value>(value: ThemedValue<Value>): Value;
@@ -0,0 +1,37 @@
1
+ import { createContext, useContext, useSyncExternalStore } from "react";
2
+ import { defaultAppearance } from "@phreshos/core";
3
+ export const AppearanceContext = createContext(defaultAppearance);
4
+ export const ThemeContext = createContext(null);
5
+ /** Returns the nearest unresolved Appearance, or Core's complete default. */
6
+ export function useAppearance() {
7
+ return useContext(AppearanceContext);
8
+ }
9
+ /** Returns the nearest explicit Theme, or reactively follows the browser. */
10
+ export function useTheme() {
11
+ const theme = useContext(ThemeContext);
12
+ const browserTheme = useBrowserTheme();
13
+ return theme ?? browserTheme;
14
+ }
15
+ /** Selects the active branch of one complete ThemedValue. */
16
+ export function useThemedValue(value) {
17
+ return value[useTheme()];
18
+ }
19
+ function useBrowserTheme() {
20
+ return useSyncExternalStore(subscribeBrowserTheme, browserTheme, serverTheme);
21
+ }
22
+ function subscribeBrowserTheme(change) {
23
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function")
24
+ return () => undefined;
25
+ const preference = window.matchMedia(darkThemeQuery);
26
+ preference.addEventListener("change", change);
27
+ return () => preference.removeEventListener("change", change);
28
+ }
29
+ function browserTheme() {
30
+ return typeof window !== "undefined"
31
+ && typeof window.matchMedia === "function"
32
+ && window.matchMedia(darkThemeQuery).matches
33
+ ? "dark"
34
+ : "light";
35
+ }
36
+ function serverTheme() { return "light"; }
37
+ const darkThemeQuery = "(prefers-color-scheme: dark)";
@@ -1,13 +1,8 @@
1
1
  import type { ReactNode } from "react";
2
- import { type Appearance, type Theme, type ThemedValue } from "@phreshos/core";
2
+ import { type Appearance, type Theme } from "@phreshos/core";
3
+ export { useAppearance, useTheme, useThemedValue } from "./appearance-context.js";
3
4
  /** Optionally overrides unresolved Appearance and effective Theme for a React subtree. */
4
5
  export declare function AppearanceProvider({ appearance, children, theme }: AppearanceProviderProps): import("react").JSX.Element;
5
- /** Returns the nearest unresolved Appearance, or Core's complete default. */
6
- export declare function useAppearance(): Appearance;
7
- /** Returns the nearest explicit Theme, or reactively follows the browser. */
8
- export declare function useTheme(): Theme;
9
- /** Resolves one themed value only where it is consumed. */
10
- export declare function useResolveTheme<Value, DarkValue extends Value = never>(value: ThemedValue<Value, DarkValue>): Exclude<Value, undefined>;
11
6
  export interface AppearanceProviderProps {
12
7
  readonly appearance?: Appearance;
13
8
  readonly children: ReactNode;
@@ -1,10 +1,9 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { createContext, useContext, useSyncExternalStore } from "react";
3
- import { defaultAppearance } from "@phreshos/core";
2
+ import {} from "@phreshos/core";
4
3
  import DocumentScrollbars from "./document-scrollbars.js";
5
4
  import MotionStyle from "./motion-style.js";
6
- const AppearanceContext = createContext(defaultAppearance);
7
- const ThemeContext = createContext(null);
5
+ import { AppearanceContext, ThemeContext, useAppearance, useTheme } from "./appearance-context.js";
6
+ export { useAppearance, useTheme, useThemedValue } from "./appearance-context.js";
8
7
  /** Optionally overrides unresolved Appearance and effective Theme for a React subtree. */
9
8
  export function AppearanceProvider({ appearance, children, theme }) {
10
9
  const inheritedAppearance = useAppearance();
@@ -13,37 +12,3 @@ export function AppearanceProvider({ appearance, children, theme }) {
13
12
  const resolvedTheme = theme ?? inheritedTheme;
14
13
  return _jsx(AppearanceContext.Provider, { value: resolvedAppearance, children: _jsxs(ThemeContext.Provider, { value: resolvedTheme, children: [_jsx(MotionStyle, {}), _jsx(DocumentScrollbars, { appearance: resolvedAppearance, theme: resolvedTheme }), children] }) });
15
14
  }
16
- /** Returns the nearest unresolved Appearance, or Core's complete default. */
17
- export function useAppearance() {
18
- return useContext(AppearanceContext);
19
- }
20
- /** Returns the nearest explicit Theme, or reactively follows the browser. */
21
- export function useTheme() {
22
- const theme = useContext(ThemeContext);
23
- const browserTheme = useBrowserTheme();
24
- return theme ?? browserTheme;
25
- }
26
- /** Resolves one themed value only where it is consumed. */
27
- export function useResolveTheme(value) {
28
- const theme = useTheme();
29
- return (theme === "dark" && "dark" in value ? value.dark : value.light);
30
- }
31
- function useBrowserTheme() {
32
- return useSyncExternalStore(subscribeBrowserTheme, browserTheme, serverTheme);
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
@@ -3,7 +3,7 @@ import type { ButtonProps as AriaButtonProps } from "react-aria-components";
3
3
  import type { ScaleLevel } from "./scale.js";
4
4
  import type { RadiusProps } from "./radius.js";
5
5
  import { type ControlColor } from "./control.js";
6
- import type { MaterialOverrides } from "./material.js";
6
+ import type { MaterialOverrides } from "./material-options.js";
7
7
  type NativeButtonProps = Omit<AriaButtonProps, "children" | "className" | "color" | "isDisabled" | "isPending" | "onClick" | "onPress" | "style">;
8
8
  /** A semantic color from Appearance; omission uses background and foreground. */
9
9
  export type ButtonColor = ControlColor;
package/dist/button.js CHANGED
@@ -2,12 +2,11 @@ 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
4
  import { controlFontSizes, useControlTheme } from "./control.js";
5
- import { MaterialButton } from "./control-material.js";
6
- import { visualTransition } from "./motion-style.js";
5
+ import { SurfaceButton } from "./control-surface.js";
7
6
  /** An Appearance-aware action with normalized pointer and keyboard behavior. */
8
7
  export const Button = forwardRef(function Button({ children, color, disabled = false, pending = false, onPress, radius = "medium", size = "medium", style, material, type = "button", ...properties }, ref) {
9
8
  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(MaterialButton, { native: native, material: material, paint: buttonPaint(theme, !disabled && !pending, state.isHovered, state.isPressed) }), style: ({ isFocusVisible, isHovered, isPressed }) => buttonStyle({
9
+ 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
10
  theme,
12
11
  disabled,
13
12
  isFocusVisible,
@@ -25,7 +24,7 @@ function buttonStyle({ theme, disabled, isFocusVisible, isHovered, isPressed, pe
25
24
  const interactive = !disabled && !pending;
26
25
  const paint = buttonPaint(theme, interactive, isHovered, isPressed);
27
26
  return {
28
- ...visualTransition,
27
+ ...theme.transition,
29
28
  appearance: "none",
30
29
  boxSizing: "border-box",
31
30
  display: "inline-grid",
@@ -1,6 +1,6 @@
1
1
  import type { CheckboxFieldProps } from "react-aria-components";
2
2
  import type { ControlOverrides, ControlProps, FieldProps } from "./control.js";
3
- import type { MaterialOverrides } from "./material.js";
3
+ import type { MaterialOverrides } from "./material-options.js";
4
4
  export interface CheckboxProps extends Omit<CheckboxFieldProps, ControlOverrides | "isReadOnly" | "isSelected" | "defaultSelected" | "isIndeterminate">, ControlProps, FieldProps, MaterialOverrides {
5
5
  readonly checked?: boolean;
6
6
  readonly defaultChecked?: boolean;
package/dist/color.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { useMemo } from "react";
2
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
+ import { useAppearance, useThemedValue } from "./appearance-provider.js";
4
4
  // Register the CSS color spaces, without bundling unrelated color-model APIs.
5
5
  for (const space of [sRGB, sRGB_Linear, HSL, HWB, Lab, LCH, OKLab, OKLCH, P3, A98RGB, ProPhoto, REC_2020, XYZ_D50, XYZ_D65])
6
6
  ColorSpace.register(space);
@@ -44,14 +44,14 @@ export function useColor(value) {
44
44
  export function useResolveColor(value = defaultColor) {
45
45
  const appearance = useAppearance();
46
46
  const semantic = parseSemanticColor(value);
47
- const source = useResolveTheme(appearance.colors[semantic?.name ?? "background"]);
47
+ const source = useThemedValue(appearance.colors)[semantic?.name ?? "background"];
48
48
  return semantic ? color(source)[semantic.level] : value;
49
49
  }
50
50
  /** Resolves a color to concrete opaque paint for solid interactive states. */
51
51
  export function useResolveSolidColor(value) {
52
52
  const appearance = useAppearance();
53
53
  const semantic = parseSemanticColor(value);
54
- const source = useResolveTheme(appearance.colors[semantic?.name ?? "background"]);
54
+ const source = useThemedValue(appearance.colors)[semantic?.name ?? "background"];
55
55
  return semantic ? resolveColorLevel(source, semantic.level) : opaqueColor(value);
56
56
  }
57
57
  /** Applies material opacity without restricting the source CSS color syntax. */
@@ -4,14 +4,14 @@ type Paint = Readonly<{
4
4
  background: string;
5
5
  color: string;
6
6
  }>;
7
- /** A native button hosts Material without changing its native contract. */
8
- export declare function MaterialButton({ native, paint, material: options }: {
7
+ /** A native button uses Surface without changing its native contract. */
8
+ export declare function SurfaceButton({ native, paint, material: options }: {
9
9
  readonly native: ComponentPropsWithRef<"button">;
10
10
  readonly paint: Paint;
11
11
  readonly material?: MaterialOptions;
12
12
  }): import("react").JSX.Element;
13
13
  /** Void text controls use a material host while retaining native sizing. */
14
- export declare function MaterialField({ paint, radius, children, material: options }: Readonly<{
14
+ export declare function SurfaceField({ paint, radius, children, material: options }: Readonly<{
15
15
  paint: Paint;
16
16
  radius: CSSProperties["borderRadius"];
17
17
  children: ReactNode;
@@ -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, as: "button", color: paint.background, material: options, 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, { as: "span", color: paint.background, material: options, radius: radius, style: { color: paint.color, display: "grid", minWidth: 0 }, children: children });
11
+ }
package/dist/control.d.ts CHANGED
@@ -25,6 +25,7 @@ export type ControlOverrides = keyof ControlProps | keyof FieldProps | "children
25
25
  export declare const controlFontSizes: Readonly<Record<ScaleLevel, number>>;
26
26
  type SolidColors = ReturnType<typeof solidColors>;
27
27
  export interface ControlTheme {
28
+ readonly transition: CSSProperties;
28
29
  readonly spacing: number;
29
30
  readonly foreground: string;
30
31
  readonly background: string;
package/dist/control.js CHANGED
@@ -1,12 +1,12 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useMemo } from "react";
3
3
  import { FieldError, Label, Text } from "react-aria-components";
4
- import { useAppearance, useResolveTheme } from "./appearance-provider.js";
4
+ import { useAppearance, useThemedValue } from "./appearance-provider.js";
5
5
  import { resolveRadius } from "./radius.js";
6
6
  import { scale } from "./scale.js";
7
7
  import { solidColors } from "./color.js";
8
8
  import { useResolveSolidColor } from "./color.js";
9
- import { visualTransition } from "./motion-style.js";
9
+ import { useVisualTransition } from "./motion-style.js";
10
10
  export const controlFontSizes = Object.freeze({
11
11
  xsmall: 11,
12
12
  small: 12,
@@ -16,9 +16,11 @@ export const controlFontSizes = Object.freeze({
16
16
  });
17
17
  export function useControlTheme({ size = "medium", color, radius = "medium" }) {
18
18
  const appearance = useAppearance();
19
- const spacing = scale(useResolveTheme(appearance.spacing), size);
20
- const foreground = useResolveTheme(appearance.colors.foreground);
21
- const background = useResolveTheme(appearance.colors.background);
19
+ const transition = useVisualTransition();
20
+ const spacing = scale(appearance.spacing, size);
21
+ const colors = useThemedValue(appearance.colors);
22
+ const foreground = colors.foreground;
23
+ const background = colors.background;
22
24
  const tint = useResolveSolidColor(color ?? "primary:base");
23
25
  const danger = useResolveSolidColor("danger:base");
24
26
  const paints = useMemo(() => ({
@@ -27,6 +29,7 @@ export function useControlTheme({ size = "medium", color, radius = "medium" }) {
27
29
  danger: solidColors(danger, background, foreground)
28
30
  }), [tint, background, foreground, danger]);
29
31
  return {
32
+ transition,
30
33
  spacing,
31
34
  foreground,
32
35
  background,
@@ -43,7 +46,7 @@ export function useControlTheme({ size = "medium", color, radius = "medium" }) {
43
46
  export function fieldStyle(theme, disabled = false, style) {
44
47
  return {
45
48
  ...style,
46
- ...visualTransition,
49
+ ...theme.transition,
47
50
  display: "grid",
48
51
  gap: theme.gap,
49
52
  minWidth: 0,
@@ -61,7 +64,7 @@ export function controlPaint(theme, focused, invalid, hovered = false) {
61
64
  export function controlStyle(theme, focused, invalid, hovered = false, focusVisible = focused) {
62
65
  const paint = controlPaint(theme, focused, invalid, hovered);
63
66
  return {
64
- ...visualTransition,
67
+ ...theme.transition,
65
68
  appearance: "none",
66
69
  boxSizing: "border-box",
67
70
  width: "100%",
@@ -86,7 +86,7 @@ const documents = new WeakMap();
86
86
  /** Applies one Appearance to the complete owning document without rendering. */
87
87
  export default function DocumentScrollbars({ appearance, theme }) {
88
88
  const identity = useRef(Symbol("AppearanceProvider")).current;
89
- const foreground = theme === "dark" ? appearance.colors.foreground.dark : appearance.colors.foreground.light;
89
+ const foreground = appearance.colors[theme].foreground;
90
90
  const padding = 5;
91
91
  const thumbSize = 6;
92
92
  const size = thumbSize + padding * 2;
@@ -95,7 +95,7 @@ export default function DocumentScrollbars({ appearance, theme }) {
95
95
  thumbHover: colorOpacity(foreground, 0.2),
96
96
  size: `${size}px`,
97
97
  padding: `${padding}px`,
98
- radius: `${Math.min(appearance.radius.light, padding + thumbSize / 2)}px`
98
+ radius: `${Math.min(appearance.radius, padding + thumbSize / 2)}px`
99
99
  };
100
100
  useInsertionEffect(function () {
101
101
  if (typeof document === "undefined")
package/dist/input.js CHANGED
@@ -2,10 +2,10 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { forwardRef } from "react";
3
3
  import { Input as AriaInput, TextField } from "react-aria-components";
4
4
  import { controlPaint, controlStyle, FieldFeedback, FieldLabel, fieldStyle, useControlTheme } from "./control.js";
5
- import { MaterialField } from "./control-material.js";
5
+ import { SurfaceField } from "./control-surface.js";
6
6
  import FieldStyle, { textControlClass } from "./field-style.js";
7
7
  /** A labeled single-line text control. onChange receives the string value. */
8
8
  export const Input = forwardRef(function Input({ label, description, errorMessage, disabled, readOnly, required, invalid, size, color, radius, style, className, placeholder, material, ...properties }, ref) {
9
9
  const theme = useControlTheme({ size, color, radius });
10
- return _jsxs(TextField, { ...properties, className: className, isDisabled: disabled, isReadOnly: readOnly, isRequired: required, isInvalid: invalid, style: fieldStyle(theme, disabled, style), children: [_jsx(FieldStyle, {}), _jsx(FieldLabel, { label: label }), _jsx(AriaInput, { ref: ref, placeholder: placeholder, className: textControlClass, render: (native, state) => _jsx(MaterialField, { material: material, radius: theme.radius, paint: controlPaint(theme, state.isFocused, state.isInvalid, state.isHovered), children: _jsx("input", { ...native, style: { ...native.style, borderRadius: "inherit", background: "transparent" } }) }), style: state => controlStyle(theme, state.isFocused, state.isInvalid, state.isHovered, state.isFocusVisible) }), _jsx(FieldFeedback, { theme: theme, description: description, errorMessage: errorMessage })] });
10
+ return _jsxs(TextField, { ...properties, className: className, isDisabled: disabled, isReadOnly: readOnly, isRequired: required, isInvalid: invalid, style: fieldStyle(theme, disabled, style), children: [_jsx(FieldStyle, {}), _jsx(FieldLabel, { label: label }), _jsx(AriaInput, { ref: ref, placeholder: placeholder, className: textControlClass, render: (native, state) => _jsx(SurfaceField, { material: material, radius: theme.radius, paint: controlPaint(theme, state.isFocused, state.isInvalid, state.isHovered), children: _jsx("input", { ...native, style: { ...native.style, borderRadius: "inherit", background: "transparent" } }) }), style: state => controlStyle(theme, state.isFocused, state.isInvalid, state.isHovered, state.isFocusVisible) }), _jsx(FieldFeedback, { theme: theme, description: description, errorMessage: errorMessage })] });
11
11
  });
package/dist/main.d.ts CHANGED
@@ -5,11 +5,10 @@
5
5
  * behavior contract has been established by the package's tests.
6
6
  */
7
7
  export { defaultAppearance } from "@phreshos/core";
8
- export { AppearanceProvider, useAppearance, useResolveTheme, useTheme, type AppearanceProviderProps } from "./appearance-provider.js";
8
+ export { AppearanceProvider, useAppearance, useThemedValue, useTheme, type AppearanceProviderProps } from "./appearance-provider.js";
9
9
  export { Flex, type FlexProps } from "./flex.js";
10
10
  export { Grid, type GridProps } from "./grid.js";
11
- export { Material, type MaterialOptions, type MaterialOverrides, type MaterialProps } from "./material.js";
12
- export { Surface, type SurfaceProps } from "./surface.js";
11
+ export { Surface, type MaterialOptions, type SurfaceComponent, type SurfaceHost, type SurfaceHostProps, type SurfaceOwnProps, type SurfaceProps } from "./surface.js";
13
12
  export { defaultColor, type Color, type ColorLevel, type ColorScale } from "./color.js";
14
13
  export { Panel, type PanelProps } from "./panel.js";
15
14
  export { Button, type ButtonColor, type ButtonProps } from "./button.js";
package/dist/main.js CHANGED
@@ -5,10 +5,9 @@
5
5
  * behavior contract has been established by the package's tests.
6
6
  */
7
7
  export { defaultAppearance } from "@phreshos/core";
8
- export { AppearanceProvider, useAppearance, useResolveTheme, useTheme } from "./appearance-provider.js";
8
+ export { AppearanceProvider, useAppearance, useThemedValue, useTheme } from "./appearance-provider.js";
9
9
  export { Flex } from "./flex.js";
10
10
  export { Grid } from "./grid.js";
11
- export { Material } from "./material.js";
12
11
  export { Surface } from "./surface.js";
13
12
  export { defaultColor } from "./color.js";
14
13
  export { Panel } from "./panel.js";
@@ -8,7 +8,11 @@ export interface MaterialOptions {
8
8
  readonly distortion?: ScaleLevel | number;
9
9
  readonly saturation?: ScaleLevel | number;
10
10
  }
11
- /** Resolves Material values from the current Appearance without knowing any host geometry. */
11
+ /** Grouped material customization exposed by components built on Surface. */
12
+ export interface MaterialOverrides {
13
+ readonly material?: MaterialOptions;
14
+ }
15
+ /** Resolves material values from the current Appearance without knowing any host geometry. */
12
16
  export declare function useMaterialOptions(values?: MaterialOptions): {
13
17
  opacity: number;
14
18
  backdrop: number;
@@ -1,10 +1,10 @@
1
1
  import { appearanceLimits } from "@phreshos/core";
2
- import { useAppearance, useResolveTheme } from "./appearance-provider.js";
2
+ import { useAppearance, useThemedValue } from "./appearance-provider.js";
3
3
  import { isScaleLevel, scale, scaleMultiplier } from "./scale.js";
4
- /** Resolves Material values from the current Appearance without knowing any host geometry. */
4
+ /** Resolves material values from the current Appearance without knowing any host geometry. */
5
5
  export function useMaterialOptions(values = {}) {
6
6
  const appearance = useAppearance();
7
- const defaults = useResolveTheme(appearance.material);
7
+ const defaults = useThemedValue(appearance.material);
8
8
  const limits = appearanceLimits.material;
9
9
  return {
10
10
  opacity: resolve(values.opacity, defaults.opacity, limits.opacity),
@@ -6,6 +6,6 @@ interface MaterialPaintProps {
6
6
  readonly identity: string;
7
7
  readonly opacity: number;
8
8
  }
9
- /** The fill, refraction definition, and grain of one Material. */
9
+ /** The fill, refraction definition, and grain of one Surface. */
10
10
  export declare function MaterialPaint({ color, distortion, grain, grainAmount, identity, opacity }: MaterialPaintProps): import("react").JSX.Element | null;
11
11
  export {};
@@ -1,8 +1,9 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useMemo } from "react";
3
- import { paintTransition } from "./motion-style.js";
4
- /** The fill, refraction definition, and grain of one Material. */
3
+ import { usePaintTransition } from "./motion-style.js";
4
+ /** The fill, refraction definition, and grain of one Surface. */
5
5
  export function MaterialPaint({ color, distortion, grain, grainAmount, identity, opacity }) {
6
+ const paintTransition = usePaintTransition();
6
7
  const seed = useMemo(() => seedFrom(identity), [identity]);
7
8
  const hasPaint = opacity > 0;
8
9
  const hasGrain = hasPaint && grain > 0 && grainAmount > 0;
@@ -1,21 +1,13 @@
1
- /** The same fixed timing for Motion-driven control movements. */
2
- export declare const controlTransition: {
3
- readonly type: "tween";
4
- readonly duration: 0.12;
5
- readonly ease: "easeOut";
6
- };
7
- /** Host opacity and filters are excluded: either can change backdrop sampling. */
8
- export declare const visualTransition: {
9
- transitionProperty: "background-color, color, border-color, border-radius";
10
- transitionDuration: "var(--phreshos-ui-motion-duration, 120ms)";
11
- transitionTimingFunction: "ease-out";
12
- };
13
- /** Radius is inherited from the host; only the painted values transition here. */
14
- export declare const paintTransition: {
15
- transitionProperty: "fill, stroke, opacity";
16
- transitionDuration: "var(--phreshos-ui-motion-duration, 120ms)";
17
- transitionTimingFunction: "ease-out";
18
- };
1
+ import type { CSSProperties } from "react";
2
+ import type { Transition } from "motion/react";
3
+ /** CSS timing for values painted directly by React UI. */
4
+ export declare function useVisualTransition(): CSSProperties;
5
+ /** CSS timing for SVG paint owned by a Material. */
6
+ export declare function usePaintTransition(): CSSProperties;
7
+ /** Motion timing for interactive geometry owned by React UI. */
8
+ export declare function useControlTransition(reduced?: boolean): Transition;
9
+ /** Per-overlay variables consumed by the shared entrance keyframes. */
10
+ export declare function useOverlayTransition(): CSSProperties;
19
11
  export declare const overlayMotionClass = "phreshos-ui-overlay";
20
- /** React hoists and deduplicates this stylesheet, including nested providers and portals. */
12
+ /** React hoists and deduplicates this stylesheet, including portals. */
21
13
  export default function MotionStyle(): import("react").JSX.Element;
@@ -1,33 +1,72 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- const timing = {
3
- transitionDuration: "var(--phreshos-ui-motion-duration, 120ms)",
4
- transitionTimingFunction: "ease-out"
5
- };
6
- /** The same fixed timing for Motion-driven control movements. */
7
- export const controlTransition = { type: "tween", duration: 0.12, ease: "easeOut" };
8
- /** Host opacity and filters are excluded: either can change backdrop sampling. */
9
- export const visualTransition = {
10
- ...timing,
11
- transitionProperty: "background-color, color, border-color, border-radius"
12
- };
13
- /** Radius is inherited from the host; only the painted values transition here. */
14
- export const paintTransition = {
15
- ...timing,
16
- transitionProperty: "fill, stroke, opacity"
17
- };
2
+ import { defaultAppearance } from "@phreshos/core";
3
+ import { useAppearance } from "./appearance-context.js";
4
+ const visualProperties = "background-color, color, border-color, border-radius";
5
+ const paintProperties = "fill, stroke, opacity";
6
+ /** CSS timing for values painted directly by React UI. */
7
+ export function useVisualTransition() {
8
+ return { ...useTransitionTiming(), transitionProperty: visualProperties };
9
+ }
10
+ /** CSS timing for SVG paint owned by a Material. */
11
+ export function usePaintTransition() {
12
+ return { ...useTransitionTiming(), transitionProperty: paintProperties };
13
+ }
14
+ /** Motion timing for interactive geometry owned by React UI. */
15
+ export function useControlTransition(reduced = false) {
16
+ const { duration, easing } = useAppearance().transaction;
17
+ return {
18
+ type: "tween",
19
+ duration: reduced ? 0 : duration / 1_000,
20
+ ease: motionEasing(easing)
21
+ };
22
+ }
23
+ /** Per-overlay variables consumed by the shared entrance keyframes. */
24
+ export function useOverlayTransition() {
25
+ const { duration, easing } = useAppearance().transaction;
26
+ return {
27
+ "--phreshos-ui-motion-duration": `${duration}ms`,
28
+ "--phreshos-ui-motion-easing": cssEasing(easing)
29
+ };
30
+ }
18
31
  export const overlayMotionClass = "phreshos-ui-overlay";
32
+ function useTransitionTiming() {
33
+ const { duration, easing } = useAppearance().transaction;
34
+ return {
35
+ transitionDuration: `${duration}ms`,
36
+ transitionTimingFunction: cssEasing(easing)
37
+ };
38
+ }
39
+ function cssEasing(easing) {
40
+ return typeof easing === "string" ? easing : `cubic-bezier(${easing.join(", ")})`;
41
+ }
42
+ function motionEasing(easing) {
43
+ if (typeof easing !== "string")
44
+ return [...easing];
45
+ if (easing === "ease")
46
+ return [0.25, 0.1, 0.25, 1];
47
+ if (easing === "ease-in")
48
+ return "easeIn";
49
+ if (easing === "ease-out")
50
+ return "easeOut";
51
+ if (easing === "ease-in-out")
52
+ return "easeInOut";
53
+ return easing;
54
+ }
19
55
  const stylesheet = `
20
- :root { --phreshos-ui-motion-duration: 120ms; }
56
+ :root {
57
+ --phreshos-ui-motion-duration: ${defaultAppearance.transaction.duration}ms;
58
+ --phreshos-ui-motion-easing: ${cssEasing(defaultAppearance.transaction.easing)};
59
+ }
21
60
 
22
61
  .phreshos-ui-overlay { --phreshos-ui-overlay-x: 0px; --phreshos-ui-overlay-y: -4px; }
23
62
  .phreshos-ui-overlay[data-placement="top"] { --phreshos-ui-overlay-y: 4px; }
24
63
  .phreshos-ui-overlay[data-placement="left"] { --phreshos-ui-overlay-x: 4px; --phreshos-ui-overlay-y: 0px; }
25
64
  .phreshos-ui-overlay[data-placement="right"] { --phreshos-ui-overlay-x: -4px; --phreshos-ui-overlay-y: 0px; }
26
65
  .phreshos-ui-overlay[data-entering] {
27
- animation: phreshos-ui-overlay-enter var(--phreshos-ui-motion-duration, 120ms) ease-out both;
66
+ animation: phreshos-ui-overlay-enter var(--phreshos-ui-motion-duration) var(--phreshos-ui-motion-easing) both;
28
67
  }
29
68
  .phreshos-ui-overlay[data-exiting] {
30
- animation: phreshos-ui-overlay-enter var(--phreshos-ui-motion-duration, 120ms) ease-out reverse both;
69
+ animation: phreshos-ui-overlay-enter var(--phreshos-ui-motion-duration) var(--phreshos-ui-motion-easing) reverse both;
31
70
  pointer-events: none;
32
71
  }
33
72
  @keyframes phreshos-ui-overlay-enter {
@@ -39,7 +78,7 @@ const stylesheet = `
39
78
  .phreshos-ui-overlay[data-entering], .phreshos-ui-overlay[data-exiting] { animation: none; }
40
79
  }
41
80
  `;
42
- /** React hoists and deduplicates this stylesheet, including nested providers and portals. */
81
+ /** React hoists and deduplicates this stylesheet, including portals. */
43
82
  export default function MotionStyle() {
44
83
  return _jsx("style", { href: "phreshos-react-ui-motion", precedence: "phreshos", children: stylesheet });
45
84
  }
package/dist/panel.js CHANGED
@@ -1,12 +1,12 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { forwardRef } from "react";
3
- import { useAppearance, useResolveTheme } from "./appearance-provider.js";
3
+ import { useAppearance } from "./appearance-provider.js";
4
4
  import { scale } from "./scale.js";
5
5
  import { Surface } from "./surface.js";
6
6
  /** Owns the shared shell layout, not positioning, interaction, or lifecycle. */
7
7
  export const Panel = forwardRef(function Panel({ header, children, contentProps, style, ...properties }, ref) {
8
8
  const appearance = useAppearance();
9
- const inset = scale(useResolveTheme(appearance.spacing), "small");
9
+ const inset = scale(appearance.spacing, "small");
10
10
  const hasHeader = header !== undefined && header !== null && header !== false;
11
11
  return _jsxs(Surface, { ...properties, ref: ref, style: {
12
12
  display: "grid",
package/dist/radio.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { ReactNode } from "react";
2
2
  import type { RadioGroupProps as AriaRadioGroupProps, RadioFieldProps } from "react-aria-components";
3
3
  import type { ControlOverrides, ControlProps, FieldProps } from "./control.js";
4
- import type { MaterialOverrides } from "./material.js";
4
+ import type { MaterialOverrides } from "./material-options.js";
5
5
  export interface RadioGroupProps extends Omit<AriaRadioGroupProps, ControlOverrides | "isReadOnly">, ControlProps, FieldProps, MaterialOverrides {
6
6
  readonly children: ReactNode;
7
7
  readonly readOnly?: boolean;
package/dist/radius.d.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  import type { CSSProperties } from "react";
2
- import type { Appearance, Shapeable } from "@phreshos/core";
2
+ import type { Appearance } from "@phreshos/core";
3
3
  import { type ScaleLevel } from "./scale.js";
4
4
  /** An Appearance-derived level, pixel value, or explicit CSS corner radius. */
5
5
  export type Radius = ScaleLevel | number | (string & {});
6
6
  /** Shared semantic corner-radius capability for React UI components. */
7
- export interface RadiusProps extends Shapeable<Radius> {
7
+ export interface RadiusProps {
8
+ readonly radius?: Radius;
8
9
  }
9
10
  /** Resolves a Radius while preserving explicit CSS and pixel values. */
10
11
  export declare function resolveRadius(value: Radius | undefined, appearance: Appearance | null): CSSProperties["borderRadius"];
package/dist/radius.js CHANGED
@@ -5,5 +5,5 @@ export function resolveRadius(value, appearance) {
5
5
  return value;
6
6
  if (!appearance)
7
7
  throw new Error("A semantic radius requires an AppearanceProvider");
8
- return scale(appearance.radius.light, value);
8
+ return scale(appearance.radius, value);
9
9
  }
package/dist/select.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { SelectProps as AriaSelectProps } from "react-aria-components";
2
2
  import type { ControlOverrides, ControlProps, FieldProps } from "./control.js";
3
3
  import type { RadiusProps } from "./radius.js";
4
- import type { MaterialOverrides } from "./material.js";
4
+ import type { MaterialOverrides } from "./material-options.js";
5
5
  export interface SelectOption {
6
6
  readonly value: string;
7
7
  readonly label: string;
package/dist/select.js CHANGED
@@ -3,19 +3,21 @@ import { forwardRef } from "react";
3
3
  import { motion, useReducedMotion } from "motion/react";
4
4
  import { Select as AriaSelect, Button, SelectValue, Popover, ListBox, ListBoxItem } from "react-aria-components";
5
5
  import { controlPaint, controlStyle, FieldFeedback, FieldLabel, fieldStyle, useControlTheme } from "./control.js";
6
- import { MaterialButton } from "./control-material.js";
6
+ import { SurfaceButton } from "./control-surface.js";
7
7
  import { Surface } from "./surface.js";
8
- import { controlTransition, overlayMotionClass, visualTransition } from "./motion-style.js";
8
+ import { overlayMotionClass, useControlTransition, useOverlayTransition } from "./motion-style.js";
9
9
  /** Single selection from string-valued options; keyboard navigation and typeahead stay native to React Aria. */
10
10
  export const Select = forwardRef(function Select({ label, description, errorMessage, disabled, required, invalid, options, value, defaultValue, onChange, size, color, radius, style, material, ...properties }, ref) {
11
11
  const theme = useControlTheme({ size, color, radius });
12
12
  const reduced = useReducedMotion();
13
- return _jsx(AriaSelect, { ...properties, ref: ref, value: value, defaultValue: defaultValue, onChange: key => onChange?.(key == null ? null : String(key)), disabledKeys: options.filter(option => option.disabled).map(option => option.value), isDisabled: disabled, isRequired: required, isInvalid: invalid, style: fieldStyle(theme, disabled, style), children: state => _jsxs(_Fragment, { children: [_jsx(FieldLabel, { label: label }), _jsxs(Button, { render: (native, button) => _jsx(MaterialButton, { native: native, material: material, paint: controlPaint(theme, state.isOpen || button.isFocused, state.isInvalid, button.isHovered) }), style: button => ({
13
+ const transition = useControlTransition(Boolean(reduced));
14
+ const overlayTransition = useOverlayTransition();
15
+ return _jsx(AriaSelect, { ...properties, ref: ref, value: value, defaultValue: defaultValue, onChange: key => onChange?.(key == null ? null : String(key)), disabledKeys: options.filter(option => option.disabled).map(option => option.value), isDisabled: disabled, isRequired: required, isInvalid: invalid, style: fieldStyle(theme, disabled, style), children: state => _jsxs(_Fragment, { children: [_jsx(FieldLabel, { label: label }), _jsxs(Button, { render: (native, button) => _jsx(SurfaceButton, { native: native, material: material, paint: controlPaint(theme, state.isOpen || button.isFocused, state.isInvalid, button.isHovered) }), style: button => ({
14
16
  ...controlStyle(theme, state.isOpen || button.isFocused, state.isInvalid, button.isHovered, button.isFocusVisible),
15
17
  display: "flex", alignItems: "center", justifyContent: "space-between", gap: theme.gap,
16
18
  cursor: disabled ? "not-allowed" : "pointer", textAlign: "start"
17
- }), children: [_jsx(SelectValue, { style: ({ isPlaceholder }) => ({ opacity: isPlaceholder ? 0.6 : 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }) }), _jsx(motion.svg, { "aria-hidden": "true", width: "12", height: "12", viewBox: "0 0 12 12", fill: "none", stroke: "currentColor", strokeWidth: "1.5", initial: false, animate: { rotate: state.isOpen ? 180 : 0 }, transition: { ...controlTransition, duration: reduced ? 0 : controlTransition.duration }, style: { flexShrink: 0 }, children: _jsx("path", { d: "m2 4 4 4 4-4" }) })] }), _jsx(Popover, { className: overlayMotionClass, placement: "bottom start", offset: theme.gap, maxHeight: 280 + theme.gap * 2, style: { width: "var(--trigger-width)", maxWidth: "calc(100vw - 16px)" }, children: _jsx(Surface, { style: { padding: theme.gap, display: "flex", flexDirection: "column", maxHeight: "inherit", boxSizing: "border-box" }, children: _jsx(ListBox, { items: options, style: { display: "grid", gap: theme.gap, minHeight: 0, overflow: "auto", outline: "none", fontSize: theme.fontSize, color: theme.foreground }, children: option => _jsx(ListBoxItem, { id: option.value, textValue: option.label, style: item => ({
18
- ...visualTransition,
19
+ }), children: [_jsx(SelectValue, { style: ({ isPlaceholder }) => ({ opacity: isPlaceholder ? 0.6 : 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }) }), _jsx(motion.svg, { "aria-hidden": "true", width: "12", height: "12", viewBox: "0 0 12 12", fill: "none", stroke: "currentColor", strokeWidth: "1.5", initial: false, animate: { rotate: state.isOpen ? 180 : 0 }, transition: transition, style: { flexShrink: 0 }, children: _jsx("path", { d: "m2 4 4 4 4-4" }) })] }), _jsx(Popover, { className: overlayMotionClass, placement: "bottom start", offset: theme.gap, maxHeight: 280 + theme.gap * 2, style: { ...overlayTransition, width: "var(--trigger-width)", maxWidth: "calc(100vw - 16px)" }, children: _jsx(Surface, { style: { padding: theme.gap, display: "flex", flexDirection: "column", maxHeight: "inherit", boxSizing: "border-box" }, children: _jsx(ListBox, { items: options, style: { display: "grid", gap: theme.gap, minHeight: 0, overflow: "auto", outline: "none", fontSize: theme.fontSize, color: theme.foreground }, children: option => _jsx(ListBoxItem, { id: option.value, textValue: option.label, style: item => ({
20
+ ...theme.transition,
19
21
  display: "flex", alignItems: "center", justifyContent: "space-between", gap: theme.gap,
20
22
  minHeight: theme.height, paddingInline: Math.max(8, theme.spacing), boxSizing: "border-box",
21
23
  borderRadius: theme.radius,
package/dist/slider.js CHANGED
@@ -3,7 +3,7 @@ import { forwardRef, useId } from "react";
3
3
  import { motion, useReducedMotion } from "motion/react";
4
4
  import { Slider as AriaSlider, SliderTrack, SliderThumb, SliderFill, SliderOutput } from "react-aria-components";
5
5
  import { FieldLabel, fieldStyle, useControlTheme } from "./control.js";
6
- import { controlTransition, visualTransition } from "./motion-style.js";
6
+ import { useControlTransition } from "./motion-style.js";
7
7
  /** One numeric value with pointer, touch, keyboard, and form support. */
8
8
  export const Slider = forwardRef(function Slider({ label, description, name, disabled, size, color, style, orientation = "horizontal", ...properties }, ref) {
9
9
  const theme = useControlTheme({ size, color });
@@ -11,17 +11,18 @@ export const Slider = forwardRef(function Slider({ label, description, name, dis
11
11
  const vertical = orientation === "vertical";
12
12
  const diameter = theme.fontSize + 6;
13
13
  const reduced = useReducedMotion();
14
+ const transition = useControlTransition(Boolean(reduced));
14
15
  const rail = Math.max(4, Math.round(diameter / 3));
15
16
  return _jsxs(AriaSlider, { ...properties, ref: ref, isDisabled: disabled, orientation: orientation, style: fieldStyle(theme, disabled, style), "aria-describedby": [properties["aria-describedby"], description != null ? descriptionId : null].filter(Boolean).join(" ") || undefined, children: [_jsxs("div", { style: { display: "flex", alignItems: "baseline", justifyContent: "space-between", gap: theme.gap }, children: [_jsx(FieldLabel, { label: label }), _jsx(SliderOutput, { style: { fontVariantNumeric: "tabular-nums", opacity: 0.7 } })] }), _jsxs(SliderTrack, { style: {
16
17
  position: "relative", touchAction: "none",
17
18
  width: vertical ? theme.height : "100%", height: vertical ? 160 : theme.height
18
19
  }, children: [_jsx("div", { "aria-hidden": "true", style: {
19
- ...visualTransition,
20
+ ...theme.transition,
20
21
  position: "absolute", borderRadius: rail,
21
22
  ...(vertical ? { width: rail, height: "100%", left: "50%", transform: "translateX(-50%)" } : { height: rail, width: "100%", top: "50%", transform: "translateY(-50%)" }),
22
23
  background: theme.paints.neutral.rest.background
23
24
  } }), _jsx(SliderFill, { style: {
24
- ...visualTransition,
25
+ ...theme.transition,
25
26
  position: "absolute", background: theme.paints.palette.rest.background, borderRadius: rail,
26
27
  ...(vertical ? { width: rail, left: "50%", transform: "translateX(-50%)" } : { height: rail, top: "50%", transform: "translateY(-50%)" })
27
28
  } }), _jsx(SliderThumb, { name: name, style: state => ({
@@ -29,7 +30,7 @@ export const Slider = forwardRef(function Slider({ label, description, name, dis
29
30
  ...(vertical ? { left: "50%" } : { top: "50%" }),
30
31
  outline: state.isFocusVisible ? `2px solid ${theme.foreground}` : "none", outlineOffset: 2,
31
32
  cursor: state.isDisabled ? "not-allowed" : state.isDragging ? "grabbing" : "grab"
32
- }), children: state => _jsx(motion.span, { "aria-hidden": "true", initial: false, animate: { scale: reduced || state.isDisabled ? 1 : state.isDragging ? 0.92 : state.isHovered ? 1.08 : 1 }, transition: { ...controlTransition, duration: reduced ? 0 : controlTransition.duration }, style: { ...visualTransition, position: "absolute", inset: 0, borderRadius: "inherit", pointerEvents: "none",
33
+ }), children: state => _jsx(motion.span, { "aria-hidden": "true", initial: false, animate: { scale: reduced || state.isDisabled ? 1 : state.isDragging ? 0.92 : state.isHovered ? 1.08 : 1 }, transition: transition, style: { ...theme.transition, position: "absolute", inset: 0, borderRadius: "inherit", pointerEvents: "none",
33
34
  background: (state.isDragging ? theme.paints.palette.pressed : state.isHovered ? theme.paints.palette.hover : theme.paints.palette.rest).color,
34
35
  border: `3px solid ${(state.isDragging ? theme.paints.palette.pressed : state.isHovered ? theme.paints.palette.hover : theme.paints.palette.rest).background}`,
35
36
  boxSizing: "border-box" } }) })] }), description != null && _jsx("span", { id: descriptionId, style: { fontSize: "0.92em", opacity: 0.7 }, children: description })] });
package/dist/spacing.js CHANGED
@@ -4,5 +4,5 @@ export function resolveSpacing(value, appearance) {
4
4
  return value;
5
5
  if (!appearance)
6
6
  throw new Error("Semantic spacing requires an AppearanceProvider");
7
- return scale(appearance.spacing.light, value);
7
+ return scale(appearance.spacing, value);
8
8
  }
@@ -1,5 +1,8 @@
1
- import type { ResolvedMaterial } from "./material.js";
2
- type EdgeMaterial = Pick<ResolvedMaterial, "color" | "foreground" | "opacity">;
1
+ type EdgeMaterial = Readonly<{
2
+ color: string;
3
+ foreground: string;
4
+ opacity: number;
5
+ }>;
3
6
  /** Paints the boundary owned by one Surface from the material it contains. */
4
7
  export declare function SurfaceEdge({ material }: Readonly<{
5
8
  material: EdgeMaterial;
@@ -1,10 +1,11 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Fragment, useCallback, useLayoutEffect, useRef, useState } from "react";
3
3
  import { colorLightness, colorOpacity, orderColors } from "./color.js";
4
- import { paintTransition } from "./motion-style.js";
4
+ import { usePaintTransition } from "./motion-style.js";
5
5
  import { scale } from "./scale.js";
6
6
  /** Paints the boundary owned by one Surface from the material it contains. */
7
7
  export function SurfaceEdge({ material }) {
8
+ const paintTransition = usePaintTransition();
8
9
  const visible = material.opacity > 0;
9
10
  const palette = useRef(null);
10
11
  const [colors, setColors] = useState(null);
package/dist/surface.d.ts CHANGED
@@ -1,7 +1,23 @@
1
- import type { ComponentPropsWithRef } from "react";
2
- import { type MaterialProps } from "./material.js";
1
+ import type { ComponentPropsWithRef, CSSProperties, ElementType, ReactElement, ReactNode } from "react";
2
+ import { type Color } from "./color.js";
3
+ import { type MaterialOverrides } from "./material-options.js";
3
4
  import { type RadiusProps } from "./radius.js";
4
- /** A div that hosts one Material and owns its geometry. */
5
- export interface SurfaceProps extends Omit<ComponentPropsWithRef<"div">, "color">, MaterialProps, RadiusProps {
5
+ export type { MaterialOptions } from "./material-options.js";
6
+ export interface SurfaceOwnProps extends MaterialOverrides, RadiusProps {
7
+ readonly color?: Color;
6
8
  }
7
- export declare const Surface: import("react").ForwardRefExoticComponent<Omit<SurfaceProps, "ref"> & import("react").RefAttributes<HTMLDivElement>>;
9
+ /**
10
+ * The structural properties a component must preserve when it hosts a Surface.
11
+ * The component must apply `style` and render `children` on the same host element,
12
+ * and it must forward its ref to that element.
13
+ */
14
+ export interface SurfaceHostProps {
15
+ readonly children?: ReactNode;
16
+ readonly style?: CSSProperties;
17
+ }
18
+ export type SurfaceHost = ElementType<SurfaceHostProps>;
19
+ export type SurfaceProps<As extends SurfaceHost = "div"> = SurfaceOwnProps & Readonly<{
20
+ as?: As;
21
+ }> & Omit<ComponentPropsWithRef<As>, keyof SurfaceOwnProps | "as" | "color">;
22
+ export type SurfaceComponent = <As extends SurfaceHost = "div">(properties: SurfaceProps<As>) => ReactElement | null;
23
+ export declare const Surface: SurfaceComponent;
package/dist/surface.js CHANGED
@@ -1,21 +1,63 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { forwardRef } from "react";
3
- import { useAppearance } from "./appearance-provider.js";
4
- import { MaterialLayer, useResolvedMaterial } from "./material.js";
5
- import { visualTransition } from "./motion-style.js";
2
+ import { createElement, forwardRef, useId } from "react";
3
+ import { useAppearance, useThemedValue } from "./appearance-provider.js";
4
+ import { useResolveColor } from "./color.js";
5
+ import { useMaterialOptions } from "./material-options.js";
6
+ import { MaterialPaint } from "./material-paint.js";
7
+ import MotionStyle, { useVisualTransition } from "./motion-style.js";
6
8
  import { resolveRadius } from "./radius.js";
7
9
  import { SurfaceEdge } from "./surface-edge.js";
8
- export const Surface = forwardRef(function Surface({ color, material: options, radius, children, style, ...properties }, ref) {
10
+ /** One material-owning element. The host is a div unless `as` selects another element. */
11
+ const SurfaceRoot = forwardRef(function Surface({ as: Element = "div", color, material: options, radius, children, style, ...properties }, ref) {
9
12
  const appearance = useAppearance();
10
- const material = useResolvedMaterial({ color, material: options });
13
+ const transition = useVisualTransition();
14
+ const material = useResolvedSurface(color, options);
11
15
  const resolvedRadius = resolveRadius(radius ?? "medium", appearance);
12
- return _jsxs("div", { ...properties, ref: ref, style: {
13
- ...visualTransition,
14
- background: "transparent",
15
- color: material.foreground,
16
+ return createElement(Element, {
17
+ ...properties,
18
+ ref,
19
+ style: {
20
+ ...transition,
16
21
  ...style,
22
+ background: "transparent",
23
+ color: style?.color ?? material.foreground,
17
24
  borderRadius: radius === undefined ? style?.borderRadius ?? resolvedRadius : resolvedRadius,
18
25
  position: style?.position ?? "relative",
19
26
  isolation: "isolate"
20
- }, children: [_jsx(MaterialLayer, { material: material }), _jsx(SurfaceEdge, { material: material }), children] });
27
+ }
28
+ }, _jsx(SurfaceLayers, { material: material }), _jsx(SurfaceEdge, { material: material }), children);
21
29
  });
30
+ export const Surface = SurfaceRoot;
31
+ function useResolvedSurface(color, options) {
32
+ const appearance = useAppearance();
33
+ const material = useMaterialOptions(options);
34
+ return {
35
+ ...material,
36
+ color: useResolveColor(color),
37
+ foreground: useThemedValue(appearance.colors).foreground
38
+ };
39
+ }
40
+ const layerStyle = {
41
+ position: "absolute",
42
+ inset: 0,
43
+ borderRadius: "inherit",
44
+ pointerEvents: "none"
45
+ };
46
+ function SurfaceLayers({ material }) {
47
+ const { backdrop, saturation, distortion } = material;
48
+ const frost = [
49
+ backdrop === 0 ? "" : `blur(${backdrop}px)`,
50
+ saturation === 1 ? "" : `saturate(${saturation})`
51
+ ].filter(Boolean).join(" ");
52
+ const identity = `phresh-material-${useId().replaceAll(":", "")}`;
53
+ return _jsxs("span", { "data-material": "", "aria-hidden": "true", style: layerStyle, children: [_jsx(MotionStyle, {}), distortion > 0 && _jsx(BackdropLayer, { name: "refraction", filter: `url("#${identity}-distortion")`, zIndex: -3 }), frost && _jsx(BackdropLayer, { name: "frost", filter: frost, zIndex: -2 }), _jsx(MaterialPaint, { identity: identity, distortion: distortion, color: material.color, grain: material.grain, grainAmount: material.grainAmount, opacity: material.opacity })] });
54
+ }
55
+ /** Keep refraction and native frost in independent compositor passes. */
56
+ function BackdropLayer({ filter, name, zIndex }) {
57
+ return _jsx("span", { "data-material-backdrop": name, "aria-hidden": "true", style: {
58
+ ...layerStyle,
59
+ zIndex,
60
+ backdropFilter: filter,
61
+ WebkitBackdropFilter: filter
62
+ } });
63
+ }
package/dist/switch.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { SwitchFieldProps } from "react-aria-components";
2
2
  import type { ControlOverrides, ControlProps, FieldProps } from "./control.js";
3
- import type { MaterialOverrides } from "./material.js";
3
+ import type { MaterialOverrides } from "./material-options.js";
4
4
  export interface SwitchProps extends Omit<SwitchFieldProps, ControlOverrides | "isReadOnly" | "isSelected" | "defaultSelected">, ControlProps, FieldProps, MaterialOverrides {
5
5
  readonly checked?: boolean;
6
6
  readonly defaultChecked?: boolean;
@@ -1,7 +1,7 @@
1
1
  import type { TextFieldProps } from "react-aria-components";
2
2
  import type { ControlOverrides, ControlProps, FieldProps } from "./control.js";
3
3
  import type { RadiusProps } from "./radius.js";
4
- import type { MaterialOverrides } from "./material.js";
4
+ import type { MaterialOverrides } from "./material-options.js";
5
5
  /** Shared contract for native text-entry controls. */
6
6
  export interface TextControlProps extends Omit<TextFieldProps, ControlOverrides | "isReadOnly">, ControlProps, FieldProps, RadiusProps, MaterialOverrides {
7
7
  readonly placeholder?: string;
package/dist/textarea.js CHANGED
@@ -2,12 +2,12 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { forwardRef } from "react";
3
3
  import { TextArea as AriaTextarea, TextField } from "react-aria-components";
4
4
  import { controlPaint, controlStyle, FieldFeedback, FieldLabel, fieldStyle, useControlTheme } from "./control.js";
5
- import { MaterialField } from "./control-material.js";
5
+ import { SurfaceField } from "./control-surface.js";
6
6
  import FieldStyle, { textControlClass } from "./field-style.js";
7
7
  /** A labeled multiline text control, vertically resizable without escaping its width. */
8
8
  export const Textarea = forwardRef(function Textarea({ label, description, errorMessage, disabled, readOnly, required, invalid, size, color, radius, style, className, placeholder, rows = 4, material, ...properties }, ref) {
9
9
  const theme = useControlTheme({ size, color, radius });
10
- return _jsxs(TextField, { ...properties, className: className, isDisabled: disabled, isReadOnly: readOnly, isRequired: required, isInvalid: invalid, style: fieldStyle(theme, disabled, style), children: [_jsx(FieldStyle, {}), _jsx(FieldLabel, { label: label }), _jsx(AriaTextarea, { ref: ref, placeholder: placeholder, rows: rows, className: textControlClass, render: (native, state) => _jsx(MaterialField, { material: material, radius: theme.radius, paint: controlPaint(theme, state.isFocused, state.isInvalid, state.isHovered), children: _jsx("textarea", { ...native, style: { ...native.style, borderRadius: "inherit", background: "transparent" } }) }), style: state => ({
10
+ return _jsxs(TextField, { ...properties, className: className, isDisabled: disabled, isReadOnly: readOnly, isRequired: required, isInvalid: invalid, style: fieldStyle(theme, disabled, style), children: [_jsx(FieldStyle, {}), _jsx(FieldLabel, { label: label }), _jsx(AriaTextarea, { ref: ref, placeholder: placeholder, rows: rows, className: textControlClass, render: (native, state) => _jsx(SurfaceField, { material: material, radius: theme.radius, paint: controlPaint(theme, state.isFocused, state.isInvalid, state.isHovered), children: _jsx("textarea", { ...native, style: { ...native.style, borderRadius: "inherit", background: "transparent" } }) }), style: state => ({
11
11
  ...controlStyle(theme, state.isFocused, state.isInvalid, state.isHovered, state.isFocusVisible),
12
12
  height: "auto",
13
13
  minHeight: theme.height,
@@ -1,12 +1,12 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { motion, useReducedMotion } from "motion/react";
3
3
  import { useLocale } from "react-aria-components";
4
- import { MaterialLayer, useResolvedMaterial } from "./material.js";
5
- import { SurfaceEdge } from "./surface-edge.js";
6
- import { controlTransition, visualTransition } from "./motion-style.js";
4
+ import { useControlTransition } from "./motion-style.js";
5
+ import { Surface } from "./surface.js";
7
6
  /** Shared paint only. Selection, focus, validation, and native input behavior belong to React Aria. */
8
7
  export function ToggleIndicator({ kind, selected, indeterminate = false, focused, invalid = false, hovered = false, pressed = false, theme, material: options }) {
9
8
  const reduced = useReducedMotion();
9
+ const transition = useControlTransition(Boolean(reduced));
10
10
  const { direction } = useLocale();
11
11
  const diameter = theme.fontSize + 6;
12
12
  const active = selected || indeterminate;
@@ -16,11 +16,9 @@ export function ToggleIndicator({ kind, selected, indeterminate = false, focused
16
16
  const switching = kind === "switch";
17
17
  const thumbWidth = diameter - 6 + (pressed && !reduced ? 3 : 0);
18
18
  const travel = (diameter * 2 - thumbWidth) / 2 - 3;
19
- const material = useResolvedMaterial({ color: paint.background, material: options });
20
- return _jsxs(motion.span, { "aria-hidden": "true", initial: false, animate: { scale: reduced || switching ? 1 : pressed ? 0.94 : hovered ? 1.03 : 1 }, transition: { ...controlTransition, duration: reduced ? 0 : controlTransition.duration }, style: {
19
+ return _jsx(Surface, { as: motion.span, material: options, color: paint.background, "aria-hidden": "true", initial: false, animate: { scale: reduced || switching ? 1 : pressed ? 0.94 : hovered ? 1.03 : 1 }, transition: transition, style: {
21
20
  color: paint.color,
22
- ...visualTransition,
23
- background: "transparent",
21
+ ...theme.transition,
24
22
  display: "inline-grid",
25
23
  placeItems: "center",
26
24
  position: "relative",
@@ -32,12 +30,12 @@ export function ToggleIndicator({ kind, selected, indeterminate = false, focused
32
30
  border: "none",
33
31
  outline: focused ? `2px solid ${theme.foreground}` : "none",
34
32
  outlineOffset: 2,
35
- }, children: [_jsx(MaterialLayer, { material: material }), _jsx(SurfaceEdge, { material: material }), switching ? _jsx(motion.span, { initial: false, animate: { width: thumbWidth, x: (selected ? travel : -travel) * (direction === "rtl" ? -1 : 1) }, transition: { ...controlTransition, duration: reduced ? 0 : controlTransition.duration }, style: {
36
- ...visualTransition,
37
- height: diameter - 6, borderRadius: diameter,
38
- background: paint.color
39
- } }) : _jsx(motion.span, { initial: false, animate: { opacity: active ? 1 : 0, scale: active ? 1 : 0.7 }, transition: { ...controlTransition, duration: reduced ? 0 : controlTransition.duration }, style: { display: "grid", placeItems: "center" }, children: kind === "radio" ? _jsx("span", { style: { ...visualTransition, width: diameter / 2, height: diameter / 2, borderRadius: "50%", background: "currentColor" } })
40
- : _jsxs("svg", { width: diameter - 4, height: diameter - 4, viewBox: "0 0 16 16", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx(motion.path, { d: "m3 8 3 3 7-7", initial: false, animate: { pathLength: selected && !indeterminate ? 1 : 0, opacity: selected && !indeterminate ? 1 : 0 }, transition: { ...controlTransition, duration: reduced ? 0 : controlTransition.duration } }), _jsx(motion.path, { d: "M4 8h8", initial: false, animate: { pathLength: indeterminate ? 1 : 0, opacity: indeterminate ? 1 : 0 }, transition: { ...controlTransition, duration: reduced ? 0 : controlTransition.duration } })] }) })] });
33
+ }, children: switching ? _jsx(motion.span, { initial: false, animate: { width: thumbWidth, x: (selected ? travel : -travel) * (direction === "rtl" ? -1 : 1) }, transition: transition, style: {
34
+ ...theme.transition,
35
+ height: diameter - 6, borderRadius: diameter,
36
+ background: paint.color
37
+ } }) : _jsx(motion.span, { initial: false, animate: { opacity: active ? 1 : 0, scale: active ? 1 : 0.7 }, transition: transition, style: { display: "grid", placeItems: "center" }, children: kind === "radio" ? _jsx("span", { style: { ...theme.transition, width: diameter / 2, height: diameter / 2, borderRadius: "50%", background: "currentColor" } })
38
+ : _jsxs("svg", { width: diameter - 4, height: diameter - 4, viewBox: "0 0 16 16", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx(motion.path, { d: "m3 8 3 3 7-7", initial: false, animate: { pathLength: selected && !indeterminate ? 1 : 0, opacity: selected && !indeterminate ? 1 : 0 }, transition: transition }), _jsx(motion.path, { d: "M4 8h8", initial: false, animate: { pathLength: indeterminate ? 1 : 0, opacity: indeterminate ? 1 : 0 }, transition: transition })] }) }) });
41
39
  }
42
40
  export function toggleStyle(theme, disabled, readOnly) {
43
41
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phreshos/react-ui",
3
- "version": "0.1.17",
3
+ "version": "0.1.19",
4
4
  "description": "React components for coherent PhreshOS Program interfaces.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -45,18 +45,17 @@
45
45
  "scripts": {
46
46
  "clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
47
47
  "check": "tsc --noEmit && tsc -p tsconfig.test.json",
48
- "test": "vitest run --environment jsdom",
48
+ "test": "vitest run --project default",
49
49
  "build": "node --run clean && tsc --noEmit false --outDir dist --rootDir source",
50
- "verify:package": "node scripts/verify-package.mjs",
51
- "verify": "node --run check && node --run test && node --run build && node --run verify:package",
52
- "prepack": "node --run test && node --run build"
50
+ "verify": "node --run check && node --run build && node --run test",
51
+ "prepack": "node --run build"
53
52
  },
54
53
  "peerDependencies": {
55
54
  "react": "^19.2.0",
56
55
  "react-dom": "^19.2.0"
57
56
  },
58
57
  "dependencies": {
59
- "@phreshos/core": "^0.1.42",
58
+ "@phreshos/core": "^0.1.43",
60
59
  "colorjs.io": "^0.7.1",
61
60
  "motion": "^13.2.0",
62
61
  "react-aria-components": "^1.20.0"
@@ -72,6 +71,6 @@
72
71
  "react": "^19.2.8",
73
72
  "react-dom": "^19.2.8",
74
73
  "typescript": "^6.0.3",
75
- "vitest": "^4.1.1"
74
+ "vitest": "^4.1.10"
76
75
  }
77
76
  }
@@ -1,14 +0,0 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { MaterialLayer, useResolvedMaterial } from "./material.js";
3
- import { SurfaceEdge } from "./surface-edge.js";
4
- /** A native button hosts Material without changing its native contract. */
5
- export function MaterialButton({ native, paint, material: options }) {
6
- const { ref, style, children, ...properties } = native;
7
- const material = useResolvedMaterial({ color: paint.background, material: options });
8
- return _jsxs("button", { ...properties, ref: ref, style: { ...style, background: "transparent", color: paint.color, position: style?.position ?? "relative", isolation: "isolate" }, children: [_jsx(MaterialLayer, { material: material }), _jsx(SurfaceEdge, { material: material }), children] });
9
- }
10
- /** Void text controls use a material host while retaining native sizing. */
11
- export function MaterialField({ paint, radius, children, material: options }) {
12
- const material = useResolvedMaterial({ color: paint.background, material: options });
13
- return _jsxs("span", { style: { color: paint.color, display: "grid", minWidth: 0, borderRadius: radius, position: "relative", isolation: "isolate" }, children: [_jsx(MaterialLayer, { material: material }), _jsx(SurfaceEdge, { material: material }), children] });
14
- }
@@ -1,27 +0,0 @@
1
- import { type MaterialOptions } from "./material-options.js";
2
- import { type Color } from "./color.js";
3
- export type { MaterialOptions } from "./material-options.js";
4
- /** Visual substance without host layout, content, interaction, or shadow. */
5
- export interface MaterialProps {
6
- readonly color?: Color;
7
- readonly material?: MaterialOptions;
8
- }
9
- export interface MaterialOverrides {
10
- readonly material?: MaterialOptions;
11
- }
12
- /** Fill the geometry of the nearest positioned host with the resolved material. */
13
- export declare function Material(options?: MaterialProps): import("react").JSX.Element;
14
- export declare function useResolvedMaterial({ color, material: options }?: MaterialProps): {
15
- color: string;
16
- foreground: string;
17
- opacity: number;
18
- backdrop: number;
19
- grain: number;
20
- grainAmount: number;
21
- distortion: number;
22
- saturation: number;
23
- };
24
- export type ResolvedMaterial = ReturnType<typeof useResolvedMaterial>;
25
- export declare function MaterialLayer({ material }: Readonly<{
26
- material: ResolvedMaterial;
27
- }>): import("react").JSX.Element;
package/dist/material.js DELETED
@@ -1,45 +0,0 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useId } from "react";
3
- import { useMaterialOptions } from "./material-options.js";
4
- import { useAppearance, useResolveTheme } from "./appearance-provider.js";
5
- import { useResolveColor } from "./color.js";
6
- import { MaterialPaint } from "./material-paint.js";
7
- import MotionStyle from "./motion-style.js";
8
- const layerStyle = {
9
- position: "absolute",
10
- inset: 0,
11
- borderRadius: "inherit",
12
- pointerEvents: "none"
13
- };
14
- /** Fill the geometry of the nearest positioned host with the resolved material. */
15
- export function Material(options = {}) {
16
- const material = useResolvedMaterial(options);
17
- return _jsx(MaterialLayer, { material: material });
18
- }
19
- export function useResolvedMaterial({ color, material: options } = {}) {
20
- const appearance = useAppearance();
21
- const material = useMaterialOptions(options);
22
- return {
23
- ...material,
24
- color: useResolveColor(color),
25
- foreground: useResolveTheme(appearance.colors.foreground)
26
- };
27
- }
28
- export function MaterialLayer({ material }) {
29
- const { backdrop, saturation, distortion } = material;
30
- const frost = [
31
- backdrop === 0 ? "" : `blur(${backdrop}px)`,
32
- saturation === 1 ? "" : `saturate(${saturation})`
33
- ].filter(Boolean).join(" ");
34
- const identity = `phresh-material-${useId().replaceAll(":", "")}`;
35
- return _jsxs("span", { "data-material": "", "aria-hidden": "true", style: layerStyle, children: [_jsx(MotionStyle, {}), distortion > 0 && _jsx(BackdropLayer, { name: "refraction", filter: `url("#${identity}-distortion")`, zIndex: -3 }), frost && _jsx(BackdropLayer, { name: "frost", filter: frost, zIndex: -2 }), _jsx(MaterialPaint, { identity: identity, distortion: distortion, color: material.color, grain: material.grain, grainAmount: material.grainAmount, opacity: material.opacity })] });
36
- }
37
- /** Keep refraction and native frost in independent compositor passes. */
38
- function BackdropLayer({ filter, name, zIndex }) {
39
- return _jsx("span", { "data-material-backdrop": name, "aria-hidden": "true", style: {
40
- ...layerStyle,
41
- zIndex,
42
- backdropFilter: filter,
43
- WebkitBackdropFilter: filter
44
- } });
45
- }