@phreshos/react-ui 0.1.10 → 0.1.12

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
@@ -27,14 +27,30 @@ collapses Appearance into a second retained object.
27
27
  An application using the Client SDK composes the packages explicitly:
28
28
 
29
29
  ```tsx
30
+ import { useDesktopPreferences, useSystemAppearance } from "@phreshos/react"
31
+
30
32
  const appearance = useSystemAppearance()
31
- const theme = useSystemTheme()
33
+ const { theme } = useDesktopPreferences()
32
34
 
33
35
  return <AppearanceProvider appearance={appearance} theme={theme}>
34
36
  {children}
35
37
  </AppearanceProvider>
36
38
  ```
37
39
 
40
+ Document color-scheme negotiation belongs to the System iframe and the Client
41
+ HTML document, not to a visual component or React hook.
42
+
43
+ `AppearanceProvider` also owns the native scrollbars in its document. It adds
44
+ no rendered container: one document stylesheet styles a six-pixel rounded
45
+ thumb from Appearance foreground and radius, leaves the track transparent, and
46
+ gives its container five pixels of transparent padding. With a precise
47
+ pointer, the thumb is transparent outside its scrollable area, uses foreground
48
+ at 10% inside it, and rises to 20% directly under the pointer. Touch documents
49
+ retain the 10% thumb. The standardized scrollbar API has no thumb-hover state,
50
+ so its fallback stops at 10%. Standard and WebKit rules are mutually exclusive
51
+ so the standard thin width cannot override the padded geometry. WebKit hover
52
+ also invalidates the scrollbar style to ensure Safari repaints each state.
53
+
38
54
  ## Levels
39
55
 
40
56
  `useScale(value)` and `useColor(value)` derive semantic UI levels from one
@@ -49,14 +65,16 @@ radii require Appearance because their concrete source is `spacing` or
49
65
  ## Surface
50
66
 
51
67
  `Surface` is the shared visual material. It accepts native `div` properties
52
- plus local overrides for color, grain, grain amount, backdrop blur, opacity,
68
+ plus local overrides for grain, grain amount, backdrop blur, opacity,
53
69
  distortion, waves, ripples, saturation, and brightness. Omitted controls derive
54
70
  from the resolved Appearance. A zero-valued optional effect is omitted from the
55
71
  rendered material so disabled work costs nothing.
56
72
 
57
- Each Surface owns its SVG material and border. Backdrop refraction and frost
58
- remain separate compositor layers. Radius and foreground resolve from
59
- Appearance; elevation stays with the surrounding layout.
73
+ Each Surface returns one plain geometry and content container. Its existing SVG
74
+ material paints both the fill and its material-derived border; no separate
75
+ border element is rendered. Backdrop refraction and frost remain separate
76
+ compositor layers. Radius and foreground resolve from Appearance; elevation
77
+ stays with the surrounding layout.
60
78
 
61
79
  ## Components
62
80
 
@@ -1,11 +1,12 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { createContext, useContext } from "react";
3
+ import DocumentScrollbars from "./document-scrollbars.js";
3
4
  const missing = Symbol("AppearanceProvider");
4
5
  const AppearanceContext = createContext(missing);
5
6
  const ThemeContext = createContext(missing);
6
7
  /** Provides unresolved Appearance and one effective Theme to a React subtree. */
7
8
  export function AppearanceProvider({ appearance, children, theme }) {
8
- return _jsx(AppearanceContext.Provider, { value: appearance, children: _jsx(ThemeContext.Provider, { value: theme, children: children }) });
9
+ return _jsx(AppearanceContext.Provider, { value: appearance, children: _jsxs(ThemeContext.Provider, { value: theme, children: [_jsx(DocumentScrollbars, { appearance: appearance, theme: theme }), children] }) });
9
10
  }
10
11
  /** Returns the complete unresolved Appearance supplied by the nearest provider. */
