@phreshos/react-ui 0.1.11 → 0.1.13

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,21 +27,29 @@ 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 { useDocumentColorScheme } from "@phreshos/react-ui"
30
+ import { useDesktopPreferences, useSystemAppearance } from "@phreshos/react"
31
31
 
32
32
  const appearance = useSystemAppearance()
33
33
  const { theme } = useDesktopPreferences()
34
- useDocumentColorScheme(theme)
35
34
 
36
35
  return <AppearanceProvider appearance={appearance} theme={theme}>
37
36
  {children}
38
37
  </AppearanceProvider>
39
38
  ```
40
39
 
41
- `useDocumentColorScheme(theme)` explicitly synchronizes the document root's
42
- native `color-scheme` and restores its previous value on unmount. Call it once
43
- at the application root; reading Theme or Desktop preferences has no document
44
- side effect.
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.
45
53
 
46
54
  ## Levels
47
55
 
@@ -57,14 +65,16 @@ radii require Appearance because their concrete source is `spacing` or
57
65
  ## Surface
58
66
 
59
67
  `Surface` is the shared visual material. It accepts native `div` properties
60
- plus local overrides for color, grain, grain amount, backdrop blur, opacity,
68
+ plus local overrides for grain, grain amount, backdrop blur, opacity,
61
69
  distortion, waves, ripples, saturation, and brightness. Omitted controls derive
62
70
  from the resolved Appearance. A zero-valued optional effect is omitted from the
63
71
  rendered material so disabled work costs nothing.
64
72
 
65
- Each Surface owns its SVG material and border. Backdrop refraction and frost
66
- remain separate compositor layers. Radius and foreground resolve from
67
- 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.
68
78
 
69
79
  ## Components
70
80
 
@@ -73,6 +83,5 @@ Appearance; elevation stays with the surrounding layout.
73
83
  - `Flex` and `Grid`: small layout primitives that preserve native props.
74
84
  - `AppearanceProvider`, `useAppearance`, `useTheme`, `useResolveTheme`:
75
85
  environment-neutral appearance composition.
76
- - `useDocumentColorScheme`: explicit browser document integration.
77
86
  - `useScale`, `useColor`, `resolveSpacing`, `resolveRadius`: explicit visual
78
87
  derivation helpers.
@@ -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
@@ -5,7 +5,6 @@
5
5
  * behavior contract has been established by the package's tests.
6
6
  */
7
7
  export { AppearanceProvider, useAppearance, useResolveTheme, useTheme, type AppearanceProviderProps } from "./appearance-provider.js";
8
- export { default as useDocumentColorScheme } from "./document-color-scheme.js";
9
8
  export { Flex, type FlexProps } from "./flex.js";
10
9
  export { Grid, type GridProps } from "./grid.js";
11
10
  export { Surface, type SurfaceProps } from "./surface.js";
package/dist/main.js CHANGED
@@ -5,7 +5,6 @@
5
5
  * behavior contract has been established by the package's tests.
6
6
  */
7
7
  export { AppearanceProvider, useAppearance, useResolveTheme, useTheme } from "./appearance-provider.js";
8
- export { default as useDocumentColorScheme } from "./document-color-scheme.js";
9
8
  export { Flex } from "./flex.js";
10
9
  export { Grid } from "./grid.js";
11
10
  export { Surface } from "./surface.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.js CHANGED
@@ -36,19 +36,8 @@ 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(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] });
40
40
  });
41
- /** Draws one uniform inset edge from the same color as the Surface material. */
42
- function SurfaceBorder({ color, opacity }) {
43
- const edge = `color-mix(in oklch, ${color} 94%, black)`;
44
- return _jsx("div", { "data-surface-border": "", "aria-hidden": "true", style: {
45
- ...layerStyle,
46
- zIndex: 0,
47
- boxSizing: "border-box",
48
- boxShadow: `inset 0 0 0 1px ${edge}`,
49
- opacity
50
- } });
51
- }
52
41
  /** Keeps refraction and native frost in independent compositor passes. */
53
42
  function BackdropLayer({ filter, name, zIndex }) {
54
43
  return _jsx("div", { "data-surface-backdrop": name, "aria-hidden": "true", style: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phreshos/react-ui",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "description": "React components for coherent PhreshOS Program interfaces.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -61,7 +61,7 @@
61
61
  },
62
62
  "devDependencies": {
63
63
  "@base-ui/react": "^1.7.0",
64
- "@phreshos/core": "^0.1.22",
64
+ "@phreshos/core": "^0.1.28",
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",
@@ -1,3 +0,0 @@
1
- import type { Theme } from "@phreshos/core";
2
- /** Keeps the document root's native color scheme aligned with one effective Theme. */
3
- export default function useDocumentColorScheme(theme: Theme): void;
@@ -1,10 +0,0 @@
1
- import { useLayoutEffect } from "react";
2
- /** Keeps the document root's native color scheme aligned with one effective Theme. */
3
- export default function useDocumentColorScheme(theme) {
4
- useLayoutEffect(() => {
5
- const root = document.documentElement;
6
- const previous = root.style.colorScheme;
7
- root.style.colorScheme = theme;
8
- return () => { root.style.colorScheme = previous; };
9
- }, [theme]);
10
- }