Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
b8f05f9
FE-43: Add alternative OBB and polar distance map filters for refractive
kube Mar 10, 2026
439cd72
PARTIAL
kube Mar 15, 2026
bc3f017
FE-518: Update stories to use renamed camelCase surface equation exports
kube Mar 16, 2026
516651b
FE-43: Add switchable background to filter stories
kube Mar 16, 2026
5547f30
FE-43: Implement polar coordinate indirection filter and remove specular
Mar 19, 2026
341070a
FE-43: Fix hardcoded borderRadius in filter stories
Mar 19, 2026
df5b31a
FE-43: Add hi-res single-image polar filter
Mar 19, 2026
ea05529
FE-43: Add bezelWidth prop to FilterPolarHiRes
Mar 19, 2026
cb8f2a1
FE-43: Extract FilterShell, PolarToCartesian, and generateMagnitudeTable
Mar 19, 2026
7017312
FE-43: Consolidate filter pipeline into FilterShell with compositing …
kube Mar 30, 2026
5436b27
FE-43: Rename bezel/glass terminology to edge/thickness
kube Mar 30, 2026
96e9f5c
FE-43: Move ResizeObserver into CompositeParts and add compositing co…
kube Mar 30, 2026
24ae1c4
FE-43: Remove surface equations story
kube Mar 30, 2026
cf437f3
FE-43: Rename geometric-polar-map and distanceFromBorder
kube Mar 30, 2026
b1b1c99
Adjust prop names in @hashintel/petrinaut
kube Mar 30, 2026
872b8fa
FE-43: Rename Geometric Polar Map story to Polar DistanceToBorder Map
kube Mar 30, 2026
f9f36f4
FE-43: Rename PolarToCartesian to Refraction and absorb feDisplacemen…
kube Mar 31, 2026
a0f28b6
FE-43: Add SpecularRim effect and move effects to components/effects/
kube Mar 31, 2026
b1b20a6
FE-43: Add DiffuseReflection effect, shared lightAngle, and Playgroun…
kube Mar 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ export const NotificationsProvider: React.FC<NotificationsProviderProps> = ({
)}
>
<refractive.div
refraction={{ radius: 31, blur: 3, bezelWidth: 20 }}
refraction={{ radius: 31, blur: 3, edgeSize: 20 }}
className={notificationStyle}
>
{notification.message}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,8 +122,8 @@ export const BottomBar: React.FC<BottomBarProps> = ({
refraction={{
radius: 8,
blur: 3,
bezelWidth: 20,
glassThickness: 100,
edgeSize: 20,
thickness: 100,
}}
>
<div className={toolbarContainerStyle}>
Expand All @@ -142,8 +142,8 @@ export const BottomBar: React.FC<BottomBarProps> = ({
refraction={{
radius: 8,
blur: 3,
bezelWidth: 20,
glassThickness: 100,
edgeSize: 20,
thickness: 100,
}}
>
<div className={toolbarContainerStyle}>
Expand Down
75 changes: 75 additions & 0 deletions libs/@hashintel/refractive/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Package Overview

`@hashintel/refractive` is a React library that applies refractive glass effects to components using SVG filters. It lives in the HASH monorepo at `libs/@hashintel/refractive`.

## Commands

```bash
# Build the library (outputs to dist/)
yarn build # or: turbo run build --filter '@hashintel/refractive'

# Run Storybook for visual development (port 6006)
yarn dev

# Build in watch mode for consumers
yarn dev:lib

# Lint
yarn lint:eslint # oxlint (not ESLint)
yarn lint:tsc # tsgo (native TypeScript)

# Fix lint issues
yarn fix:eslint
```

## Architecture

The library exports two things from `src/main.ts`:

1. **`refractive`** β€” a Proxy-based HOC that wraps any React component or HTML element (`refractive.div`, `refractive(MyComponent)`) to apply a refractive glass effect via `backdrop-filter: url(#filterId)`.
2. **Surface equation functions** (`convex`, `concave`, `convexCircle`, `lip`) β€” mathematical curves used to shape the bezel height profile.

### Rendering Pipeline

1. **`refractive` HOC** (`src/hoc/refractive.tsx`) β€” Observes element size via `ResizeObserver`, renders a hidden `<svg>` containing the `<Filter>` alongside the wrapped component.
2. **`Filter`** (`src/components/filter.tsx`) β€” Orchestrates the SVG filter graph:
- Computes a **displacement map** (refraction) and **specular map** (highlights) as `ImageData` bitmaps.
- Feeds them to `CompositeParts` which slices each bitmap into 9-patch parts (4 corners, 4 edges, 1 center) for stretching to any element size.
- Chains SVG filter primitives: `feGaussianBlur` β†’ `feDisplacementMap` β†’ specular overlay via `feComposite`.
3. **Map generators** (`src/maps/`):
- `displacement-map.ts` β€” Computes per-pixel refraction offsets using Snell's law, with configurable glass thickness, bezel width, and refractive index. Uses `calculateRoundedSquareMap` for distance-to-border computation.
- `specular.ts` β€” Computes specular highlight intensity based on dot product with a light angle vector. Uses `calculateCircleMap`.
- `calculate-rounded-square-map.ts` / `calculate-circle-map.ts` β€” Iterate over pixels, compute distance-from-border fields, and call a `processPixel` callback to fill RGBA buffers.
4. **Helpers** (`src/helpers/`):
- `split-imagedata-to-parts.ts` β€” Slices an `ImageData` into the 9-patch data URLs.
- `image-data-to-url.ts` β€” Converts `ImageData` to a base64 data URL via `OffscreenCanvas`.

### Key Design Decisions

- **9-patch compositing**: Displacement/specular maps are generated once at the corner size, then split into 9 regions and stretched via `<feImage>` + `<feComposite>` to handle any element dimensions without regenerating the full bitmap.
- **Pixel ratio**: Maps are rendered at `pixelRatio: 6` for quality, independent of device pixel ratio.
- **ResizeObserver dependency**: Currently required to pass explicit width/height to the SVG filter. There's a TODO (FE-43) to switch to `objectBoundingBox` filter units to eliminate this.

## Roadmap: Toward a Fully Declarative SVG Filter

The library is evolving from rasterized bitmap computation toward a purely declarative SVG filter. Each stage reduces computational cost and coupling to raster images.

1. **Per-parameter rasterization** (current default) β€” Full bitmap recomputed on every parameter change. Existing: `Filter` + `CompositeParts`.

2. **Polar coordinate indirection** β€” Decouple shape geometry from the optical transfer function. Compute a single "polar field" image encoding (angle, distance-to-border) per shape, then apply the 1D displacement lookup via SVG filter math (`feComponentTransfer` table + trig via `feColorMatrix`). The shape image is reused across optical parameter changes, reducing recomputation. Existing starting point: `FilterPolar` + `polar-distance-map.ts`. Could further reuse a single high-resolution polar field scaled down per border-radius.

3. **`objectBoundingBox` filter units** β€” Eliminate `ResizeObserver` by using relative (percentage-based) filter coordinates so the filter auto-scales with the element. Existing: `FilterOBB` + `CompositeImage`. Orthogonal to stages 2 and 4; can be combined with either.

4. **Fully procedural SVG filter** β€” Compute the distance field, displacement, and specular entirely within the SVG filter graph (e.g., turbulence, lighting, morphology primitives). No raster images at all. This would make the filter resolution-independent and applicable to arbitrary shapes, not just rounded rectangles.

## Tooling Notes

- Linting uses **oxlint**, not ESLint. The config is in `.oxlintrc.json`.
- Type checking uses **tsgo** (native TypeScript preview), not `tsc`.
- Build uses **Vite 8** with **Rolldown** bundler and `rolldown-plugin-dts` for type declarations.
- React Compiler (babel-plugin-react-compiler) is enabled for build optimization.
- Storybook 10 with `@storybook/react-vite` framework; stories are in `stories/`.
83 changes: 83 additions & 0 deletions libs/@hashintel/refractive/src/components/composite/image.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import type { Parts } from "../../helpers/split-imagedata-to-parts";

/**
* Builds an SVG string containing all 9 image parts composited together,
* then returns it as a base64 data URL.
*
* The SVG has no viewBox and no explicit dimensions, so it adapts to whatever
* size the feImage renders it at. Corners are placed at fixed pixel sizes using
* nested SVGs with overflow="visible" and percentage-based positioning.
*/
export function buildCompositeSvgUrl(
parts: Parts,
cornerWidth: number,
hideTop?: boolean,
hideBottom?: boolean,
hideLeft?: boolean,
hideRight?: boolean,
): string {
const cw = cornerWidth;
const elements: string[] = [];

// Center (base layer, stretched to fill)
elements.push(
`<image href="${parts.center}" x="0" y="0" width="100%" height="100%" preserveAspectRatio="none"/>`,
);

// Edges
if (!hideTop) {
elements.push(
`<image href="${parts.top}" x="0" y="0" width="100%" height="${cw}" preserveAspectRatio="none"/>`,
);
}
if (!hideLeft) {
elements.push(
`<image href="${parts.left}" x="0" y="0" width="${cw}" height="100%" preserveAspectRatio="none"/>`,
);
}
if (!hideRight) {
elements.push(
`<svg x="100%" y="0" width="0" height="100%" overflow="visible">` +
`<image href="${parts.right}" x="${-cw}" y="0" width="${cw}" height="100%" preserveAspectRatio="none"/>` +
`</svg>`,
);
}
if (!hideBottom) {
elements.push(
`<svg x="0" y="100%" width="100%" height="0" overflow="visible">` +
`<image href="${parts.bottom}" x="0" y="${-cw}" width="100%" height="${cw}" preserveAspectRatio="none"/>` +
`</svg>`,
);
}

// Corners
if (!hideTop && !hideLeft) {
elements.push(
`<image href="${parts.topLeft}" x="0" y="0" width="${cw}" height="${cw}" preserveAspectRatio="none"/>`,
);
}
if (!hideTop && !hideRight) {
elements.push(
`<svg x="100%" y="0" width="0" height="0" overflow="visible">` +
`<image href="${parts.topRight}" x="${-cw}" y="0" width="${cw}" height="${cw}" preserveAspectRatio="none"/>` +
`</svg>`,
);
}
if (!hideBottom && !hideLeft) {
elements.push(
`<svg x="0" y="100%" width="0" height="0" overflow="visible">` +
`<image href="${parts.bottomLeft}" x="0" y="${-cw}" width="${cw}" height="${cw}" preserveAspectRatio="none"/>` +
`</svg>`,
);
}
if (!hideBottom && !hideRight) {
elements.push(
`<svg x="100%" y="100%" width="0" height="0" overflow="visible">` +
`<image href="${parts.bottomRight}" x="${-cw}" y="${-cw}" width="${cw}" height="${cw}" preserveAspectRatio="none"/>` +
`</svg>`,
);
}

const svg = `<svg xmlns="http://www.w3.org/2000/svg">${elements.join("")}</svg>`;
return `data:image/svg+xml;base64,${btoa(svg)}`;
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { splitImageDataToParts } from "../helpers/split-imagedata-to-parts";
import { useEffect, useState } from "react";

import type { Parts } from "../../helpers/split-imagedata-to-parts";

type CompositePartsProps = {
imageData: ImageData;
parts: Parts;
cornerWidth: number;
pixelRatio: number;
width: number;
height: number;
/** Ref to the element whose dimensions drive the filter layout. */
elementRef: React.RefObject<HTMLElement | null>;
result: string;
hideTop?: boolean;
hideBottom?: boolean;
Expand All @@ -15,29 +16,50 @@ type CompositePartsProps = {

/**
* @private
* Component that renders the 8 parts of an image and composites them together.
*
* Used internally by the Filter component, for DisplacementMap and SpecularMap.
* Renders pre-split 9-patch parts as feImage primitives and composites them together.
*
* @return {JSX.Element} Fragment containing all image parts for the refractive effect, along with compositing.
* Observes the referenced element's size via ResizeObserver to position
* parts at the correct pixel coordinates.
*/
export const CompositeParts: React.FC<CompositePartsProps> = ({
imageData,
parts,
cornerWidth,
width,
height,
pixelRatio,
elementRef,
result,
hideTop,
hideBottom,
hideLeft,
hideRight,
}) => {
const parts = splitImageDataToParts({
imageData,
cornerWidth,
pixelRatio,
});
const [width, setWidth] = useState(0);
const [height, setHeight] = useState(0);

useEffect(() => {
const element = elementRef.current;
if (!element) {
return;
}

const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const borderBox = entry.borderBoxSize[0];

if (borderBox) {
setWidth(borderBox.inlineSize);
setHeight(borderBox.blockSize);
} else {
setWidth(entry.contentRect.width);
setHeight(entry.contentRect.height);
}
}
});

resizeObserver.observe(element);

return () => {
resizeObserver.disconnect();
};
}, [elementRef]);

const widthMinusCorner = width - cornerWidth;
const heightMinusCorner = height - cornerWidth;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { generateTableValues } from "../../helpers/generate-table-values";

/**
* Generates a directional cosine table centered at 0.5 (signed encoding).
*
* Maps the polar map angle channel [0,255] β†’ [0,2Ο€] to
* `(cos(angle - lightAngle) + 1) / 2`, where 0.5 = perpendicular,
* 1 = facing light, 0 = facing away.
*/
function generateCosAngleTable(lightAngle: number): string {
return generateTableValues(256, (i) => {
const angle = (i / 255) * 2 * Math.PI;
return (Math.cos(angle - lightAngle) + 1) / 2;
});
}

type DiffuseReflectionProps = {
/** Input result name containing the polar map (R=distance ratio, G=angle). */
in: string;
/** Input source image to apply diffuse shading to. */
source: string;
/** Light angle in radians (0 = right, Ο€/2 = down, Ο€ = left, 3Ο€/2 = up). */
lightAngle: number;
/**
* Pre-computed surface tilt lookup table (from generateSurfaceTiltTable).
* Maps distance ratio β†’ normalized tilt [0,1].
*/
surfaceTiltTable: string;
/** Strength of the diffuse shading [0,1]. */
intensity?: number;
/** Output result name. */
result: string;
};

/**
* @private
* Diffuse reflection effect: applies Lambertian-style shading based on the
* surface normal (derived from the edge profile) and light direction.
*
* Surfaces facing the light are brightened (white overlay), surfaces facing
* away are darkened (black overlay). Neutral areas have alpha=0 and don't
* affect the source at all β€” no gray wash on dark backgrounds.
*
* Pipeline:
* 1. Extract angle (G) β†’ cosine of angle relative to light direction (signed, centered at 0.5)
* 2. Extract distance ratio (R) β†’ surface tilt from edge profile (unsigned [0,1])
* 3. Signed Γ— unsigned multiply β†’ diffuse value centered at 0.5
* 4. Light pass: white with alpha = max(0, diffuse - 0.5) Γ— 2 Γ— intensity
* 5. Dark pass: black with alpha = max(0, 0.5 - diffuse) Γ— 2 Γ— intensity
*/
export const DiffuseReflection: React.FC<DiffuseReflectionProps> = ({
in: inResult,
source,
lightAngle,
surfaceTiltTable,
intensity = 0.3,
result,
}) => {
const cosAngleTable = generateCosAngleTable(lightAngle);
const lightAlphaScale = 2 * intensity;

return (
<>
{/* 1. Copy angle (G) into R, apply signed cosine table */}
<feColorMatrix
in={inResult}
type="matrix"
values="0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0"
result={`${result}_cos_in`}
/>
<feComponentTransfer in={`${result}_cos_in`} result={`${result}_cos`}>
<feFuncR type="table" tableValues={cosAngleTable} />
</feComponentTransfer>

{/* 2. Apply surface tilt table to R channel (distance ratio β†’ tilt) */}
<feComponentTransfer in={inResult} result={`${result}_tilt`}>
<feFuncR type="table" tableValues={surfaceTiltTable} />
</feComponentTransfer>

{/* 3. Signed Γ— unsigned multiply: cos(centered 0.5) Γ— tilt(unsigned)
result = AΒ·B - 0.5Β·B + 0.5
Maps to [0,1] centered at 0.5: >0.5 = lit, <0.5 = shadow */}
<feComposite
in={`${result}_cos`}
in2={`${result}_tilt`}
operator="arithmetic"
k1={1}
k2={0}
k3={-0.5}
k4={0.5}
result={`${result}_diffuse`}
/>

{/* 4. Light pass: white overlay where diffuse > 0.5
A = intensity Γ— 2 Γ— (R - 0.5), clamped to 0 when R < 0.5 */}
<feColorMatrix
in={`${result}_diffuse`}
type="matrix"
values={`0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 ${lightAlphaScale} 0 0 0 ${-intensity}`}
result={`${result}_light`}
/>
<feComposite
in={`${result}_light`}
in2={source}
operator="over"
result={`${result}_lit`}
/>

{/* 5. Dark pass: black overlay where diffuse < 0.5
A = intensity Γ— 2 Γ— (0.5 - R), clamped to 0 when R > 0.5 */}
<feColorMatrix
in={`${result}_diffuse`}
type="matrix"
values={`0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 ${-lightAlphaScale} 0 0 0 ${intensity}`}
result={`${result}_dark`}
/>
<feComposite
in={`${result}_dark`}
in2={`${result}_lit`}
operator="over"
result={result}
/>
</>
);
};
Loading
Loading