@phreshos/react-ui 0.1.4 → 0.1.6

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
@@ -83,9 +83,10 @@ Explicit values such as `4rem` are used directly — passing them through a
83
83
  Theme hook would perform no additional work.
84
84
 
85
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:
86
+ instance one locally owned pure-SVG material plus only the backdrop layers its
87
+ settings enable. The material uses a deterministic 64×64 micro-pattern derived
88
+ from the former shader grain; it creates no canvas, WebGL context, or shared
89
+ texture:
89
90
 
90
91
  ```tsx
91
92
  <Surface className="grid rounded-xl shadow-lg">
@@ -94,16 +95,27 @@ WebGL context, or shared texture:
94
95
 
95
96
  <Surface color="strong" grain="large">...</Surface>
96
97
  <Surface color="#101114" grain={0.2} animation={8} backdrop={4} opacity={0.9}>...</Surface>
98
+ <Surface distortion={70} waves={8} ripples={4} saturation={1.4} brightness={1.04}>...</Surface>
97
99
  ```
98
100
 
99
101
  `color` resolves from `Theme.background`; its semantic levels derive from that
100
102
  same source and a direct color remains an explicit local override. `grain`,
101
- `animation`, `backdrop`, and `opacity` resolve from `Theme.surface`, accept
103
+ `grainAmount`, `animation`, `backdrop`, `opacity`, `distortion`, `waves`,
104
+ `ripples`, `saturation`, and `brightness` resolve from `Theme.surface`, accept
102
105
  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.
106
+ limits. Radius and foreground remain ordinary Theme styles. Grain intensity
107
+ controls tonal distance while grain amount controls retained cell density.
108
+ Each Surface owns one uniform one-pixel inset edge derived from its resolved
109
+ material color; consumers supply only their radius and do not redraw the edge.
110
+ When either is zero, Surface creates no grain pattern or paths. Refraction and
111
+ native frost use independent backdrop layers so blur does not soften the
112
+ displaced image. Enabled organic, wave, and ripple fields are combined
113
+ mathematically before one displacement pass; each zero-valued field is absent,
114
+ and the filter and refraction layer are absent when all three are zero.
115
+ Backdrop blur emits no CSS function at zero. Neutral saturation and brightness
116
+ at one are also omitted. Animation defaults to zero; only a Surface with
117
+ visible grain and a positive rate joins the internal document clock, while
118
+ every texture and seed remains local to its own Surface.
107
119
 
108
120
  The Theme stores unrestricted CSS background, foreground, and accent sources.
109
121
  Core derives the fixed `subtle`, `soft`, `base`, `strong`, and `intense`
@@ -1,9 +1,14 @@
1
1
  interface SurfaceMaterialProps {
2
2
  readonly animation: number;
3
3
  readonly color: string;
4
+ readonly distortion: number;
4
5
  readonly grain: number;
6
+ readonly grainAmount: number;
7
+ readonly identity: string;
5
8
  readonly opacity: number;
9
+ readonly ripples: number;
10
+ readonly waves: number;
6
11
  }
7
12
  /** The locally owned SVG paint layer inside one Surface. */
8
- export declare function SurfaceMaterial({ animation, color, grain, opacity }: SurfaceMaterialProps): import("react").JSX.Element;
13
+ export declare function SurfaceMaterial({ animation, color, distortion, grain, grainAmount, identity, opacity, ripples, waves }: SurfaceMaterialProps): import("react").JSX.Element | null;
9
14
  export {};
