@phreshos/react-ui 0.1.1 → 0.1.3

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
@@ -82,6 +82,29 @@ const spacing = useScale(theme.spacing)
82
82
  Explicit values such as `4rem` are used directly — passing them through a
83
83
  Theme hook would perform no additional work.
84
84
 
85
+ `Surface` keeps a native `<div>` as its public container and gives every
86
+ instance one locally owned pure-SVG material. The material uses a deterministic
87
+ 64×64 micro-pattern derived from the former shader grain; it creates no canvas,
88
+ WebGL context, or shared texture:
89
+
90
+ ```tsx
91
+ <Surface className="grid rounded-xl shadow-lg">
92
+ ...
93
+ </Surface>
94
+
95
+ <Surface color="strong" grain="large">...</Surface>
96
+ <Surface color="#101114" grain={0.2} animation={8} backdrop={4} opacity={0.9}>...</Surface>
97
+ ```
98
+
99
+ `color` resolves from `Theme.background`; its semantic levels derive from that
100
+ same source and a direct color remains an explicit local override. `grain`,
101
+ `animation`, `backdrop`, and `opacity` resolve from `Theme.surface`, accept
102
+ their semantic levels or direct values, and remain bounded by Core's Theme
103
+ limits. Radius and foreground remain ordinary Theme styles. Backdrop belongs
104
+ to the outer div and emits no filter property when its resolved value is zero.
105
+ Animation defaults to zero; only explicitly animated Surfaces join the internal
106
+ document clock, while every texture and seed remains local to its own Surface.
107
+
85
108
  The Theme stores unrestricted CSS background, foreground, and accent sources.
86
109
  Core derives the fixed `subtle`, `soft`, `base`, `strong`, and `intense`
87
110
  treatments from any supplied color, preserving the value exactly at `base`.
@@ -156,8 +179,9 @@ Pending Buttons remain focusable but cannot activate. Disabled Buttons leave
156
179
  the focus order entirely. The native element defaults to `type="button"`, so
157
180
  placing it inside a form never triggers an accidental submission.
158
181
 
159
- `ThemeProvider` accepts a plain `ThemeProperties` snapshot, so the library
160
- remains usable without either environment SDK. A Program can adapt its
182
+ `ThemeProvider` accepts a plain `ThemeProperties` snapshot, such as Core's
183
+ `standardTheme`, so the library remains usable without either environment SDK.
184
+ A Program can adapt its
161
185
  observable Host value at the application boundary:
162
186
 
