@phreshos/react-ui 0.1.15 → 0.1.16

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.
package/README.md CHANGED
@@ -2,8 +2,19 @@
2
2
 
3
3
  Environment-neutral React components and the visual language of PhreshOS.
4
4
 
5
- React UI interprets Core Appearance and Theme contracts. It does not depend on a
6
- Client or Server runtime and does not own authoritative application state.
5
+ [Appearance](https://docs.phreshos.com/system/appearance) ·
6
+ [React SDK](https://docs.phreshos.com/sdks/react) ·
7
+ [Source](https://github.com/PhreshOS/react-ui)
8
+
9
+ ## Role
10
+
11
+ React UI interprets Core Appearance and Theme contracts as reusable visual
12
+ primitives. The Desktop and Programs compose those primitives instead of
13
+ reimplementing material, spacing, color, radius, or interaction behavior.
14
+
15
+ The package does not depend on a Client or Server runtime and does not own
16
+ authoritative application state. React owns runtime-neutral state adaptation;
17
+ applications own composition.
7
18
 
8
19
  ## Installation
9
20
 
@@ -16,15 +27,9 @@ Client or Server runtime and does not own authoritative application state.
16
27
 
17
28
  `@phreshos/core`, React, and React DOM are peer dependencies.
18
29
 
19
- ## Appearance
20
-
21
30
  ```tsx
22
31
  import { standardAppearance } from "@phreshos/core"
23
- import {
24
- AppearanceProvider,
25
- Button,
26
- Surface,
27
- } from "@phreshos/react-ui"
32
+ import { AppearanceProvider, Button, Surface } from "@phreshos/react-ui"
28
33
 
29
34
  <AppearanceProvider appearance={standardAppearance} theme="light">
30
35
  <Surface>
@@ -33,26 +38,23 @@ import {
33
38
  </AppearanceProvider>
34
39
  ```
35
40
 
36
- `AppearanceProvider` provides the unresolved Appearance and one effective
37
- `"light" | "dark"` Theme. `useAppearance()` reads the unresolved value,
38
- `useTheme()` reads the effective mode, and `useResolveTheme()` resolves one
39
- themed property where it is consumed.
41
+ See [Appearance](https://docs.phreshos.com/system/appearance) for the contract
42
+ interpreted by the provider and components.
40
43
 
41
- The provider also applies the shared document scrollbar treatment without
42
- adding a rendered container.
44
+ `Panel` composes an outer `Surface`, an optional header, and an inset content
45
+ `Surface`. Both materials retain Surface defaults; the content inset follows
46
+ Appearance spacing. Positioning, modality, and lifecycle belong to the caller.
43
47
 
44
- ## Components
45
-
46
- The package owns the reusable visual primitives used across the desktop and
47
- official Programs:
48
+ ```tsx
49
+ import { Panel } from "@phreshos/react-ui"
48
50
 
49
- - `Surface` and `SurfaceMaterial`
50
- - `Button`
51
- - `Flex` and `Grid`
52
- - spacing, scale, radius, color, and icon utilities
51
+ <Panel header={<h2>Title</h2>} contentProps={{ style: { padding: 16 } }}>
52
+ Content
53
+ </Panel>
54
+ ```
53
55
 
54
- These primitives form one visual language. Desktop and Program Views compose
55
- them rather than reimplementing their material or layout behavior.
56
+ Native properties and the forwarded ref target the outer Surface.
57
+ `contentProps` targets the inner Surface; `children` supplies its content.
56
58
 
57
59
  ## Development
58
60
 
@@ -62,13 +64,20 @@ bun run verify
62
64
  ```
63
65
 
64
66
  `verify` checks the contracts, tests the components, builds the package, and
65
- validates its public artifact.
67
+ validates its published shape.
66
68
 
67
- ## Repository boundary
69
+ ## Related repositories
68
70
 
69
- This repository owns visual interpretation and reusable React components. Core
70
- owns Appearance contracts, React owns runtime-neutral state adaptation, and
71
- applications own composition.
71
+ - [`@phreshos/core`](https://github.com/PhreshOS/core) owns Appearance, Theme,
72
+ and the shared values interpreted here.
73
+ - [`@phreshos/react`](https://github.com/PhreshOS/react) owns runtime-neutral
74
+ React state adaptation.
75
+ - [PhreshOS System](https://github.com/PhreshOS/system) composes the visual
76
+ language into the Desktop.
77
+ - [Settings](https://github.com/PhreshOS/settings-program) presents owner-facing
78
+ Appearance controls.
79
+
80
+ ## Contributing
72
81
 
73
82
  See [CONTRIBUTING.md](CONTRIBUTING.md) for the repository workflow and
74
83
  [SECURITY.md](SECURITY.md) for private vulnerability reporting.
package/dist/color.d.ts CHANGED
@@ -8,3 +8,10 @@ export declare function color(value: string): ColorScale;
8
8
  export declare function useColor(value: string): ColorScale;
9
9
  /** Applies material opacity without restricting the source CSS color syntax. */
10
10
  export declare function colorOpacity(value: string, opacity: number): string;
11
+ /** Reads perceptual lightness from a resolved CSS color. */
12
+ export declare function colorLightness(value: string): number;
13
+ /** Orders resolved CSS colors by perceptual lightness, never by their role. */
14
+ export declare function orderColors(first: string, second: string): {
15
+ lighter: string;
16
+ darker: string;
17
+ };
package/dist/color.js CHANGED
@@ -1,4 +1,8 @@
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";
3
+ // Register the CSS color spaces, without bundling unrelated color-model APIs.
4
+ for (const space of [sRGB, sRGB_Linear, HSL, HWB, Lab, LCH, OKLab, OKLCH, P3, A98RGB, ProPhoto, REC_2020, XYZ_D50, XYZ_D65])
5
+ ColorSpace.register(space);
2
6
  /** Derives visual treatments while preserving the concrete value at `base`. */
3
7
  export function color(value) {
4
8
  return Object.freeze({
@@ -18,3 +22,15 @@ export function colorOpacity(value, opacity) {
18
22
  const percentage = Math.round(opacity * 10_000) / 100;
19
23
  return `color-mix(in srgb, ${value} ${percentage}%, transparent)`;
20
24
  }
25
+ /** Reads perceptual lightness from a resolved CSS color. */
26
+ export function colorLightness(value) {
27
+ // A missing CSS channel (`none`) contributes zero outside interpolation.
28
+ return OKLCH.from(parse(value))[0] ?? 0;
29
+ }
30
+ /** Orders resolved CSS colors by perceptual lightness, never by their role. */
31
+ export function orderColors(first, second) {
32
+ const difference = colorLightness(first) - colorLightness(second);
33
+ return difference > 0 || (difference === 0 && first >= second)
34
+ ? { lighter: first, darker: second }
35
+ : { lighter: second, darker: first };
36
+ }
package/dist/main.d.ts CHANGED
@@ -8,6 +8,7 @@ export { AppearanceProvider, useAppearance, useResolveTheme, useTheme, type Appe
8
8
  export { Flex, type FlexProps } from "./flex.js";
9
9
  export { Grid, type GridProps } from "./grid.js";
10
10
  export { Surface, type SurfaceProps } from "./surface.js";
11
+ export { Panel, type PanelProps } from "./panel.js";
11
12
  export { Button, type ButtonProps } from "./button.js";
12
13
  export type { LayoutAlignment, LayoutGap, LayoutJustification } from "./layout.js";
13
14
  export { resolveRadius, type Radius, type RadiusProps } from "./radius.js";
package/dist/main.js CHANGED
@@ -8,6 +8,7 @@ export { AppearanceProvider, useAppearance, useResolveTheme, useTheme } from "./
8
8
  export { Flex } from "./flex.js";
9
9
  export { Grid } from "./grid.js";
10
10
  export { Surface } from "./surface.js";
11
+ export { Panel } from "./panel.js";
11
12
  export { Button } from "./button.js";
12
13
  export { resolveRadius } from "./radius.js";
13
14
  export { resolveSpacing } from "./spacing.js";
@@ -0,0 +1,9 @@
1
+ import { type ReactNode } from "react";
2
+ import { type SurfaceProps } from "./surface.js";
3
+ /** A Surface with an optional header and an inset content Surface. */
4
+ export interface PanelProps extends SurfaceProps {
5
+ readonly header?: ReactNode;
6
+ readonly contentProps?: Omit<SurfaceProps, "children">;
7
+ }
8
+ /** Owns the shared shell layout, not positioning, interaction, or lifecycle. */
9
+ export declare const Panel: import("react").ForwardRefExoticComponent<PanelProps & import("react").RefAttributes<HTMLDivElement>>;
package/dist/panel.js ADDED
@@ -0,0 +1,27 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { forwardRef } from "react";
3
+ import { useAppearance, useResolveTheme } from "./appearance-provider.js";
4
+ import { scale } from "./scale.js";
5
+ import { Surface } from "./surface.js";
6
+ /** Owns the shared shell layout, not positioning, interaction, or lifecycle. */
7
+ export const Panel = forwardRef(function Panel({ header, children, contentProps, style, ...properties }, ref) {
8
+ const appearance = useAppearance();
9
+ const inset = scale(useResolveTheme(appearance.spacing), "small");
10
+ const hasHeader = header !== undefined && header !== null && header !== false;
11
+ return _jsxs(Surface, { ...properties, ref: ref, style: {
12
+ display: "grid",
13
+ gridTemplateRows: hasHeader ? "auto minmax(0, 1fr)" : "minmax(0, 1fr)",
14
+ minWidth: 0,
15
+ minHeight: 0,
16
+ maxHeight: "inherit",
17
+ ...style
18
+ }, children: [header, _jsx(Surface, { ...contentProps, style: {
19
+ position: "relative",
20
+ minWidth: 0,
21
+ minHeight: 0,
22
+ margin: inset,
23
+ marginTop: hasHeader ? 0 : inset,
24
+ overflow: "hidden",
25
+ ...contentProps?.style
26
+ }, children: children })] });
27
+ });
@@ -1,6 +1,7 @@
1
1
  interface SurfaceMaterialProps {
2
2
  readonly color: string;
3
3
  readonly distortion: number;
4
+ readonly foreground: string;
4
5
  readonly grain: number;
5
6
  readonly grainAmount: number;
6
7
  readonly identity: string;
@@ -8,6 +9,6 @@ interface SurfaceMaterialProps {
8
9
  readonly ripples: number;
9
10
  readonly waves: number;
10
11
  }
11
- /** The locally owned SVG paint layer inside one Surface. */
12
- export declare function SurfaceMaterial({ color, distortion, grain, grainAmount, identity, opacity, ripples, waves }: SurfaceMaterialProps): import("react").JSX.Element | null;
12
+ /** The locally owned fill, refraction definition, and glass rim inside one Surface. */
13
+ export declare function SurfaceMaterial({ color, distortion, foreground, grain, grainAmount, identity, opacity, ripples, waves }: SurfaceMaterialProps): import("react").JSX.Element | null;
13
14
  export {};
@@ -1,37 +1,63 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Fragment, useMemo } from "react";
2
+ import { Fragment, useLayoutEffect, useMemo, useRef, useState } from "react";
3
+ import { colorLightness, colorOpacity, orderColors } from "./color.js";
3
4
  import { scale } from "./scale.js";
4
- /** The locally owned SVG paint layer inside one Surface. */
5
- export function SurfaceMaterial({ color, distortion, grain, grainAmount, identity, opacity, ripples, waves }) {
5
+ /** The locally owned fill, refraction definition, and glass rim inside one Surface. */
6
+ export function SurfaceMaterial({ color, distortion, foreground, grain, grainAmount, identity, opacity, ripples, waves }) {
7
+ const element = useRef(null);
8
+ const [colors, setColors] = useState(null);
6
9
  const seed = useMemo(() => seedFrom(identity), [identity]);
7
10
  const hasPaint = opacity > 0;
8
11
  const hasGrain = hasPaint && grain > 0 && grainAmount > 0;
9
12
  const hasDistortion = distortion > 0 || waves > 0 || ripples > 0;
10
13
  const initial = useMemo(() => hasGrain ? grainPaths(seed, grainAmount) : [], [grainAmount, hasGrain, seed]);
14
+ // Resolve variables, currentColor, and CSS expressions in this Surface's
15
+ // own scope before comparing colors. Layout effects settle before paint.
16
+ useLayoutEffect(() => {
17
+ const material = element.current;
18
+ const view = material?.ownerDocument.defaultView;
19
+ if (!material || !view || !hasPaint)
20
+ return;
21
+ const computed = view.getComputedStyle(material);
22
+ const background = computed.fill;
23
+ const foreground = computed.color;
24
+ if (colors?.background === background && colors.foreground === foreground)
25
+ return;
26
+ setColors({ ...orderColors(background, foreground), background, foreground, lightness: Math.max(0, Math.min(1, colorLightness(background))) });
27
+ });
11
28
  if (!hasPaint && !hasDistortion)
12
29
  return null;
13
- return _jsxs("svg", { "data-surface-material": "", "data-surface-border": hasPaint ? "" : undefined, "aria-hidden": "true", focusable: "false", style: {
14
- position: "absolute",
15
- zIndex: -1,
16
- inset: 0,
17
- display: "block",
18
- width: "100%",
19
- height: "100%",
20
- overflow: "hidden",
21
- borderRadius: "inherit",
22
- borderColor: edgeColor(color, opacity),
23
- borderStyle: hasPaint ? "solid" : "none",
24
- borderWidth: hasPaint ? 1 : 0,
25
- boxSizing: "border-box",
26
- pointerEvents: "none"
27
- }, children: [(hasGrain || hasDistortion) && _jsxs("defs", { children: [hasDistortion && _jsx(DistortionFilter, { distortion: distortion, identity: identity, ripples: ripples, seed: seed, waves: waves }), hasGrain && _jsx("pattern", { id: `${identity}-grain`, width: patternSize, height: patternSize, patternUnits: "userSpaceOnUse", children: initial.map((path, tone) => _jsx("path", { "data-surface-grain-tone": tone, d: path, fill: grainTone(color, tone, grain), shapeRendering: "crispEdges" }, tone)) })] }), hasPaint && _jsxs("g", { "data-surface-paint": "", opacity: opacity, children: [_jsx("rect", { "data-surface-base": "", width: "100%", height: "100%", fill: color }), hasGrain && _jsx("rect", { "data-surface-grain": "", width: "100%", height: "100%", fill: `url(#${identity}-grain)`, shapeRendering: "crispEdges" })] })] });
30
+ return _jsxs(Fragment, { children: [_jsxs("svg", { "data-surface-material": "", "aria-hidden": "true", focusable: "false", style: {
31
+ position: "absolute",
32
+ zIndex: -1,
33
+ inset: 0,
34
+ display: "block",
35
+ width: "100%",
36
+ height: "100%",
37
+ overflow: "hidden",
38
+ borderRadius: "inherit",
39
+ boxShadow: hasPaint && colors ? `inset 0 1px 2px ${colorOpacity(colors.lighter, 0.28)}, 0 8px 24px ${colorOpacity(colors.darker, 0.04)}` : undefined,
40
+ pointerEvents: "none"
41
+ }, children: [(hasGrain || hasDistortion) && _jsxs("defs", { children: [hasDistortion && _jsx(DistortionFilter, { distortion: distortion, identity: identity, ripples: ripples, seed: seed, waves: waves }), hasGrain && _jsx("pattern", { id: `${identity}-grain`, width: patternSize, height: patternSize, patternUnits: "userSpaceOnUse", children: initial.map((path, tone) => _jsx("path", { "data-surface-grain-tone": tone, d: path, fill: grainTone(color, tone, grain), shapeRendering: "crispEdges" }, tone)) })] }), hasPaint && _jsxs("g", { "data-surface-paint": "", opacity: opacity, children: [_jsx("rect", { ref: element, "data-surface-base": "", width: "100%", height: "100%", style: { fill: color, color: foreground } }), hasGrain && _jsx("rect", { "data-surface-grain": "", width: "100%", height: "100%", fill: `url(#${identity}-grain)`, shapeRendering: "crispEdges" })] })] }), hasPaint && _jsx("div", { "data-surface-border": "", "aria-hidden": "true", style: {
42
+ position: "absolute",
43
+ // The material belongs behind content; its rim belongs above it.
44
+ // Opaque children must not erase the Surface's rounded edge.
45
+ zIndex: 1,
46
+ inset: 0,
47
+ padding: 1,
48
+ borderRadius: "inherit",
49
+ pointerEvents: "none",
50
+ opacity: Math.min(1, scale(opacity, "xlarge")) * (colors?.lightness ?? 0),
51
+ background: colors ? glassRim(colors.lighter, colors.darker) : undefined,
52
+ WebkitMask: "linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)",
53
+ WebkitMaskComposite: "xor",
54
+ mask: "linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)",
55
+ maskComposite: "exclude"
56
+ } })] });
28
57
  }
29
- function edgeColor(color, opacity) {
30
- const edge = `color-mix(in oklch, ${color} 94%, black)`;
31
- const edgeOpacity = Math.min(1, scale(opacity, "large"));
32
- if (edgeOpacity === 1)
33
- return edge;
34
- return `color-mix(in srgb, ${edge} ${Math.round(edgeOpacity * 10_000) / 100}%, transparent)`;
58
+ function glassRim(light, dark) {
59
+ const edge = `color-mix(in oklch, ${dark} 20%, ${light})`;
60
+ return `linear-gradient(145deg, ${colorOpacity(light, 0.92)}, ${colorOpacity(light, 0.4)} 35%, ${colorOpacity(edge, 0.18)} 55%, ${colorOpacity(light, 0.6)} 85%, ${colorOpacity(light, 0.3)})`;
35
61
  }
36
62
  function DistortionFilter({ distortion, identity, ripples, seed, waves }) {
37
63
  const fields = [
package/dist/surface.js CHANGED
@@ -15,7 +15,7 @@ export const Surface = forwardRef(function Surface({ backdrop, brightness, child
15
15
  const appearance = useAppearance();
16
16
  const background = useResolveTheme(appearance.background);
17
17
  const foreground = useResolveTheme(appearance.foreground);
18
- const radius = useResolveTheme(appearance.radius);
18
+ const radius = scale(useResolveTheme(appearance.radius), "medium");
19
19
  const surface = useResolveTheme(appearance.surface);
20
20
  const identity = `phresh-surface-${useId().replaceAll(":", "")}`;
21
21
  const element = useRef(null);
@@ -36,7 +36,7 @@ export const Surface = forwardRef(function Surface({ backdrop, brightness, child
36
36
  borderRadius: radius,
37
37
  color: foreground,
38
38
  ...style
39
- }, children: [resolved.refracts && _jsx(BackdropLayer, { name: "refraction", filter: `url("#${identity}-distortion")`, zIndex: -3 }), resolved.frost && _jsx(BackdropLayer, { name: "frost", filter: resolved.frost, zIndex: -2 }), _jsx(SurfaceMaterial, { identity: identity, ...resolved.material }), children] });
39
+ }, children: [resolved.refracts && _jsx(BackdropLayer, { name: "refraction", filter: `url("#${identity}-distortion")`, zIndex: -3 }), resolved.frost && _jsx(BackdropLayer, { name: "frost", filter: resolved.frost, zIndex: -2 }), _jsx(SurfaceMaterial, { identity: identity, foreground: foreground, ...resolved.material }), children] });
40
40
  });
41
41
  /** Keeps refraction and native frost in independent compositor passes. */
42
42
  function BackdropLayer({ filter, name, zIndex }) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phreshos/react-ui",
3
- "version": "0.1.15",
3
+ "version": "0.1.16",
4
4
  "description": "React components for coherent PhreshOS Program interfaces.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -52,16 +52,17 @@
52
52
  "prepack": "node --run test && node --run build"
53
53
  },
54
54
  "peerDependencies": {
55
- "@phreshos/core": "^0.1.37",
55
+ "@phreshos/core": "^0.1.41",
56
56
  "react": "^19.2.0",
57
57
  "react-dom": "^19.2.0"
58
58
  },
59
59
  "dependencies": {
60
+ "colorjs.io": "^0.7.1",
60
61
  "react-aria-components": "^1.20.0"
61
62
  },
62
63
  "devDependencies": {
63
64
  "@base-ui/react": "^1.7.0",
64
- "@phreshos/core": "^0.1.37",
65
+ "@phreshos/core": "^0.1.41",
65
66
  "@testing-library/dom": "^10.4.1",
66
67
  "@testing-library/react": "^16.3.0",
67
68
  "@testing-library/user-event": "^14.6.1",