11
12
  export function useAppearance() {
@@ -0,0 +1,6 @@
1
+ import type { Appearance, Theme } from "@phreshos/core";
2
+ /** Applies one Appearance to the complete owning document without rendering. */
3
+ export default function DocumentScrollbars({ appearance, theme }: Readonly<{
4
+ appearance: Appearance;
5
+ theme: Theme;
6
+ }>): null;
@@ -0,0 +1,164 @@
1
+ import { useInsertionEffect, useRef } from "react";
2
+ import { colorOpacity } from "./color.js";
3
+ const properties = {
4
+ thumb: "--phreshos-scrollbar-thumb",
5
+ thumbHover: "--phreshos-scrollbar-thumb-hover",
6
+ size: "--phreshos-scrollbar-size",
7
+ padding: "--phreshos-scrollbar-padding",
8
+ radius: "--phreshos-scrollbar-radius"
9
+ };
10
+ const stylesheet = `
11
+ @supports not selector(::-webkit-scrollbar) {
12
+ * {
13
+ scrollbar-color: var(${properties.thumb}) transparent;
14
+ scrollbar-width: thin;
15
+ }
16
+
17
+ @media (hover: hover) and (pointer: fine) {
18
+ * {
19
+ scrollbar-color: transparent transparent;
20
+ }
21
+
22
+ *:hover {
23
+ scrollbar-color: var(${properties.thumb}) transparent;
24
+ }
25
+ }
26
+
27
+ @media (forced-colors: active) {
28
+ *,
29
+ *:hover {
30
+ scrollbar-color: auto;
31
+ }
32
+ }
33
+ }
34
+
35
+ @supports selector(::-webkit-scrollbar) {
36
+ *::-webkit-scrollbar {
37
+ width: var(${properties.size});
38
+ height: var(${properties.size});
39
+ background: transparent;
40
+ }
41
+
42
+ *::-webkit-scrollbar-track,
43
+ *::-webkit-scrollbar-corner {
44
+ background: transparent;
45
+ }
46
+
47
+ *::-webkit-scrollbar-button {
48
+ display: none;
49
+ }
50
+
51
+ *::-webkit-scrollbar-thumb {
52
+ border: var(${properties.padding}) solid transparent;
53
+ border-radius: var(${properties.radius});
54
+ background-color: var(${properties.thumb});
55
+ background-clip: padding-box;
56
+ }
57
+
58
+ @media (hover: hover) and (pointer: fine) {
59
+ *:hover {
60
+ --phreshos-scrollbar-repaint: ;
61
+ }
62
+
63
+ *::-webkit-scrollbar-thumb {
64
+ background-color: transparent;
65
+ }
66
+
67
+ *:hover::-webkit-scrollbar-thumb {
68
+ background-color: var(${properties.thumb});
69
+ }
70
+
71
+ *::-webkit-scrollbar-thumb:hover {
72
+ background-color: var(${properties.thumbHover});
73
+ }
74
+ }
75
+
76
+ @media (forced-colors: active) {
77
+ *::-webkit-scrollbar-thumb,
78
+ *:hover::-webkit-scrollbar-thumb,
79
+ *::-webkit-scrollbar-thumb:hover {
80
+ background-color: ButtonText;
81
+ }
82
+ }
83
+ }
84
+ `;
85
+ const documents = new WeakMap();
86
+ /** Applies one Appearance to the complete owning document without rendering. */
87
+ export default function DocumentScrollbars({ appearance, theme }) {
88
+ const identity = useRef(Symbol("AppearanceProvider")).current;
89
+ const foreground = theme === "dark" ? appearance.foreground.dark : appearance.foreground.light;
90
+ const padding = 5;
91
+ const thumbSize = 6;
92
+ const size = thumbSize + padding * 2;
93
+ const values = {
94
+ thumb: colorOpacity(foreground, 0.1),
95
+ thumbHover: colorOpacity(foreground, 0.2),
96
+ size: `${size}px`,
97
+ padding: `${padding}px`,
98
+ radius: `${Math.min(appearance.radius.light, padding + thumbSize / 2)}px`
99
+ };
100
+ useInsertionEffect(function () {
101
+ if (typeof document === "undefined")
102
+ return;
103
+ return register(document, identity, values);
104
+ }, []);
105
+ useInsertionEffect(function () {
106
+ if (typeof document !== "undefined")
107
+ update(document, identity, values);
108
+ }, [values.thumb, values.thumbHover, values.size, values.padding, values.radius]);
109
+ return null;
110
+ }
111
+ function register(owner, identity, values) {
112
+ const state = documents.get(owner) ?? create(owner);
113
+ state.providers.set(identity, values);
114
+ apply(state);
115
+ return function () {
116
+ state.providers.delete(identity);
117
+ if (state.providers.size) {
118
+ apply(state);
119
+ return;
120
+ }
121
+ state.style.remove();
122
+ restore(state);
123
+ documents.delete(owner);
124
+ };
125
+ }
126
+ function update(owner, identity, values) {
127
+ const state = documents.get(owner);
128
+ if (!state?.providers.has(identity))
129
+ return;
130
+ state.providers.set(identity, values);
131
+ apply(state);
132
+ }
133
+ function create(owner) {
134
+ const root = owner.documentElement;
135
+ const style = owner.createElement("style");
136
+ const original = new Map(Object.values(properties).map(property => [property, {
137
+ value: root.style.getPropertyValue(property),
138
+ priority: root.style.getPropertyPriority(property)
139
+ }]));
140
+ style.dataset.phreshosScrollbars = "";
141
+ style.textContent = stylesheet;
142
+ owner.head.append(style);
143
+ const state = { root, style, original, providers: new Map() };
144
+ documents.set(owner, state);
145
+ return state;
146
+ }
147
+ function apply(state) {
148
+ const values = [...state.providers.values()].at(-1);
149
+ if (!values)
150
+ return;
151
+ state.root.style.setProperty(properties.thumb, values.thumb);
152
+ state.root.style.setProperty(properties.thumbHover, values.thumbHover);
153
+ state.root.style.setProperty(properties.size, values.size);
154
+ state.root.style.setProperty(properties.padding, values.padding);
155
+ state.root.style.setProperty(properties.radius, values.radius);
156
+ }
157
+ function restore(state) {
158
+ for (const [property, original] of state.original) {
159
+ if (original.value)
160
+ state.root.style.setProperty(property, original.value, original.priority);
161
+ else
162
+ state.root.style.removeProperty(property);
163
+ }
164
+ }
package/dist/main.d.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  export { AppearanceProvider, useAppearance, useResolveTheme, useTheme, type AppearanceProviderProps } from "./appearance-provider.js";
8
8
  export { Flex, type FlexProps } from "./flex.js";
9
9
  export { Grid, type GridProps } from "./grid.js";
10
- export { Surface, type SurfaceColor, type SurfaceProps } from "./surface.js";
10
+ export { Surface, type SurfaceProps } from "./surface.js";
11
11
  export { Button, type ButtonProps } from "./button.js";
12
12
  export type { LayoutAlignment, LayoutGap, LayoutJustification } from "./layout.js";
13
13
  export { resolveRadius, type Radius, type RadiusProps } from "./radius.js";
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Fragment, useMemo } from "react";
3
+ import { scale } from "./scale.js";
3
4
  /** The locally owned SVG paint layer inside one Surface. */