163
187
  ```tsx
package/dist/main.d.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  export { ThemeProvider, useTheme, type ThemeProviderProps } from "./theme-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
11
  export { GlassSurface, type GlassSurfaceProps } from "./glass-surface.js";
11
12
  export { Button, type ButtonProps } from "./button.js";
12
13
  export type { LayoutAlignment, LayoutGap, LayoutJustification } from "./layout.js";
package/dist/main.js CHANGED
@@ -7,6 +7,7 @@
7
7
  export { ThemeProvider, useTheme } from "./theme-provider.js";
8
8
  export { Flex } from "./flex.js";
9
9
  export { Grid } from "./grid.js";
10
+ export { Surface } from "./surface.js";
10
11
  export { GlassSurface } from "./glass-surface.js";
11
12
  export { Button } from "./button.js";
12
13
  export { resolveRadius } from "./radius.js";
@@ -0,0 +1,9 @@
1
+ interface SurfaceMaterialProps {
2
+ readonly animation: number;
3
+ readonly color: string;
4
+ readonly grain: number;
5
+ readonly opacity: number;
6
+ }
7
+ /** The locally owned SVG paint layer inside one Surface. */
8
+ export declare function SurfaceMaterial({ animation, color, grain, opacity }: SurfaceMaterialProps): import("react").JSX.Element;
9
+ export {};
@@ -0,0 +1,133 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useId, useMemo, useRef } from "react";
3
+ /** The locally owned SVG paint layer inside one Surface. */
4
+ export function SurfaceMaterial({ animation, color, grain, opacity }) {
5
+ const identity = `phresh-surface-${useId().replaceAll(":", "")}`;
6
+ const seed = useMemo(() => seedFrom(identity), [identity]);
7
+ const initial = useMemo(() => grainPaths(seed, 0), [seed]);
8
+ const svg = useRef(null);
9
+ const paths = useRef([]);
10
+ useEffect(() => {
11
+ paths.current.forEach((path, tone) => path?.setAttribute("d", initial[tone] ?? ""));
12
+ if (animation === 0 || !svg.current)
13
+ return;
14
+ const paint = (frame) => {
15
+ const values = grainPaths(seed, frame);
16
+ paths.current.forEach((path, tone) => path?.setAttribute("d", values[tone] ?? ""));
17
+ };
18
+ return animate(svg.current, animation, paint);
19
+ }, [animation, initial, seed]);
20
+ return _jsxs("svg", { ref: svg, "data-surface-material": "", "aria-hidden": "true", focusable: "false", style: {
21
+ position: "absolute",
22
+ zIndex: -1,
23
+ inset: 0,
24
+ display: "block",
25
+ width: "100%",
26
+ height: "100%",
27
+ overflow: "hidden",
28
+ borderRadius: "inherit",
29
+ border: "1px solid rgba(15, 17, 21, 0.08)",
30
+ boxSizing: "border-box",
31
+ opacity,
32
+ pointerEvents: "none"
33
+ }, children: [_jsx("defs", { children: _jsx("pattern", { id: identity, width: patternSize, height: patternSize, patternUnits: "userSpaceOnUse", children: initial.map((path, tone) => {
34
+ const channel = Math.round(tone / (toneCount - 1) * 255);
35
+ return _jsx("path", { ref: node => { paths.current[tone] = node; }, "data-surface-grain-tone": tone, d: path, fill: `rgb(${channel}, ${channel}, ${channel})`, shapeRendering: "crispEdges" }, tone);
36
+ }) }) }), _jsx("rect", { "data-surface-base": "", width: "100%", height: "100%", fill: color }), _jsx("rect", { "data-surface-grain": "", width: "100%", height: "100%", fill: `url(#${identity})`, opacity: grain, shapeRendering: "crispEdges" })] });
37
+ }
38
+ class AnimationClock {
39
+ #entries = new Set();
40
+ #view;
41
+ #request;
42
+ constructor(view) {
43
+ this.#view = view;
44
+ }
45
+ subscribe(rate, paint) {
46
+ const entry = { rate, paint, frame: -1 };
47
+ this.#entries.add(entry);
48
+ this.#start();
49
+ return () => {
50
+ this.#entries.delete(entry);
51
+ if (this.#entries.size === 0)
52
+ this.#stop();
53
+ };
54
+ }
55
+ #start() {
56
+ if (this.#request !== undefined)
57
+ return;
58
+ this.#request = this.#view.requestAnimationFrame(this.#tick);
59
+ }
60
+ #stop() {
61
+ if (this.#request === undefined)
62
+ return;
63
+ this.#view.cancelAnimationFrame(this.#request);
64
+ this.#request = undefined;
65
+ }
66
+ #tick = (time) => {
67
+ this.#entries.forEach(entry => {
68
+ const frame = Math.floor(time / 1000 * entry.rate);
69
+ if (frame === entry.frame)
70
+ return;
71
+ entry.frame = frame;
72
+ entry.paint(frame);
73
+ });
74
+ this.#request = this.#entries.size === 0
75
+ ? undefined
76
+ : this.#view.requestAnimationFrame(this.#tick);
77
+ };
78
+ }
79
+ const clocks = new WeakMap();
80
+ function animate(element, rate, paint) {
81
+ const view = element.ownerDocument.defaultView;
82
+ if (!view)
83
+ return;
84
+ let clock = clocks.get(view);
85
+ if (!clock) {
86
+ clock = new AnimationClock(view);
87
+ clocks.set(view, clock);
88
+ }
89
+ return clock.subscribe(rate, paint);
90
+ }
91
+ function grainPaths(seed, frame) {
92
+ const tones = Array.from({ length: toneCount }, () => []);
93
+ const frameX = frame * 19.17;
94
+ const frameY = frame * 7.31;
95
+ for (let y = 0; y < patternSize; y += 1) {
96
+ for (let x = 0; x < patternSize; x += 1) {
97
+ const pointX = x + seed * 41;
98
+ const pointY = y + seed * 17;
99
+ const fine = shaderHash(Math.floor(pointX * 1.18) + frameX, Math.floor(pointY * 1.18) + frameY);
100
+ const clustered = shaderHash(Math.floor(pointX * 0.47) + frameX + 31.7, Math.floor(pointY * 0.47) + frameY + 31.7);
101
+ const value = clamp(fine * 0.8 + clustered * 0.2, 0, 1);
102
+ const tone = Math.min(toneCount - 1, Math.floor(value * toneCount));
103
+ tones[tone]?.push(`M${x} ${y}h1v1h-1z`);
104
+ }
105
+ }
106
+ return tones.map(tone => tone.join(""));
107
+ }
108
+ function shaderHash(x, y) {
109
+ let red = fract(x * 0.1031);
110
+ let green = fract(y * 0.1031);
111
+ let blue = fract(x * 0.1031);
112
+ const product = red * (green + 33.33) + green * (blue + 33.33) + blue * (red + 33.33);
113
+ red += product;
114
+ green += product;
115
+ blue += product;
116
+ return fract((red + green) * blue);
117
+ }
118
+ function seedFrom(value) {
119
+ let seed = 2166136261;
120
+ for (const character of value) {
121
+ seed ^= character.codePointAt(0) ?? 0;
122
+ seed = Math.imul(seed, 16777619);
123
+ }
124
+ return (seed >>> 0) % 997 + 1;
125
+ }
126
+ function fract(value) {
127
+ return value - Math.floor(value);
128
+ }
129
+ function clamp(value, minimum, maximum) {
130
+ return Math.min(maximum, Math.max(minimum, value));
131
+ }
132
+ const patternSize = 64;
133
+ const toneCount = 16;
@@ -0,0 +1,30 @@
1
+ import type { ComponentPropsWithoutRef } from "react";
2
+ import { type ColorLevel, type ScaleLevel } from "@phreshos/core";
3
+ /** A Theme-derived treatment or direct CSS color. */
4
+ export type SurfaceColor = ColorLevel | (string & {});
5
+ /** Native div properties plus controls for the locally owned material. */
6
+ export type SurfaceProps = Omit<ComponentPropsWithoutRef<"div">, "color" | "opacity"> & Readonly<{
7
+ /** Theme-derived treatment or direct CSS material color. */
8
+ color?: SurfaceColor;
9
+ /** Theme-derived level or direct grain intensity from zero to one. */
10
+ grain?: ScaleLevel | number;
11
+ /** Theme-derived level or direct grain changes per second from zero to 16. */
12
+ animation?: ScaleLevel | number;
13
+ /** Theme-derived level or direct backdrop blur from zero to 24 CSS pixels. */
14
+ backdrop?: ScaleLevel | number;
15
+ /** Theme-derived level or direct material opacity from zero to one. */
16
+ opacity?: ScaleLevel | number;
17
+ }>;
18
+ /** Contains content above one independent pure-SVG Surface material. */
19
+ export declare const Surface: import("react").ForwardRefExoticComponent<Omit<Omit<import("react").DetailedHTMLProps<import("react").HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "ref">, "color" | "opacity"> & Readonly<{
20
+ /** Theme-derived treatment or direct CSS material color. */
21
+ color?: SurfaceColor;
22
+ /** Theme-derived level or direct grain intensity from zero to one. */
23
+ grain?: ScaleLevel | number;
24
+ /** Theme-derived level or direct grain changes per second from zero to 16. */
25
+ animation?: ScaleLevel | number;
26
+ /** Theme-derived level or direct backdrop blur from zero to 24 CSS pixels. */
27
+ backdrop?: ScaleLevel | number;
28
+ /** Theme-derived level or direct material opacity from zero to one. */
29
+ opacity?: ScaleLevel | number;
30
+ }> & import("react").RefAttributes<HTMLDivElement>>;
@@ -0,0 +1,68 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { forwardRef, useCallback, useLayoutEffect, useRef } from "react";
3
+ import { color as deriveColor, isScaleLevel, scale, themeLimits } from "@phreshos/core";
4
+ import { SurfaceMaterial } from "./surface-material.js";
5
+ import { useTheme } from "./theme-provider.js";
6
+ /** Contains content above one independent pure-SVG Surface material. */
7
+ export const Surface = forwardRef(function Surface({ animation, backdrop, children, color, grain, opacity, style, ...properties }, forwardedRef) {
8
+ const theme = useTheme();
9
+ const element = useRef(null);
10
+ const ref = useCallback((node) => {
11
+ element.current = node;
12
+ if (typeof forwardedRef === "function")
13
+ forwardedRef(node);
14
+ else if (forwardedRef)
15
+ forwardedRef.current = node;
16
+ }, [forwardedRef]);
17
+ const resolvedColor = resolveColor(color, theme.background);
18
+ const resolvedGrain = resolveScale(grain, theme.surface.grain, themeLimits.surface.grain);
19
+ const resolvedAnimation = resolveScale(animation, theme.surface.animation, themeLimits.surface.animation);
20
+ const resolvedBackdrop = resolveScale(backdrop, theme.surface.backdrop, themeLimits.surface.backdrop);
21
+ const resolvedOpacity = resolveScale(opacity, theme.surface.opacity, themeLimits.surface.opacity);
22
+ const blur = resolvedBackdrop === 0 ? {} : {
23
+ backdropFilter: `blur(${resolvedBackdrop}px)`,
24
+ WebkitBackdropFilter: `blur(${resolvedBackdrop}px)`
25
+ };
26
+ useLayoutEffect(() => {
27
+ const surface = element.current;
28
+ if (surface)
29
+ return prepareSurfaceLayout(surface);
30
+ });
31
+ return _jsxs("div", { ...properties, ref: ref, style: {
32
+ borderRadius: theme.radius,
33
+ color: theme.foreground,
34
+ ...blur,
35
+ ...style
36
+ }, children: [_jsx(SurfaceMaterial, { animation: resolvedAnimation, color: resolvedColor, grain: resolvedGrain, opacity: resolvedOpacity }), children] });
37
+ });
38
+ function resolveColor(value, base) {
39
+ if (value === undefined)
40
+ return base;
41
+ return isColorLevel(value) ? deriveColor(base)[value] : value;
42
+ }
43
+ function isColorLevel(value) {
44
+ return value === "subtle" || value === "soft" || value === "base" || value === "strong" || value === "intense";
45
+ }
46
+ function resolveScale(value, base, range) {
47
+ const resolved = isScaleLevel(value) ? scale(base, value) : value ?? base;
48
+ const finite = Number.isFinite(resolved) ? resolved : base;
49
+ return Math.min(range.maximum, Math.max(range.minimum, finite));
50
+ }
51
+ function prepareSurfaceLayout(element) {
52
+ const view = element.ownerDocument.defaultView;
53
+ const computed = view?.getComputedStyle(element);
54
+ const position = element.style.position;
55
+ const isolation = element.style.isolation;
56
+ const ownsPosition = computed?.position === "static";
57
+ const ownsIsolation = computed?.isolation !== "isolate";
58
+ if (ownsPosition)
59
+ element.style.position = "relative";
60
+ if (ownsIsolation)
61
+ element.style.isolation = "isolate";
62
+ return () => {
63
+ if (ownsPosition && element.style.position === "relative")
64
+ element.style.position = position;
65
+ if (ownsIsolation && element.style.isolation === "isolate")
66
+ element.style.isolation = isolation;
67
+ };
68
+ }
@@ -3,23 +3,9 @@ import type { ThemeProperties } from "@phreshos/core";
3
3
  /** Provides the nearest Theme value to one React subtree. */
4
4
  export declare function ThemeProvider({ children, theme }: ThemeProviderProps): import("react").JSX.Element;
5
5
  /** Returns the complete snapshot supplied by the nearest ThemeProvider. */
6
- export declare function useTheme(): Readonly<{
7
- background: string;
8
- foreground: string;
9
- accent: string;
10
- spacing: number;
11
- radius: number;
12
- glass: import("@phreshos/core").ThemeGlass;
13
- }>;
6
+ export declare function useTheme(): ThemeProperties;
14
7
  /** Internal optional read used by primitives with both raw and themed values. */
15
- export declare function useThemeIfAvailable(): Readonly<{
16
- background: string;
17
- foreground: string;
18
- accent: string;
19
- spacing: number;
20
- radius: number;
21
- glass: import("@phreshos/core").ThemeGlass;
22
- }> | null;
8
+ export declare function useThemeIfAvailable(): ThemeProperties | null;
23
9
  /** Properties accepted by ThemeProvider. */
24
10
  export interface ThemeProviderProps {
25
11
  /** Content that receives this Theme instead of any outer Theme. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phreshos/react-ui",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
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.4",
55
+ "@phreshos/core": "^0.1.7",
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.4",
64
+ "@phreshos/core": "^0.1.7",
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",