@@ -1,22 +1,26 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useEffect, useId, useMemo, useRef } from "react";
2
+ import { Fragment, useEffect, useMemo, useRef } from "react";
3
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(":", "")}`;
4
+ export function SurfaceMaterial({ animation, color, distortion, grain, grainAmount, identity, opacity, ripples, waves }) {
6
5
  const seed = useMemo(() => seedFrom(identity), [identity]);
7
- const initial = useMemo(() => grainPaths(seed, 0), [seed]);
6
+ const hasPaint = opacity > 0;
7
+ const hasGrain = hasPaint && grain > 0 && grainAmount > 0;
8
+ const hasDistortion = distortion > 0 || waves > 0 || ripples > 0;
9
+ const initial = useMemo(() => hasGrain ? grainPaths(seed, 0, grainAmount) : [], [grainAmount, hasGrain, seed]);
8
10
  const svg = useRef(null);
9
11
  const paths = useRef([]);
10
12
  useEffect(() => {
11
13
  paths.current.forEach((path, tone) => path?.setAttribute("d", initial[tone] ?? ""));
12
- if (animation === 0 || !svg.current)
14
+ if (animation === 0 || !hasGrain || !svg.current)
13
15
  return;
14
16
  const paint = (frame) => {
15
- const values = grainPaths(seed, frame);
17
+ const values = grainPaths(seed, frame, grainAmount);
16
18
  paths.current.forEach((path, tone) => path?.setAttribute("d", values[tone] ?? ""));
17
19
  };
18
20
  return animate(svg.current, animation, paint);
19
- }, [animation, initial, seed]);
21
+ }, [animation, grainAmount, hasGrain, initial, seed]);
22
+ if (!hasPaint && !hasDistortion)
23
+ return null;
20
24
  return _jsxs("svg", { ref: svg, "data-surface-material": "", "aria-hidden": "true", focusable: "false", style: {
21
25
  position: "absolute",
22
26
  zIndex: -1,
@@ -26,14 +30,33 @@ export function SurfaceMaterial({ animation, color, grain, opacity }) {
26
30
  height: "100%",
27
31
  overflow: "hidden",
28
32
  borderRadius: "inherit",
29
- border: "1px solid rgba(15, 17, 21, 0.08)",
30
33
  boxSizing: "border-box",
31
34
  opacity,
32
35
  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" })] });
36
+ }, 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", { ref: node => { paths.current[tone] = node; }, "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" })] });
37
+ }
38
+ function DistortionFilter({ distortion, identity, ripples, seed, waves }) {
39
+ const fields = [
40
+ distortion > 0 && { name: "organic", strength: distortion, x: "R" },
41
+ waves > 0 && { name: "waves", strength: waves, x: "R" },
42
+ ripples > 0 && { name: "ripples", strength: ripples, x: "B" }
43
+ ].filter((field) => Boolean(field));
44
+ const scale = fields.reduce((total, field) => total + field.strength, 0);
45
+ const weighted = fields.map(field => `${identity}-${field.name}-weighted`);
46
+ const map = `${identity}-distortion-map`;
47
+ return _jsxs("filter", { id: `${identity}-distortion`, "data-surface-distortion": "", x: "-20%", y: "-20%", width: "140%", height: "140%", colorInterpolationFilters: "sRGB", children: [distortion > 0 && _jsxs(Fragment, { children: [_jsx("feTurbulence", { "data-surface-distortion-noise": "", "data-surface-distortion-field": "organic", type: "fractalNoise", baseFrequency: "0.008 0.008", numOctaves: 2, seed: 92, result: `${identity}-organic-noise` }), _jsx("feGaussianBlur", { in: `${identity}-organic-noise`, stdDeviation: 2, result: `${identity}-organic-noise-blurred` })] }), waves > 0 && _jsx(Fragment, { children: _jsx("feTurbulence", { "data-surface-distortion-field": "waves", type: "turbulence", baseFrequency: "0.006 0.045", numOctaves: 1, seed: seed + 17, result: `${identity}-waves-noise` }) }), ripples > 0 && _jsx(Fragment, { children: _jsx("feTurbulence", { "data-surface-distortion-field": "ripples", type: "turbulence", baseFrequency: "0.055", numOctaves: 2, seed: seed + 31, result: `${identity}-ripples-noise` }) }), fields.map(field => _jsx("feColorMatrix", { "data-surface-distortion-weight": field.name, in: `${identity}-${field.name}-noise${field.name === "organic" ? "-blurred" : ""}`, type: "matrix", values: weightMatrix(field.x, field.strength / scale), result: `${identity}-${field.name}-weighted` }, field.name)), weighted.slice(1).map((field, index) => _jsx("feComposite", { "data-surface-distortion-combine": "", in: index === 0 ? weighted[0] : `${identity}-distortion-sum-${index}`, in2: field, operator: "arithmetic", k2: 1, k3: 1, result: index === weighted.length - 2 ? map : `${identity}-distortion-sum-${index + 1}` }, field)), _jsx("feDisplacementMap", { "data-surface-distortion-stage": "combined", in: "SourceGraphic", in2: weighted.length === 1 ? weighted[0] : map, scale: scale, xChannelSelector: "R", yChannelSelector: "G" })] });
48
+ }
49
+ function weightMatrix(x, weight) {
50
+ const red = x === "R" ? `${weight} 0 0 0 0` : `0 0 ${weight} 0 0`;
51
+ return `${red}
52
+ 0 ${weight} 0 0 0
53
+ 0 0 0 0 0
54
+ 0 0 0 1 0`;
55
+ }
56
+ function grainTone(color, tone, intensity) {
57
+ const channel = Math.round(tone / (toneCount - 1) * 255);
58
+ const percentage = Math.round(intensity * 10_000) / 100;
59
+ return `color-mix(in srgb, ${color} ${100 - percentage}%, rgb(${channel} ${channel} ${channel}) ${percentage}%)`;
37
60
  }
38
61
  class AnimationClock {
39
62
  #entries = new Set();
@@ -88,7 +111,7 @@ function animate(element, rate, paint) {
88
111
  }
89
112
  return clock.subscribe(rate, paint);
90
113
  }
91
- function grainPaths(seed, frame) {
114
+ function grainPaths(seed, frame, amount) {
92
115
  const tones = Array.from({ length: toneCount }, () => []);
93
116
  const frameX = frame * 19.17;
94
117
  const frameY = frame * 7.31;
@@ -96,6 +119,9 @@ function grainPaths(seed, frame) {
96
119
  for (let x = 0; x < patternSize; x += 1) {
97
120
  const pointX = x + seed * 41;
98
121
  const pointY = y + seed * 17;
122
+ const presence = shaderHash(pointX + frameX + 71.9, pointY + frameY + 13.7);
123
+ if (presence > amount)
124
+ continue;
99
125
  const fine = shaderHash(Math.floor(pointX * 1.18) + frameX, Math.floor(pointY * 1.18) + frameY);
100
126
  const clustered = shaderHash(Math.floor(pointX * 0.47) + frameX + 31.7, Math.floor(pointY * 0.47) + frameY + 31.7);
101
127
  const value = clamp(fine * 0.8 + clustered * 0.2, 0, 1);
package/dist/surface.d.ts CHANGED
@@ -8,23 +8,47 @@ export type SurfaceProps = Omit<ComponentPropsWithoutRef<"div">, "color" | "opac
8
8
  color?: SurfaceColor;
9
9
  /** Theme-derived level or direct grain intensity from zero to one. */
10
10
  grain?: ScaleLevel | number;
11
+ /** Theme-derived level or direct retained grain amount from zero to one. */
12
+ grainAmount?: ScaleLevel | number;
11
13
  /** Theme-derived level or direct grain changes per second from zero to 16. */
12
14
  animation?: ScaleLevel | number;
13
15
  /** Theme-derived level or direct backdrop blur from zero to 24 CSS pixels. */
14
16
  backdrop?: ScaleLevel | number;
15
17
  /** Theme-derived level or direct material opacity from zero to one. */
16
18
  opacity?: ScaleLevel | number;
19
+ /** Theme-derived level or direct organic displacement from zero to 140 pixels. */
20
+ distortion?: ScaleLevel | number;
21
+ /** Theme-derived level or direct directional displacement from zero to 40 pixels. */
22
+ waves?: ScaleLevel | number;
23
+ /** Theme-derived level or direct ripple displacement from zero to 40 pixels. */
24
+ ripples?: ScaleLevel | number;
25
+ /** Theme-derived level or direct backdrop saturation multiplier. */
26
+ saturation?: ScaleLevel | number;
27
+ /** Theme-derived level or direct backdrop brightness multiplier. */
28
+ brightness?: ScaleLevel | number;
17
29
  }>;
18
- /** Contains content above one independent pure-SVG Surface material. */
30
+ /** Contains content above locally owned Surface material layers. */
19
31
  export declare const Surface: import("react").ForwardRefExoticComponent<Omit<Omit<import("react").DetailedHTMLProps<import("react").HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "ref">, "color" | "opacity"> & Readonly<{
20
32
  /** Theme-derived treatment or direct CSS material color. */
21
33
  color?: SurfaceColor;
22
34
  /** Theme-derived level or direct grain intensity from zero to one. */
23
35
  grain?: ScaleLevel | number;
36
+ /** Theme-derived level or direct retained grain amount from zero to one. */
37
+ grainAmount?: ScaleLevel | number;
24
38
  /** Theme-derived level or direct grain changes per second from zero to 16. */
25
39
  animation?: ScaleLevel | number;
26
40
  /** Theme-derived level or direct backdrop blur from zero to 24 CSS pixels. */
27
41
  backdrop?: ScaleLevel | number;
28
42
  /** Theme-derived level or direct material opacity from zero to one. */
29
43
  opacity?: ScaleLevel | number;
44
+ /** Theme-derived level or direct organic displacement from zero to 140 pixels. */
45
+ distortion?: ScaleLevel | number;
46
+ /** Theme-derived level or direct directional displacement from zero to 40 pixels. */
47
+ waves?: ScaleLevel | number;
48
+ /** Theme-derived level or direct ripple displacement from zero to 40 pixels. */
49
+ ripples?: ScaleLevel | number;
50
+ /** Theme-derived level or direct backdrop saturation multiplier. */
51
+ saturation?: ScaleLevel | number;
52
+ /** Theme-derived level or direct backdrop brightness multiplier. */
53
+ brightness?: ScaleLevel | number;
30
54
  }> & import("react").RefAttributes<HTMLDivElement>>;
package/dist/surface.js CHANGED
@@ -1,40 +1,83 @@
1
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";
2
+ import { forwardRef, useCallback, useId, useLayoutEffect, useRef } from "react";
3
+ import { color as deriveColor, isScaleLevel, scale, scaleMultiplier, themeLimits } from "@phreshos/core";
4
4
  import { SurfaceMaterial } from "./surface-material.js";
5
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) {
6
+ const layerStyle = {
7
+ position: "absolute",
8
+ inset: 0,
9
+ borderRadius: "inherit",
10
+ pointerEvents: "none"
11
+ };
12
+ /** Contains content above locally owned Surface material layers. */
13
+ export const Surface = forwardRef(function Surface({ animation, backdrop, brightness, children, color, distortion, grain, grainAmount, opacity, ripples, saturation, style, waves, ...properties }, forwardedRef) {
8
14
  const theme = useTheme();
15
+ const identity = `phresh-surface-${useId().replaceAll(":", "")}`;
9
16
  const element = useRef(null);
10
- const ref = useCallback((node) => {
17
+ const capture = useCallback((node) => {
11
18
  element.current = node;
12
19
  if (typeof forwardedRef === "function")
13
20
  forwardedRef(node);
14
21
  else if (forwardedRef)
15
22
  forwardedRef.current = node;
16
23
  }, [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
- };
24
+ const resolved = resolveSurface({ animation, backdrop, brightness, color, distortion, grain, grainAmount, opacity, ripples, saturation, waves }, theme);
26
25
  useLayoutEffect(() => {
27
26
  const surface = element.current;
28
27
  if (surface)
29
28
  return prepareSurfaceLayout(surface);
30
29
  });
31
- return _jsxs("div", { ...properties, ref: ref, style: {
30
+ return _jsxs("div", { ...properties, ref: capture, style: {
32
31
  borderRadius: theme.radius,
33
32
  color: theme.foreground,
34
- ...blur,
35
33
  ...style
36
- }, children: [_jsx(SurfaceMaterial, { animation: resolvedAnimation, color: resolvedColor, grain: resolvedGrain, opacity: resolvedOpacity }), children] });
34
+ }, 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, themeLimits.surface.opacity) }), _jsx(SurfaceMaterial, { identity: identity, ...resolved.material }), children] });
37
35
  });
36
+ /** Draws one uniform inset edge from the same color as the Surface material. */
37
+ function SurfaceBorder({ color, opacity }) {
38
+ const edge = `color-mix(in oklch, ${color} 94%, black)`;
39
+ return _jsx("div", { "data-surface-border": "", "aria-hidden": "true", style: {
40
+ ...layerStyle,
41
+ zIndex: 0,
42
+ boxSizing: "border-box",
43
+ boxShadow: `inset 0 0 0 1px ${edge}`,
44
+ opacity
45
+ } });
46
+ }
47
+ /** Keeps refraction and native frost in independent compositor passes. */
48
+ function BackdropLayer({ filter, name, zIndex }) {
49
+ return _jsx("div", { "data-surface-backdrop": name, "aria-hidden": "true", style: {
50
+ ...layerStyle,
51
+ zIndex,
52
+ backdropFilter: filter,
53
+ WebkitBackdropFilter: filter
54
+ } });
55
+ }
56
+ function resolveSurface(values, theme) {
57
+ const material = {
58
+ animation: resolveScale(values.animation, theme.surface.animation, themeLimits.surface.animation),
59
+ color: resolveColor(values.color, theme.background),
60
+ distortion: resolveScale(values.distortion, theme.surface.distortion, themeLimits.surface.distortion),
61
+ grain: resolveScale(values.grain, theme.surface.grain, themeLimits.surface.grain),
62
+ grainAmount: resolveScale(values.grainAmount, theme.surface.grainAmount, themeLimits.surface.grainAmount),
63
+ opacity: resolveScale(values.opacity, theme.surface.opacity, themeLimits.surface.opacity),
64
+ ripples: resolveScale(values.ripples, theme.surface.ripples, themeLimits.surface.ripples),
65
+ waves: resolveScale(values.waves, theme.surface.waves, themeLimits.surface.waves)
66
+ };
67
+ const backdrop = resolveScale(values.backdrop, theme.surface.backdrop, themeLimits.surface.backdrop);
68
+ const saturation = resolveMultiplier(values.saturation, theme.surface.saturation, themeLimits.surface.saturation);
69
+ const brightness = resolveMultiplier(values.brightness, theme.surface.brightness, themeLimits.surface.brightness);
70
+ const frost = [
71
+ backdrop === 0 ? "" : `blur(${backdrop}px)`,
72
+ saturation === 1 ? "" : `saturate(${saturation})`,
73
+ brightness === 1 ? "" : `brightness(${brightness})`
74
+ ].filter(Boolean).join(" ");
75
+ return {
76
+ material,
77
+ frost,
78
+ refracts: material.distortion > 0 || material.waves > 0 || material.ripples > 0
79
+ };
80
+ }
38
81
  function resolveColor(value, base) {
39
82
  if (value === undefined)
40
83
  return base;
@@ -48,6 +91,11 @@ function resolveScale(value, base, range) {
48
91
  const finite = Number.isFinite(resolved) ? resolved : base;
49
92
  return Math.min(range.maximum, Math.max(range.minimum, finite));
50
93
  }
94
+ function resolveMultiplier(value, base, range) {
95
+ const resolved = isScaleLevel(value) ? scaleMultiplier(base, value) : value ?? base;
96
+ const finite = Number.isFinite(resolved) ? resolved : base;
97
+ return Math.min(range.maximum, Math.max(range.minimum, finite));
98
+ }
51
99
  function prepareSurfaceLayout(element) {
52
100
  const view = element.ownerDocument.defaultView;
53
101
  const computed = view?.getComputedStyle(element);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phreshos/react-ui",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
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.8",
55
+ "@phreshos/core": "^0.1.13",
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.8",
64
+ "@phreshos/core": "^0.1.13",
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",