/* The lens: a refraction map and five masks for a rounded box, computed from a physical profile instead of drawn by hand. Everything here is pure, so it runs in tests and workers as well as the page. The profile is rendered once as a rounded square of side 2r + 1, cut into nine slices: fixed corners and stretched edges keep it exact at any size, so the glass never has to switch off while its box resizes. Glass of any other outline is rendered whole, by the same shader, from an SVG path (createShapedGlass). */ const PROFILE_STEPS = 128; // The bottom-right glint, relative to the top-left one: the light's return off the far curve. const GLINT_RETURN = 0.6; /** Slice order shared by masks and the refraction filter. */ export const SLICES = ["tl", "tr", "bl", "br", "t", "b", "l", "r"] as const; export type GlassGeometry = { /** Width of the refracting rim along the top and bottom, in CSS pixels. The centre is flat. */ bezel: number; /** Glass depth under the top and bottom rims, in CSS pixels. Deeper glass bends further. */ thickness: number; /** Width of the refracting rim at the sides and rounded ends, in CSS pixels. */ sideBezel: number; /** Glass depth under the sides and rounded ends, in CSS pixels. */ sideThickness: number; refractiveIndex: number; /** Rim hairline width, in CSS pixels. */ hairline: number; /** Share of the hairline's strength that reaches the sides (0–1). */ hairlineSides: number; /** Width of the darkened sides, in CSS pixels. */ shadeWidth: number; /** How far the glow reaches in from the top and bottom rims, in CSS pixels. */ glowReach: number; /** Bottom glow strength relative to the top (0–1). */ glowBottom: number; /** * Steepest the rim's shift may fall per pixel inward (0–1). Past 1 the lens folds * content into mirrored echoes. Where it falls, the rim magnifies what is beneath * by 1 / (1 - maxFall), so lower values bend more gently and keep text crossing * the rim from smearing. */ maxFall: number; /** * Most of the corner radius the top and bottom rims may take (0–1). On small * glass a full-width bezel reaches the middle, so the whole body is rim and * magnifies what is beneath; iOS keeps the bend to a band at the edge and the * middle shows the page as it is. */ rimShare: number; /** * Width of iOS 27's darkened edge, in CSS pixels: a contour where the glass meets * the air, with the hairline moved just inside it. 0 leaves it out. */ edge: number; /** * Share of the hairline's light that gathers into glints where the light catches * the curve, at the top-left and, fainter, the bottom-right (0–1). 0 keeps an even * band along the top and bottom. */ glint: number; }; /** * Proportions measured from Apple's iOS 27 press images, then tuned by eye. Sharp * as the page around it, as iOS's glass is: the rims stop short of the middle of * small glass and magnify what is beneath at most 1.8 times. The glow inside the * bottom rim is nearly the top's, as the light iOS 27's glass gathers along its * lower edge is. */ export const DEFAULT_GEOMETRY: GlassGeometry = { bezel: 17, thickness: 24, sideBezel: 27, sideThickness: 44, refractiveIndex: 1.5, hairline: 1, hairlineSides: 0.15, shadeWidth: 1, glowReach: 8, glowBottom: 0.8, // On a 40px capsule a full rim reached the middle, magnifying up to five times, // and Chromium's nearest-pixel displacement showed every page pixel it stretched // as a block. maxFall: 0.45, rimShare: 0.6, edge: 0.75, glint: 0.4, }; /** * The rim for a lens about a control high, like a segmented control's thumb: the * rounded ends bend over their whole radius, and gently, so a label crossing the * rim bends a little rather than breaking into a doubled, stair-stepped piece. * Pair with the `lens` variant. */ export const LENS_GEOMETRY: GlassGeometry = { ...DEFAULT_GEOMETRY, bezel: 9, thickness: 5, sideBezel: 18, sideThickness: 9, glowReach: 5, glowBottom: 0.5, maxFall: 0.22, glint: 0, }; /** * Masked layers over the glass body: rim hairline, darkened sides, inner glow, outer * contour, and the refraction, which bends only where the rim does. The rest of the * body is the page as it is, never resampled by the displacement, which takes the * nearest pixel and on some GPUs drew a flat body visibly coarser than the page. */ export type GlassMask = "highlight" | "shade" | "glow" | "edge" | "refract"; export const MASKS: readonly GlassMask[] = ["highlight", "shade", "glow", "edge", "refract"]; function smoothstep(from: number, to: number, value: number) { const t = Math.min(1, Math.max(0, (value - from) / (to - from))); return t * t * (3 - 2 * t); } /** Slope of the convex squircle y = (1 - (1 - x)^4)^(1/4), flat once x reaches 1. */ function squircleSlope(x: number) { const clamped = Math.min(Math.max(x, 1e-3), 1); return (1 - clamped) ** 3 * (1 - (1 - clamped) ** 4) ** -0.75; } /** Lateral shift across one rim cross-section, from the outer edge inward. */ function refractionProfile(bezel: number, thickness: number, refractiveIndex: number, maxFall: number) { const profile = new Float32Array(PROFILE_STEPS + 1); for (let step = 0; step <= PROFILE_STEPS; step++) { const x = Math.max(step / PROFILE_STEPS, 1e-3); const height = (1 - (1 - x) ** 4) ** 0.25; // A straight-down view ray meets the tilted surface, refracts (Snell), and // travels through the remaining depth to the backdrop. const incidence = Math.atan((thickness / bezel) * squircleSlope(x)); const refracted = Math.asin(Math.sin(incidence) / refractiveIndex); profile[step] = thickness * height * Math.tan(incidence - refracted); } // Keep the lens one-to-one. Where the shift falls faster than the rim moves inward, // neighbouring pixels sample in reverse order and content folds into mirrored // echoes. Capping the fall compresses content toward the rim instead. const fallPerStep = (maxFall * bezel) / PROFILE_STEPS; for (let step = PROFILE_STEPS - 1; step >= 0; step--) { profile[step] = Math.min(profile[step] ?? 0, (profile[step + 1] ?? 0) + fallPerStep); } let max = 0; for (const shift of profile) max = Math.max(max, shift); return { profile, max }; } type GlassPixels = { /** feDisplacementMap input: red and green carry X and Y around 128. */ refraction: Uint8ClampedArray; /** Alpha masks for the layers over the body. */ masks: Record; }; function glassPixels(count: number): GlassPixels { return { refraction: new Uint8ClampedArray(count * 4), masks: { highlight: new Uint8ClampedArray(count * 4), shade: new Uint8ClampedArray(count * 4), glow: new Uint8ClampedArray(count * 4), edge: new Uint8ClampedArray(count * 4), refract: new Uint8ClampedArray(count * 4), }, }; } /** * Shades glass whose corners have this radius one pixel at a time, from how deep * the pixel is inside the edge (negative outside it) and the edge's outward normal * nearest it. */ function glassShader(cornerRadius: number, pixelRatio: number, geometry: Partial, { refraction, masks }: GlassPixels) { const { bezel, thickness, sideBezel, sideThickness, refractiveIndex, hairline, hairlineSides, shadeWidth, glowReach, glowBottom, maxFall, rimShare, edge, glint, } = { ...DEFAULT_GEOMETRY, ...geometry }; const radius = Math.max(1, Math.floor(cornerRadius)); // Like real glass, the rounded ends bend light far more than the long straight edges, // which keep content crossing them straight. Each has its own lens profile, blended // around the curve. const rim = Math.min(bezel, radius * rimShare); const sideRim = Math.min(sideBezel, radius); const edges = refractionProfile(rim, thickness, refractiveIndex, maxFall); const ends = refractionProfile(sideRim, sideThickness, refractiveIndex, maxFall); const max = Math.max(edges.max, ends.max); const shiftAt = (lens: typeof edges, width: number, inset: number) => inset < width ? (lens.profile[Math.round((inset / width) * PROFILE_STEPS)] ?? 0) : 0; // With a darkened edge, the hairline starts just inside it. const lit = edge * 0.6; return { radius, // feDisplacementMap moves by scale * (channel - 0.5); a full swing is ±127/255. scale: (max * 255) / 127, /** Shades the pixel at byte `index` of every map. */ shade(index: number, inset: number, nx: number, ny: number) { let dx = 0; let dy = 0; if (inset > 0 && max > 0) { const sideShare = nx * nx; const shift = shiftAt(edges, rim, inset) * (1 - sideShare) + shiftAt(ends, sideRim, inset) * sideShare; // Convex glass bends rays toward the centre, so the rim shows backdrop from further in. dx = (-nx * shift) / max; dy = (-ny * shift) / max; } refraction[index] = 128 + dx * 127; refraction[index + 1] = 128 + dy * 127; refraction[index + 2] = 128; refraction[index + 3] = 255; // The refraction shows only where it bends. masks.refract[index + 3] = 255 * Math.min(1, Math.hypot(dx, dy) * 40); if (inset <= 0) return; // Proportions measured from Apple's iOS 27 press images: every edge feature is // about a point wide, and only the glow reaches further in. const cover = Math.min(1, inset * pixelRatio + 0.5); const vertical = ny * ny; // A one-point hairline where the rim meets the air, lit from above and below. // Glints gather part of it where a light from the top-left catches the curve. const bands = hairlineSides + (1 - hairlineSides) * Math.abs(ny) ** 1.5; const facing = (-nx - ny) * Math.SQRT1_2; const glints = Math.max(facing, 0) ** 3 + GLINT_RETURN * Math.max(-facing, 0) ** 3; const reach = edge > 0 ? smoothstep(lit * 0.5, lit * 1.5, inset) : 1; masks.highlight[index + 3] = 255 * cover * reach * (1 - smoothstep(lit + 0.6 * hairline, lit + 1.6 * hairline, inset)) * Math.max(bands * (1 - glint), Math.min(1, glints * glint * 2.5)); // iOS 27's darkened edge: a contour where the glass meets the air, all the way round. if (edge > 0) masks.edge[index + 3] = 255 * cover * (1 - smoothstep(0.5 * edge, 1.1 * edge, inset)); // The darkened edge, on the sides only and about a point and a half wide. masks.shade[index + 3] = 255 * cover * (1 - smoothstep(0.8 * shadeWidth, 2 * shadeWidth, inset)) * nx * nx; // Light gathered just inside the top and bottom rims, fading over about five points. masks.glow[index + 3] = 255 * cover * smoothstep(0.8, 1.6, inset) * (1 - smoothstep(1.6, Math.max(glowReach, 1.7), inset)) * vertical * (ny < 0 ? 1 : glowBottom); }, }; } export type GlassSlices = GlassPixels & { /** Corner radius the slices were built for, in whole CSS pixels. */ radius: number; /** Square side in device pixels: two corners plus one pixel of straight edge. */ size: number; pixelRatio: number; /** feDisplacementMap scale that restores the map to pixels. */ scale: number; }; /** * Renders a rounded square of side 2r + 1. Its corners are the glass's corners and * its centre row and column are its straight edges, so any size can be assembled * from eight slices. */ export function createGlassSlices( cornerRadius: number, pixelRatio = 1, geometry: Partial = {}, ): GlassSlices { const radius = Math.max(1, Math.floor(cornerRadius)); const side = radius * 2 + 1; const size = side * pixelRatio; const pixels = glassPixels(size * size); const glass = glassShader(radius, pixelRatio, geometry, pixels); const half = side / 2; const straight = half - radius; for (let row = 0; row < size; row++) { for (let column = 0; column < size; column++) { const x = (column + 0.5) / pixelRatio - half; const y = (row + 0.5) / pixelRatio - half; // Signed distance to the rounded square; `inset` is depth inside its edge. const qx = Math.abs(x) - straight; const qy = Math.abs(y) - straight; const cornerX = Math.max(qx, 0); const cornerY = Math.max(qy, 0); const corner = Math.hypot(cornerX, cornerY); const inset = radius - corner - Math.min(Math.max(qx, qy), 0); const nx = (corner > 0 ? cornerX / corner : qx > qy ? 1 : 0) * (Math.sign(x) || 1); const ny = (corner > 0 ? cornerY / corner : qx > qy ? 0 : 1) * (Math.sign(y) || 1); glass.shade((row * size + column) * 4, inset, nx, ny); } } return { radius, size, pixelRatio, ...pixels, scale: glass.scale }; } export type ShapedGlass = GlassPixels & { /** Map size in device pixels. */ width: number; height: number; pixelRatio: number; /** feDisplacementMap scale that restores the map to pixels. */ scale: number; }; /** * Glass for an outline no rounded rectangle makes, rendered whole at one size: an * SVG path of absolute M, L, H, V, C and Z commands, in CSS pixels. Each pixel is * shaded as the slices' are, from its distance to the nearest point of the outline * and the outline's normal there. The rims stay within `cornerRadius`, as a rounded * rectangle's stay within its corners. */ export function createShapedGlass( path: string, width: number, height: number, cornerRadius: number, pixelRatio = 1, geometry: Partial = {}, ): ShapedGlass { const columns = Math.ceil(width * pixelRatio); const rows = Math.ceil(height * pixelRatio); const pixels = glassPixels(columns * rows); const glass = glassShader(cornerRadius, pixelRatio, geometry, pixels); // Points a quarter of a device pixel apart, so the nearest is as near as the true outline. const outline = outlinePoints(path, 0.25 / pixelRatio); const nearest = nearestOutlinePoints(outline, columns, rows, pixelRatio); for (let row = 0; row < rows; row++) { for (let column = 0; column < columns; column++) { const pixel = row * columns + column; const point = nearest[pixel] ?? 0; const nx = outline.nx[point] ?? 0; const ny = outline.ny[point] ?? 0; const dx = (column + 0.5) / pixelRatio - (outline.x[point] ?? 0); const dy = (row + 0.5) / pixelRatio - (outline.y[point] ?? 0); // Inside, the pixel lies behind the outline's outward normal. const distance = Math.hypot(dx, dy); glass.shade(pixel * 4, dx * nx + dy * ny < 0 ? distance : -distance, nx, ny); } } return { width: columns, height: rows, pixelRatio, ...pixels, scale: glass.scale }; } /** The path's outline as evenly spaced points with outward normals, in CSS pixels. */ function outlinePoints(path: string, spacing: number) { const polygon: [number, number][] = []; const tokens = path.match(/[MLHVCZ]|-?(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?/gi) ?? []; let command = ""; let x = 0; let y = 0; for (let index = 0; index < tokens.length; ) { const token = tokens[index] ?? ""; if (/^[a-z]$/i.test(token)) { command = token.toUpperCase(); index++; if (command === "Z") continue; } const take = () => Number(tokens[index++]); if (command === "M" || command === "L") { x = take(); y = take(); } else if (command === "H") { x = take(); } else if (command === "V") { y = take(); } else if (command === "C") { const [x1, y1, x2, y2, x3, y3] = [take(), take(), take(), take(), take(), take()]; const [x0, y0] = [x, y]; // Fine enough that a 25px corner's chords stray well under a device pixel. for (let step = 1; step < 32; step++) { const t = step / 32; const u = 1 - t; polygon.push([ u * u * u * x0 + 3 * u * u * t * x1 + 3 * u * t * t * x2 + t * t * t * x3, u * u * u * y0 + 3 * u * u * t * y1 + 3 * u * t * t * y2 + t * t * t * y3, ]); } x = x3; y = y3; } else { index++; continue; } polygon.push([x, y]); } // The outward normal is on the right of a clockwise outline (y down) and on the left otherwise. let area = 0; polygon.forEach(([ax, ay], index) => { const [bx, by] = polygon[(index + 1) % polygon.length] ?? [ax, ay]; area += ax * by - bx * ay; }); const turn = area > 0 ? 1 : -1; const points = { x: [] as number[], y: [] as number[], nx: [] as number[], ny: [] as number[] }; polygon.forEach(([ax, ay], index) => { const [bx, by] = polygon[(index + 1) % polygon.length] ?? [ax, ay]; const length = Math.hypot(bx - ax, by - ay); if (length === 0) return; const steps = Math.ceil(length / spacing); for (let step = 0; step < steps; step++) { const t = step / steps; points.x.push(ax + (bx - ax) * t); points.y.push(ay + (by - ay) * t); points.nx.push((turn * (by - ay)) / length); points.ny.push((-turn * (bx - ax)) / length); } }); return points; } /** * The nearest outline point to every pixel centre: seeded where the outline crosses * the grid, then carried across it in two sweeps (8SSEDT). */ function nearestOutlinePoints(outline: ReturnType, columns: number, rows: number, pixelRatio: number) { const nearest = new Int32Array(columns * rows).fill(-1); const distance = new Float32Array(columns * rows).fill(Infinity); const offer = (column: number, row: number, point: number) => { if (column < 0 || row < 0 || column >= columns || row >= rows) return; const pixel = row * columns + column; const dx = column + 0.5 - (outline.x[point] ?? 0) * pixelRatio; const dy = row + 0.5 - (outline.y[point] ?? 0) * pixelRatio; const squared = dx * dx + dy * dy; if (squared < (distance[pixel] ?? Infinity)) { distance[pixel] = squared; nearest[pixel] = point; } }; outline.x.forEach((pointX, point) => { const column = Math.floor(pointX * pixelRatio); const row = Math.floor((outline.y[point] ?? 0) * pixelRatio); for (let dy = -1; dy <= 1; dy++) { for (let dx = -1; dx <= 1; dx++) offer(column + dx, row + dy, point); } }); const pull = (column: number, row: number, fromColumn: number, fromRow: number) => { if (fromColumn < 0 || fromRow < 0 || fromColumn >= columns || fromRow >= rows) return; const point = nearest[fromRow * columns + fromColumn] ?? -1; if (point >= 0) offer(column, row, point); }; for (let row = 0; row < rows; row++) { for (let column = 0; column < columns; column++) { pull(column, row, column - 1, row); pull(column, row, column - 1, row - 1); pull(column, row, column, row - 1); pull(column, row, column + 1, row - 1); } for (let column = columns - 1; column >= 0; column--) pull(column, row, column + 1, row); } for (let row = rows - 1; row >= 0; row--) { for (let column = columns - 1; column >= 0; column--) { pull(column, row, column + 1, row); pull(column, row, column + 1, row + 1); pull(column, row, column, row + 1); pull(column, row, column - 1, row + 1); } for (let column = 0; column < columns; column++) pull(column, row, column - 1, row); } return nearest; } /** Device-pixel rectangles of the eight slices, in SLICES order. */ export function sliceRects(slices: Pick) { const corner = slices.radius * slices.pixelRatio; const edge = slices.pixelRatio; const far = corner + edge; return [ [0, 0, corner, corner], [far, 0, corner, corner], [0, far, corner, corner], [far, far, corner, corner], [corner, 0, edge, corner], [corner, far, edge, corner], [0, corner, corner, edge], [far, corner, corner, edge], ] as const; }