@phreshos/react-ui 0.1.0 → 0.1.2
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 +26 -2
- package/dist/main.d.ts +1 -0
- package/dist/main.js +1 -0
- package/dist/surface-material.d.ts +9 -0
- package/dist/surface-material.js +131 -0
- package/dist/surface.d.ts +30 -0
- package/dist/surface.js +68 -0
- package/dist/theme-provider.d.ts +2 -16
- package/package.json +3 -3
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,
|
|
160
|
-
remains usable without either environment SDK.
|
|
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,131 @@
|
|
|
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
|
+
opacity,
|
|
30
|
+
pointerEvents: "none"
|
|
31
|
+
}, children: [_jsx("defs", { children: _jsx("pattern", { id: identity, width: patternSize, height: patternSize, patternUnits: "userSpaceOnUse", children: initial.map((path, tone) => {
|
|
32
|
+
const channel = Math.round(tone / (toneCount - 1) * 255);
|
|
33
|
+
return _jsx("path", { ref: node => { paths.current[tone] = node; }, "data-surface-grain-tone": tone, d: path, fill: `rgb(${channel}, ${channel}, ${channel})`, shapeRendering: "crispEdges" }, tone);
|
|
34
|
+
}) }) }), _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" }), _jsx("rect", { "data-surface-edge": "", width: "100%", height: "100%", fill: "none", stroke: "rgb(15, 17, 21)", strokeOpacity: "0.08", strokeWidth: "2", vectorEffect: "non-scaling-stroke" })] });
|
|
35
|
+
}
|
|
36
|
+
class AnimationClock {
|
|
37
|
+
#entries = new Set();
|
|
38
|
+
#view;
|
|
39
|
+
#request;
|
|
40
|
+
constructor(view) {
|
|
41
|
+
this.#view = view;
|
|
42
|
+
}
|
|
43
|
+
subscribe(rate, paint) {
|
|
44
|
+
const entry = { rate, paint, frame: -1 };
|
|
45
|
+
this.#entries.add(entry);
|
|
46
|
+
this.#start();
|
|
47
|
+
return () => {
|
|
48
|
+
this.#entries.delete(entry);
|
|
49
|
+
if (this.#entries.size === 0)
|
|
50
|
+
this.#stop();
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
#start() {
|
|
54
|
+
if (this.#request !== undefined)
|
|
55
|
+
return;
|
|
56
|
+
this.#request = this.#view.requestAnimationFrame(this.#tick);
|
|
57
|
+
}
|
|
58
|
+
#stop() {
|
|
59
|
+
if (this.#request === undefined)
|
|
60
|
+
return;
|
|
61
|
+
this.#view.cancelAnimationFrame(this.#request);
|
|
62
|
+
this.#request = undefined;
|
|
63
|
+
}
|
|
64
|
+
#tick = (time) => {
|
|
65
|
+
this.#entries.forEach(entry => {
|
|
66
|
+
const frame = Math.floor(time / 1000 * entry.rate);
|
|
67
|
+
if (frame === entry.frame)
|
|
68
|
+
return;
|
|
69
|
+
entry.frame = frame;
|
|
70
|
+
entry.paint(frame);
|
|
71
|
+
});
|
|
72
|
+
this.#request = this.#entries.size === 0
|
|
73
|
+
? undefined
|
|
74
|
+
: this.#view.requestAnimationFrame(this.#tick);
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
const clocks = new WeakMap();
|
|
78
|
+
function animate(element, rate, paint) {
|
|
79
|
+
const view = element.ownerDocument.defaultView;
|
|
80
|
+
if (!view)
|
|
81
|
+
return;
|
|
82
|
+
let clock = clocks.get(view);
|
|
83
|
+
if (!clock) {
|
|
84
|
+
clock = new AnimationClock(view);
|
|
85
|
+
clocks.set(view, clock);
|
|
86
|
+
}
|
|
87
|
+
return clock.subscribe(rate, paint);
|
|
88
|
+
}
|
|
89
|
+
function grainPaths(seed, frame) {
|
|
90
|
+
const tones = Array.from({ length: toneCount }, () => []);
|
|
91
|
+
const frameX = frame * 19.17;
|
|
92
|
+
const frameY = frame * 7.31;
|
|
93
|
+
for (let y = 0; y < patternSize; y += 1) {
|
|
94
|
+
for (let x = 0; x < patternSize; x += 1) {
|
|
95
|
+
const pointX = x + seed * 41;
|
|
96
|
+
const pointY = y + seed * 17;
|
|
97
|
+
const fine = shaderHash(Math.floor(pointX * 1.18) + frameX, Math.floor(pointY * 1.18) + frameY);
|
|
98
|
+
const clustered = shaderHash(Math.floor(pointX * 0.47) + frameX + 31.7, Math.floor(pointY * 0.47) + frameY + 31.7);
|
|
99
|
+
const value = clamp(fine * 0.8 + clustered * 0.2, 0, 1);
|
|
100
|
+
const tone = Math.min(toneCount - 1, Math.floor(value * toneCount));
|
|
101
|
+
tones[tone]?.push(`M${x} ${y}h1v1h-1z`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return tones.map(tone => tone.join(""));
|
|
105
|
+
}
|
|
106
|
+
function shaderHash(x, y) {
|
|
107
|
+
let red = fract(x * 0.1031);
|
|
108
|
+
let green = fract(y * 0.1031);
|
|
109
|
+
let blue = fract(x * 0.1031);
|
|
110
|
+
const product = red * (green + 33.33) + green * (blue + 33.33) + blue * (red + 33.33);
|
|
111
|
+
red += product;
|
|
112
|
+
green += product;
|
|
113
|
+
blue += product;
|
|
114
|
+
return fract((red + green) * blue);
|
|
115
|
+
}
|
|
116
|
+
function seedFrom(value) {
|
|
117
|
+
let seed = 2166136261;
|
|
118
|
+
for (const character of value) {
|
|
119
|
+
seed ^= character.codePointAt(0) ?? 0;
|
|
120
|
+
seed = Math.imul(seed, 16777619);
|
|
121
|
+
}
|
|
122
|
+
return (seed >>> 0) % 997 + 1;
|
|
123
|
+
}
|
|
124
|
+
function fract(value) {
|
|
125
|
+
return value - Math.floor(value);
|
|
126
|
+
}
|
|
127
|
+
function clamp(value, minimum, maximum) {
|
|
128
|
+
return Math.min(maximum, Math.max(minimum, value));
|
|
129
|
+
}
|
|
130
|
+
const patternSize = 64;
|
|
131
|
+
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>>;
|
package/dist/surface.js
ADDED
|
@@ -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
|
+
}
|
package/dist/theme-provider.d.ts
CHANGED
|
@@ -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():
|
|
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():
|
|
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.
|
|
3
|
+
"version": "0.1.2",
|
|
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.
|
|
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.
|
|
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",
|