4
5
  export function SurfaceMaterial({ color, distortion, grain, grainAmount, identity, opacity, ripples, waves }) {
5
6
  const seed = useMemo(() => seedFrom(identity), [identity]);
@@ -9,7 +10,7 @@ export function SurfaceMaterial({ color, distortion, grain, grainAmount, identit
9
10
  const initial = useMemo(() => hasGrain ? grainPaths(seed, grainAmount) : [], [grainAmount, hasGrain, seed]);
10
11
  if (!hasPaint && !hasDistortion)
11
12
  return null;
12
- return _jsxs("svg", { "data-surface-material": "", "aria-hidden": "true", focusable: "false", style: {
13
+ return _jsxs("svg", { "data-surface-material": "", "data-surface-border": hasPaint ? "" : undefined, "aria-hidden": "true", focusable: "false", style: {
13
14
  position: "absolute",
14
15
  zIndex: -1,
15
16
  inset: 0,
@@ -18,10 +19,19 @@ export function SurfaceMaterial({ color, distortion, grain, grainAmount, identit
18
19
  height: "100%",
19
20
  overflow: "hidden",
20
21
  borderRadius: "inherit",
22
+ borderColor: edgeColor(color, opacity),
23
+ borderStyle: hasPaint ? "solid" : "none",
24
+ borderWidth: hasPaint ? 1 : 0,
21
25
  boxSizing: "border-box",
22
- opacity,
23
26
  pointerEvents: "none"
24
- }, 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 && _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" })] });
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" })] })] });
28
+ }
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)`;
25
35
  }
26
36
  function DistortionFilter({ distortion, identity, ripples, seed, waves }) {
27
37
  const fields = [
package/dist/surface.d.ts CHANGED
@@ -1,12 +1,7 @@
1
1
  import type { ComponentPropsWithoutRef } from "react";
2
- import { type ColorLevel } from "./color.js";
3
2
  import { type ScaleLevel } from "./scale.js";
4
- /** An Appearance-derived treatment or direct CSS color. */
5
- export type SurfaceColor = ColorLevel | (string & {});
6
3
  /** Native div properties plus controls for the locally owned material. */
7
- export type SurfaceProps = Omit<ComponentPropsWithoutRef<"div">, "color" | "opacity"> & Readonly<{
8
- /** Appearance-derived treatment or direct CSS material color. */
9
- color?: SurfaceColor;
4
+ export type SurfaceProps = Omit<ComponentPropsWithoutRef<"div">, "opacity"> & Readonly<{
10
5
  /** Appearance-derived level or direct grain intensity from zero to one. */
11
6
  grain?: ScaleLevel | number;
12
7
  /** Appearance-derived level or direct retained grain amount from zero to one. */
@@ -27,9 +22,7 @@ export type SurfaceProps = Omit<ComponentPropsWithoutRef<"div">, "color" | "opac
27
22
  brightness?: ScaleLevel | number;
28
23
  }>;
29
24
  /** Contains content above locally owned Surface material layers. */
30
- export declare const Surface: import("react").ForwardRefExoticComponent<Omit<Omit<import("react").DetailedHTMLProps<import("react").HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "ref">, "opacity" | "color"> & Readonly<{
31
- /** Appearance-derived treatment or direct CSS material color. */
32
- color?: SurfaceColor;
25
+ export declare const Surface: import("react").ForwardRefExoticComponent<Omit<Omit<import("react").DetailedHTMLProps<import("react").HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "ref">, "opacity"> & Readonly<{
33
26
  /** Appearance-derived level or direct grain intensity from zero to one. */
34
27
  grain?: ScaleLevel | number;
35
28
  /** Appearance-derived level or direct retained grain amount from zero to one. */
package/dist/surface.js CHANGED
@@ -1,7 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { forwardRef, useCallback, useId, useLayoutEffect, useRef } from "react";
3
3
  import { appearanceLimits } from "@phreshos/core";
4
- import { color as deriveColor } from "./color.js";
5
4
  import { isScaleLevel, scale, scaleMultiplier } from "./scale.js";
6
5
  import { SurfaceMaterial } from "./surface-material.js";
7
6
  import { useAppearance, useResolveTheme } from "./appearance-provider.js";
@@ -12,7 +11,7 @@ const layerStyle = {
12
11
  pointerEvents: "none"
13
12
  };
14
13
  /** Contains content above locally owned Surface material layers. */
15
- export const Surface = forwardRef(function Surface({ backdrop, brightness, children, color, distortion, grain, grainAmount, opacity, ripples, saturation, style, waves, ...properties }, forwardedRef) {
14
+ export const Surface = forwardRef(function Surface({ backdrop, brightness, children, distortion, grain, grainAmount, opacity, ripples, saturation, style, waves, ...properties }, forwardedRef) {
16
15
  const appearance = useAppearance();
17
16
  const background = useResolveTheme(appearance.background);
18
17
  const foreground = useResolveTheme(appearance.foreground);
@@ -27,7 +26,7 @@ export const Surface = forwardRef(function Surface({ backdrop, brightness, child
27
26
  else if (forwardedRef)
28
27
  forwardedRef.current = node;
29
28
  }, [forwardedRef]);
30
- const resolved = resolveSurface({ backdrop, brightness, color, distortion, grain, grainAmount, opacity, ripples, saturation, waves }, background, surface);
29
+ const resolved = resolveSurface({ backdrop, brightness, distortion, grain, grainAmount, opacity, ripples, saturation, waves }, background, surface);
31
30
  useLayoutEffect(() => {
32
31
  const surface = element.current;
33
32
  if (surface)
@@ -37,19 +36,8 @@ export const Surface = forwardRef(function Surface({ backdrop, brightness, child
37
36
  borderRadius: radius,
38
37
  color: foreground,
39
38
  ...style
40
- }, 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(SurfaceBorder, { color: resolved.material.color, opacity: resolveScale("large", resolved.material.opacity, appearanceLimits.surface.opacity) }), _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, ...resolved.material }), children] });
41
40
  });
42
- /** Draws one uniform inset edge from the same color as the Surface material. */
43
- function SurfaceBorder({ color, opacity }) {
44
- const edge = `color-mix(in oklch, ${color} 94%, black)`;
45
- return _jsx("div", { "data-surface-border": "", "aria-hidden": "true", style: {
46
- ...layerStyle,
47
- zIndex: 0,
48
- boxSizing: "border-box",
49
- boxShadow: `inset 0 0 0 1px ${edge}`,
50
- opacity
51
- } });
52
- }
53
41
  /** Keeps refraction and native frost in independent compositor passes. */
54
42
  function BackdropLayer({ filter, name, zIndex }) {
55
43
  return _jsx("div", { "data-surface-backdrop": name, "aria-hidden": "true", style: {
@@ -61,7 +49,7 @@ function BackdropLayer({ filter, name, zIndex }) {
61
49
  }
62
50
  function resolveSurface(values, background, surface) {
63
51
  const material = {
64
- color: resolveColor(values.color, background),
52
+ color: background,
65
53
  distortion: resolveScale(values.distortion, surface.distortion, appearanceLimits.surface.distortion),
66
54
  grain: resolveScale(values.grain, surface.grain, appearanceLimits.surface.grain),
67
55
  grainAmount: resolveScale(values.grainAmount, surface.grainAmount, appearanceLimits.surface.grainAmount),
@@ -83,14 +71,6 @@ function resolveSurface(values, background, surface) {
83
71
  refracts: material.distortion > 0 || material.waves > 0 || material.ripples > 0
84
72
  };
85
73
  }
86
- function resolveColor(value, base) {
87
- if (value === undefined)
88
- return base;
89
- return isColorLevel(value) ? deriveColor(base)[value] : value;
90
- }
91
- function isColorLevel(value) {
92
- return value === "subtle" || value === "soft" || value === "base" || value === "strong" || value === "intense";
93
- }
94
74
  function resolveScale(value, base, range) {
95
75
  const resolved = isScaleLevel(value) ? scale(base, value) : value ?? base;
96
76
  const finite = Number.isFinite(resolved) ? resolved : base;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phreshos/react-ui",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
4
4
  "description": "React components for coherent PhreshOS Program interfaces.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -52,7 +52,7 @@
52
52
  "prepack": "node --run test && node --run build"
53
53
  },
54
54
  "peerDependencies": {
55
- "@phreshos/core": "^0.1.19",
55
+ "@phreshos/core": "^0.1.22",
56
56
  "react": "^19.2.0",
57
57
  "react-dom": "^19.2.0"
58
58
  },
@@ -61,7 +61,7 @@
61
61
  },
62
62
  "devDependencies": {
63
63
  "@base-ui/react": "^1.7.0",
64
- "@phreshos/core": "^0.1.19",
64
+ "@phreshos/core": "^0.1.22",
65
65
  "@testing-library/dom": "^10.4.1",
66
66
  "@testing-library/react": "^16.3.0",
67
67
  "@testing-library/user-event": "^14.6.1",