/* Tone: what each layer does to the page behind it. Every layer is the backdrop, filtered; nothing is painted over the glass apart from an optional wash. Values were tuned by eye against Apple's iOS 27 glass in both appearances. */ export type GlassAppearance = "dark" | "light"; /** * - `regular`: the default material. Diffuses and evens out the page so text * on it stays legible over anything. * - `clear`: only bends and lights the page; the body barely changes it. For * surfaces over media or calm backgrounds. * - `lens`: the clear lens a control lifts into, like a segmented control's * thumb. It lifts what is beneath a little, and its edge is darker at the * rounded ends than a surface's bright rim. Pair with LENS_GEOMETRY. */ export type GlassVariant = "regular" | "clear" | "lens"; export type GlassTone = { /** Body blur, in CSS pixels. */ blur: number; /** Body blur of floating surfaces (menus, popovers, dialogs), which diffuse more. */ floatingBlur: number; saturate: number; contrast: number; brightness: number; /** Share of the tint colour washed over the body (0–1). */ tint: number; highlightContrast: number; highlightBrightness: number; shadeBrightness: number; glowSaturate: number; glowBrightness: number; /** The darkened edge's brightness. */ edgeBrightness: number; }; // The rim of every variant: a hairline bright enough for its glints, side shade, // inner glow and the darkened edge. In dark the glints get the extra light; in // light the hairline is a white line inside the edge. const RIM: Record> = { dark: { highlightContrast: 1.1, highlightBrightness: 2.4, shadeBrightness: 1.33, glowSaturate: 1.1, glowBrightness: 1.3, edgeBrightness: 0.66, }, light: { highlightContrast: 1, highlightBrightness: 1.25, shadeBrightness: 1.08, glowSaturate: 1, glowBrightness: 1.02, edgeBrightness: 0.82, }, }; export const TONES: Record> = { regular: { // Dark glass lowers the page's contrast and brightness into an even, dim body. // Untinted, that lifts a near-black page to a flat grey slab, so it takes half // the tint colour. dark: { ...RIM.dark, blur: 1.5, floatingBlur: 6, saturate: 0.8, contrast: 0.53, brightness: 0.43, tint: 0.5, }, // Light glass lifts the page into a pale body and brightens its sides instead. light: { ...RIM.light, blur: 2, floatingBlur: 7, saturate: 0.8, contrast: 0.3, brightness: 1.73, tint: 0.4, }, }, clear: { dark: { ...RIM.dark, blur: 1.5, floatingBlur: 1.5, saturate: 1, contrast: 1, brightness: 1, tint: 0, }, // A faint wash keeps text on light clear glass legible over bright pages. light: { ...RIM.light, blur: 0, floatingBlur: 0, saturate: 1, contrast: 1.02, brightness: 1.02, tint: 0.28, }, }, lens: { dark: { ...RIM.dark, blur: 0, floatingBlur: 0, saturate: 1.2, contrast: 1, brightness: 1.08, tint: 0, highlightBrightness: 1.5, shadeBrightness: 0.8, glowBrightness: 1.12, }, light: { ...RIM.light, blur: 0, floatingBlur: 0, saturate: 1.1, contrast: 1, brightness: 1.03, tint: 0, }, }, }; /** The page brightened under a control sitting on the glass, at rest and hovered. */ export const WELL_BRIGHTNESS: Record = { dark: { rest: 1.45, hover: 1.8 }, light: { rest: 0.92, hover: 0.86 }, }; /** The tint colour when none is given: near-black and white. */ export const DEFAULT_TINT: Record = { dark: "#101114", light: "#fff", }; export type GlassFilter = { /** The layer's backdrop-filter. */ full: string; /** The same functions at their identity values, for materializing from clear glass. */ clear: string; }; type FilterFunction = [name: string, value: number, identity: number, unit?: string]; function filter(functions: FilterFunction[]): GlassFilter { // Identity functions are dropped so the browser runs fewer passes per frame; // `clear` keeps the same list so the two interpolate function by function. const used = functions.filter(([, value, identity]) => value !== identity); if (used.length === 0) return { full: "none", clear: "none" }; return { full: used.map(([name, value, , unit = ""]) => `${name}(${value}${unit})`).join(" "), clear: used.map(([name, , identity, unit = ""]) => `${name}(${identity}${unit})`).join(" "), }; } /** Backdrop filters for each layer of a tone. Floating surfaces take the tone's floatingBlur. */ export function toneFilters(tone: GlassTone, floating = false) { return { body: filter([ ["blur", floating ? tone.floatingBlur : tone.blur, 0, "px"], ["saturate", tone.saturate, 1], ["contrast", tone.contrast, 1], ["brightness", tone.brightness, 1], ]), shade: filter([["brightness", tone.shadeBrightness, 1]]), glow: filter([ ["saturate", tone.glowSaturate, 1], ["brightness", tone.glowBrightness, 1], ]), edge: filter([["brightness", tone.edgeBrightness, 1]]), highlight: filter([ ["contrast", tone.highlightContrast, 1], ["brightness", tone.highlightBrightness, 1], ]), }; } /** The body's wash: a share of the tint colour over the filtered page. */ export function toneWash(tone: GlassTone, tint: string) { if (tone.tint <= 0) return "transparent"; return `color-mix(in srgb, ${tint} ${Math.round(Math.min(tone.tint, 1) * 100)}%, transparent)`; } export type GlassToneOverrides = Partial>>; export function resolveTone(variant: GlassVariant, appearance: GlassAppearance, overrides?: GlassToneOverrides): GlassTone { return { ...TONES[variant][appearance], ...overrides?.[appearance] }; } const LUMA = [0.2126, 0.7152, 0.0722] as const; /** How much of a hue the glass takes by default. Hue measured from near-black dark * surfaces reads stronger than it looks on the surface itself. */ export const DEFAULT_HUE_STRENGTH: Record = { light: 1, dark: 0.5 }; /** * An feColorMatrix that tints the backdrop with a colour's hue the way Apple tints * its glass: each pixel shifts toward the hue at its own luma, so dark content takes * a deep tone and light content a pale one, and colours behind keep their chroma. * Takes 0–255 channels. Returns null for neutral colours, which leave glass untinted. */ export function hueMatrix(rgb: readonly [number, number, number], strength: number): string | null { const colour = rgb.map(channel => channel / 255); const luma = colour.reduce((sum, channel, index) => sum + channel * (LUMA[index] ?? 0), 0); const gains = colour.map(channel => (luma > 0 ? channel / luma : 1)); if (Math.max(...gains.map(gain => Math.abs(gain - 1))) < 0.005) return null; const rows = gains.map((gain, channel) => { const weights = LUMA.map((weight, index) => (channel === index ? 1 : 0) + strength * (gain - 1) * weight); return [...weights, 0, 0].map(value => value.toFixed(4)).join(" "); }); return [...rows, "0 0 0 1 0"].join(" "); }