From 35471df7678e5635f38bfea2eaddd1f346c8b4df Mon Sep 17 00:00:00 2001 From: Logan Sack <12778322+logancsack@users.noreply.github.com> Date: Thu, 1 Oct 2026 04:44:37 +0000 Subject: [PATCH 1/4] feat(web): Prism's glass is a lens Apple's Liquid Glass is a lens, not a blur: the middle passes the backdrop almost untouched and a bezel at the rim bends it, with a highlight where the dome's curve faces the light. Prism's glass was frosted film with a painted highlight. prismGlass.ts builds, per lensed element and size, a displacement map (a squircle dome refracting by Snell's law, sampled toward the centre so the eye at the edge sees inner content) and a rim-light image (surface normals lit from the upper left, with a fainter answer from the lower right), and hands them to the stylesheet as --prism-lens (an SVG filter in the backdrop filter, Chromium only) and --prism-rim (a background layer, everywhere). The thick variant chosen for Prism splits the channels at the rim for a faint chromatic edge. Maps are cached by size, capped at 160k pixels, and built once per element from a ResizeObserver; the runtime starts and stops with the theme id. The stylesheet puts the lens on the composer, the Aldo dock, toolbar controls, popups, dialogs, sheets, and the sidebar, with more tint and blur on the overlays so their text stays legible, and keeps everything but the bend where the lens can't run. Co-Authored-By: Claude Fable 5.1 --- apps/web/src/index.css | 195 +++++++------- apps/web/src/main.tsx | 2 + apps/web/src/prismGlass.test.ts | 54 ++++ apps/web/src/prismGlass.ts | 447 ++++++++++++++++++++++++++++++++ 4 files changed, 593 insertions(+), 105 deletions(-) create mode 100644 apps/web/src/prismGlass.test.ts create mode 100644 apps/web/src/prismGlass.ts diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 193ba68b34b4..aa53e31ecf12 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1416,7 +1416,10 @@ html[data-theme-id="prism"] { --prism-dim: color-mix(in srgb, var(--app-theme-chrome) 48%, transparent); /* Liquid glass: a specular fill over a light tint, a rim lit from the top left, and an inner highlight along the top edge. */ - --prism-lg-tint: color-mix(in srgb, var(--app-theme-surface-raised) 52%, transparent); + /* Liquid glass, the thick lens: a specular fill over a light tint, a + hairline, and the rim light and bend that prismGlass.ts hands each + element through --prism-rim and --prism-lens. */ + --prism-lg-tint: color-mix(in srgb, var(--app-theme-surface-raised) 42%, transparent); --prism-lg-fill: linear-gradient( 135deg, oklch(1 0 0 / 70%) 0%, @@ -1424,17 +1427,11 @@ html[data-theme-id="prism"] { oklch(1 0 0 / 22%) 70%, oklch(1 0 0 / 48%) 100% ); - --prism-lg-rim: linear-gradient( - 135deg, - oklch(1 0 0 / 100%) 0%, - oklch(1 0 0 / 55%) 30%, - color-mix(in srgb, var(--prism-ink) 14%, transparent) 55%, - oklch(1 0 0 / 80%) 100% - ); - --prism-lg-inset: - inset 0 1px 0 oklch(1 0 0 / 95%), inset 0 -1px 0 oklch(1 0 0 / 40%), - inset 1px 0 0 oklch(1 0 0 / 55%), inset -1px 0 0 oklch(1 0 0 / 55%); - --prism-lg-blur: blur(28px) saturate(1.6) brightness(1.04); + --prism-lg-inset: inset 0 0 0 1px oklch(1 0 0 / 75%); + --prism-lg-blur: blur(2px) saturate(1.4); + /* Overlays: the same lens, more tint and blur so what they say stays legible. */ + --prism-overlay-tint: color-mix(in srgb, var(--app-theme-surface-overlay) 64%, transparent); + --prism-overlay-blur: blur(8px) saturate(1.45); --prism-shadow: 0 24px 64px -28px oklch(0.25 0.03 268 / 40%); --prism-chrome-text: linear-gradient( 180deg, @@ -1479,25 +1476,18 @@ html[data-theme-id="prism"] { --prism-frost-canvas: color-mix(in srgb, var(--app-theme-canvas) 82%, transparent); --prism-frost-blur: blur(40px) saturate(1.5); --prism-dim: color-mix(in srgb, var(--app-theme-chrome) 55%, transparent); - --prism-lg-tint: color-mix(in srgb, var(--app-theme-surface-raised) 48%, transparent); + --prism-lg-tint: color-mix(in srgb, var(--app-theme-surface-raised) 36%, transparent); --prism-lg-fill: linear-gradient( 135deg, - oklch(1 0 0 / 16%) 0%, - oklch(1 0 0 / 5%) 42%, + oklch(1 0 0 / 14%) 0%, + oklch(1 0 0 / 4%) 42%, oklch(1 0 0 / 2%) 70%, - oklch(1 0 0 / 9%) 100% + oklch(1 0 0 / 8%) 100% ); - --prism-lg-rim: linear-gradient( - 135deg, - oklch(1 0 0 / 60%) 0%, - oklch(1 0 0 / 18%) 30%, - oklch(1 0 0 / 6%) 55%, - oklch(1 0 0 / 38%) 100% - ); - --prism-lg-inset: - inset 0 1px 0 oklch(1 0 0 / 34%), inset 0 -1px 0 oklch(1 0 0 / 7%), - inset 1px 0 0 oklch(1 0 0 / 12%), inset -1px 0 0 oklch(1 0 0 / 12%); - --prism-lg-blur: blur(28px) saturate(1.7) brightness(1.08); + --prism-lg-inset: inset 0 0 0 1px oklch(1 0 0 / 18%); + --prism-lg-blur: blur(2px) saturate(1.55) brightness(1.06); + --prism-overlay-tint: color-mix(in srgb, var(--app-theme-surface-overlay) 62%, transparent); + --prism-overlay-blur: blur(8px) saturate(1.5) brightness(1.04); --prism-shadow: 0 28px 72px -28px oklch(0 0 0 / 85%); --prism-chrome-text: linear-gradient( 180deg, @@ -1615,36 +1605,45 @@ html[data-theme-id="prism"] [data-app-sidebar] [data-slot="sidebar-inner"] { background-color: transparent; } -/* ---- Frosted overlays --------------------------------------------------- - Everything that opens over other content. On narrow screens the sidebar - is a sheet (and never carries the docked sidebar's attribute), so it is - matched by its slot here. */ -html[data-theme-id="prism"] [data-slot="sidebar"][data-mobile="true"] { - --sidebar-stage-fade: var(--prism-frost-sidebar); - background-color: var(--prism-frost-sidebar); - border-color: var(--prism-line-strong); - box-shadow: - inset -1px 0 0 var(--prism-highlight), - var(--prism-shadow); - -webkit-backdrop-filter: var(--prism-frost-blur); - backdrop-filter: var(--prism-frost-blur); -} - -/* Menus and popovers carry dropdown-glass on the popup itself; a select's - popup slot is a bare wrapper around a dropdown-glass child, so the class - is the one element to frost in all three. */ +/* ---- Glass surfaces --------------------------------------------------- + Two materials. Controls that sit on the scene (the composer, the Aldo + dock, toolbar controls) are thick liquid glass: a light tint, a specular + fill, the rim light, and the lens bend at the edge. Overlays over other + content (the sidebar, sheets, dialogs, menus) are the same glass with more + tint and blur so their text stays readable. Both take the rim image and + the lens filter from prismGlass.ts; where the lens can't run (Safari, + Firefox) they keep everything but the bend. */ + +/* Overlays. On narrow screens the sidebar is a sheet that never carries the + docked sidebar's attribute, so it is matched by its slot here. Menus and + popovers carry dropdown-glass on the popup itself; a select's popup slot is + a bare wrapper around a dropdown-glass child, so the class is the one + element to style in all three. */ html[data-theme-id="prism"] :is( + [data-app-sidebar], + [data-slot="sidebar"][data-mobile="true"], [data-slot="sheet-popup"], [data-slot="dialog-popup"], [data-slot="command-dialog-popup"], .dropdown-glass ) { - background: var(--prism-frost); + background-color: var(--prism-overlay-tint); + background-image: var(--prism-rim, none), var(--prism-lg-fill); + background-size: 100% 100%; + background-repeat: no-repeat; border-color: var(--prism-line-strong); box-shadow: var(--prism-lg-inset), var(--prism-shadow); - -webkit-backdrop-filter: var(--prism-frost-blur); - backdrop-filter: var(--prism-frost-blur); + -webkit-backdrop-filter: var(--prism-lens, opacity(1)) var(--prism-overlay-blur); + backdrop-filter: var(--prism-lens, opacity(1)) var(--prism-overlay-blur); +} + +html[data-theme-id="prism"] [data-app-sidebar] { + --sidebar-stage-fade: var(--prism-overlay-tint); +} + +html[data-theme-id="prism"] [data-slot="sidebar"][data-mobile="true"] { + --sidebar-stage-fade: var(--prism-overlay-tint); } html[data-theme-id="prism"] @@ -1699,50 +1698,31 @@ html[data-theme-id="prism"] [data-aldo-assistant-footer] { } } -/* ---- Liquid glass ------------------------------------------------------- - Controls that sit on the scene: the composer, the Aldo dock, toolbar - controls, inputs, and the selected sidebar row. */ - -/* The composer: its backdrop pseudo becomes the glass body, and the outline - pseudo on its host becomes the lit rim (a gradient border, cut with a mask - so only the 1px ring paints). */ +/* Controls. The composer's backdrop pseudo is its glass body (and with a + banner attached, the main surface paints instead); the outline pseudo on + its host is a hairline. The pseudo inherits the lens and rim from the + shell, which is what prismGlass.ts measures. */ html[data-theme-id="prism"] [data-slot="composer-shell"] { --chat-composer-glass-surface: var(--app-theme-surface-raised); - --chat-composer-outline: transparent; + --chat-composer-outline: var(--prism-line-strong); --chat-composer-highlight: transparent; } -html[data-theme-id="prism"] [data-slot="composer-shell"]::before { - background: var(--prism-lg-fill), var(--prism-lg-tint); - box-shadow: var(--prism-lg-inset), var(--prism-shadow); - -webkit-backdrop-filter: var(--prism-lg-blur); - backdrop-filter: var(--prism-lg-blur); -} - -/* With a banner attached the shell hides its backdrop pseudo and the main - surface paints the glass instead. */ +html[data-theme-id="prism"] [data-slot="composer-shell"]::before, html[data-theme-id="prism"] [data-slot="composer-shell"]:has([data-composer-banner-surface="attached"]) [data-chat-composer-main-surface] { - background: var(--prism-lg-fill), var(--prism-lg-tint); + background-color: var(--prism-lg-tint); + background-image: var(--prism-rim, none), var(--prism-lg-fill); + background-size: 100% 100%; + background-repeat: no-repeat; box-shadow: var(--prism-lg-inset), var(--prism-shadow); - -webkit-backdrop-filter: var(--prism-lg-blur); - backdrop-filter: var(--prism-lg-blur); + -webkit-backdrop-filter: var(--prism-lens, opacity(1)) var(--prism-lg-blur); + backdrop-filter: var(--prism-lens, opacity(1)) var(--prism-lg-blur); } html[data-theme-id="prism"] [data-slot="composer-host"]::after, html[data-theme-id="prism"] [data-chat-composer-main-surface]::after { - border: 0; - padding: 1px; - background: var(--prism-lg-rim) border-box; - -webkit-mask: - linear-gradient(#000 0 0) content-box, - linear-gradient(#000 0 0); - mask: - linear-gradient(#000 0 0) content-box, - linear-gradient(#000 0 0); - -webkit-mask-composite: xor; - mask-composite: exclude; box-shadow: none; } @@ -1759,31 +1739,14 @@ html[data-theme-id="prism"] [data-slot="composer-shell"] textarea { /* The Aldo dock: a liquid glass capsule and speech bubble. */ html[data-theme-id="prism"] [data-aldo-dock] { - position: relative; - background: var(--prism-lg-fill), var(--prism-lg-tint); + background-color: var(--prism-lg-tint); + background-image: var(--prism-rim, none), var(--prism-lg-fill); + background-size: 100% 100%; + background-repeat: no-repeat; border-color: transparent; box-shadow: var(--prism-lg-inset), var(--prism-shadow); - -webkit-backdrop-filter: var(--prism-lg-blur); - backdrop-filter: var(--prism-lg-blur); -} - -html[data-theme-id="prism"] [data-aldo-dock]::before { - content: ""; - position: absolute; - inset: 0; - z-index: 1; - padding: 1px; - border-radius: inherit; - background: var(--prism-lg-rim) border-box; - -webkit-mask: - linear-gradient(#000 0 0) content-box, - linear-gradient(#000 0 0); - mask: - linear-gradient(#000 0 0) content-box, - linear-gradient(#000 0 0); - -webkit-mask-composite: xor; - mask-composite: exclude; - pointer-events: none; + -webkit-backdrop-filter: var(--prism-lens, opacity(1)) var(--prism-lg-blur); + backdrop-filter: var(--prism-lens, opacity(1)) var(--prism-lg-blur); } html[data-theme-id="prism"] [data-aldo-dock] [data-slot="input-control"] { @@ -1792,13 +1755,17 @@ html[data-theme-id="prism"] [data-aldo-dock] [data-slot="input-control"] { box-shadow: none; } -/* Toolbar controls and inputs: glass pills with a lit top edge. */ +/* Toolbar controls: small lenses. */ html[data-theme-id="prism"] [data-chat-header] [data-slot="button"], html[data-theme-id="prism"] [data-chat-header] [data-slot="menu-trigger"], html[data-theme-id="prism"] [data-chat-header] [data-toolbar-control] { - background-image: var(--prism-lg-fill); + background-image: var(--prism-rim, none), var(--prism-lg-fill); + background-size: 100% 100%; + background-repeat: no-repeat; border-color: var(--prism-line-strong); - box-shadow: inset 0 1px 0 var(--prism-highlight); + box-shadow: var(--prism-lg-inset); + -webkit-backdrop-filter: var(--prism-lens, opacity(1)) blur(2px) saturate(1.4); + backdrop-filter: var(--prism-lens, opacity(1)) blur(2px) saturate(1.4); } /* Inputs have no blur of their own, so their fill stays thin: the lit top @@ -1819,6 +1786,24 @@ html[data-theme-id="prism"] [data-app-sidebar] :is([aria-current="page"], [data- inset 0 0 0 1px var(--prism-line); } +/* Without the lens (Safari, Firefox), an inner ring stands in for the bezel. */ +html[data-theme-id="prism"][data-prism-lens="none"] + :is( + [data-app-sidebar], + [data-slot="sidebar"][data-mobile="true"], + [data-slot="sheet-popup"], + [data-slot="dialog-popup"], + [data-slot="command-dialog-popup"], + .dropdown-glass, + [data-aldo-dock] + ), +html[data-theme-id="prism"][data-prism-lens="none"] [data-slot="composer-shell"]::before { + box-shadow: + var(--prism-lg-inset), + inset 0 0 28px color-mix(in srgb, var(--prism-ink) 7%, transparent), + var(--prism-shadow); +} + /* The wordmark is set in chrome. */ html[data-theme-id="prism"] .sidebar-brand > span { background-image: var(--prism-chrome-text); diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 34a5ae5cc5e6..23e2bcaa9c11 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -25,9 +25,11 @@ import { installAldoThreadDetails } from "./aldo/threadDetails"; import { installAldoVersionCheck } from "./aldo/version"; import { initializeLandingDemoClientSettings } from "./hooks/useSettings"; import { isLandingDemo } from "./landingDemo/mode"; +import { installPrismGlass } from "./prismGlass"; import { clerkAppearance } from "./components/clerk/clerkAppearance"; async function renderApp() { + installPrismGlass(); const landingDemo = isLandingDemo(); if (!landingDemo) { installManagedCommandDispatch(); diff --git a/apps/web/src/prismGlass.test.ts b/apps/web/src/prismGlass.test.ts new file mode 100644 index 000000000000..745400057914 --- /dev/null +++ b/apps/web/src/prismGlass.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { buildLensMaps, LENS_CONTROL, lensFilterMarkup } from "./prismGlass"; + +function pixel(data: Uint8ClampedArray, width: number, x: number, y: number) { + const o = (y * width + x) * 4; + return [data[o]!, data[o + 1]!, data[o + 2]!, data[o + 3]!] as const; +} + +describe("Prism lens maps", () => { + const maps = buildLensMaps(200, 80, 40, LENS_CONTROL); + + it("leaves the middle untouched and pulls the rim toward the centre", () => { + const centre = pixel(maps.map, maps.width, 100, 40); + expect(centre[0]).toBe(128); + expect(centre[1]).toBe(128); + // At the right edge the eye sees inner content: the sample moves left. + const right = pixel(maps.map, maps.width, maps.width - 1, 40); + expect(right[0]).toBeLessThan(120); + expect(right[1]).toBe(128); + // And at the bottom edge it moves up. + const bottom = pixel(maps.map, maps.width, 100, maps.height - 1); + expect(bottom[0]).toBe(128); + expect(bottom[1]).toBeLessThan(120); + expect(maps.scale).toBe(LENS_CONTROL.shift); + }); + + it("lights the rim from the upper left, with a fainter answer at the lower right", () => { + const top = pixel(maps.rim, maps.width, 100, 1)[3]; + const bottom = pixel(maps.rim, maps.width, 100, maps.height - 2)[3]; + const centre = pixel(maps.rim, maps.width, 100, 40)[3]; + expect(top).toBeGreaterThan(bottom); + expect(bottom).toBeGreaterThan(0); + expect(centre).toBe(0); + }); + + it("caps the map's resolution for big panels and keeps the lens thin on small controls", () => { + const sheet = buildLensMaps(400, 900, 28, LENS_CONTROL); + expect(sheet.width * sheet.height).toBeLessThanOrEqual(160_000); + const button = buildLensMaps(28, 28, 14, LENS_CONTROL); + expect(button.width).toBe(28); + // A 28px circle still has an untouched centre. + expect(pixel(button.map, 28, 14, 14)[0]).toBe(128); + }); + + it("splits the channels only when chroma is on", () => { + expect(lensFilterMarkup("f", 10, 10, "data:,", 18, 0).match(/feDisplacementMap/g)).toHaveLength( + 1, + ); + expect(lensFilterMarkup("f", 10, 10, "data:,", 18, 1).match(/feDisplacementMap/g)).toHaveLength( + 3, + ); + }); +}); diff --git a/apps/web/src/prismGlass.ts b/apps/web/src/prismGlass.ts new file mode 100644 index 000000000000..36b295f7f1e9 --- /dev/null +++ b/apps/web/src/prismGlass.ts @@ -0,0 +1,447 @@ +// Prism's liquid glass. Apple's material is a lens, not a blur: the middle +// passes the backdrop almost untouched and a bezel at the rim bends it, with +// a highlight where the dome's curve faces the light. A browser can do that +// with an SVG displacement map fed to backdrop-filter, but the map has to +// match the element's size, so this runtime builds one per lensed element +// and hands it to the stylesheet through two custom properties: +// +// --prism-lens the filter (url(#...)), Chromium only: Safari and Firefox +// cannot run an SVG filter as a backdrop filter, so they get +// the same glass minus the bend +// --prism-rim the rim light, a white image with alpha, every browser +// +// The stylesheet decides where they apply (see index.css); this file only +// knows which elements to measure. Maps are cached by size, so a toolbar of +// identical buttons shares one filter. + +export type LensParams = Readonly<{ + /** Width of the refracting rim as a fraction of the shorter side. */ + bezel: number; + /** Cap on that width in CSS px, so big panels keep a thin rim. */ + bezelMax: number; + /** How far the backdrop is pulled at the very edge, in CSS px. */ + shift: number; + /** Steepest incidence angle the dome reaches, in radians. */ + thetaMax: number; + /** Index of refraction (glass is about 1.5). */ + ior: number; + /** Red/blue split at the rim, 0 for none. */ + chroma: number; + /** Brightness of the rim light and how tightly it hugs the edge. */ + rimGain: number; + rimPow: number; +}>; + +/** Controls that sit on the scene: thick glass with a visible chromatic rim. */ +export const LENS_CONTROL: LensParams = { + bezel: 0.4, + bezelMax: 56, + shift: 18, + thetaMax: 1.1, + ior: 1.52, + chroma: 1, + rimGain: 2.6, + rimPow: 2.5, +}; + +/** Overlays over other content: the same glass, bending a little less. */ +export const LENS_OVERLAY: LensParams = { ...LENS_CONTROL, shift: 14, rimGain: 2.2 }; + +/** Which elements get a lens, with their params. Matched live as the DOM changes. */ +const LENSED: ReadonlyArray<{ selector: string; params: LensParams; radius?: number }> = [ + { + // The composer shell is square; its glass pseudo rounds itself to 22px, + // or clips to that shape when a context strip hangs off it. + selector: '[data-slot="composer-shell"]', + params: LENS_CONTROL, + radius: 22, + }, + { + selector: + '[data-chat-composer-main-surface], [data-aldo-dock], [data-chat-header] [data-slot="button"], [data-chat-header] [data-slot="menu-trigger"], [data-chat-header] [data-toolbar-control]', + params: LENS_CONTROL, + }, + { + selector: + '[data-slot="dialog-popup"], [data-slot="command-dialog-popup"], [data-slot="sheet-popup"], [data-slot="sidebar"][data-mobile="true"], .dropdown-glass, [data-app-sidebar]', + params: LENS_OVERLAY, + }, +]; +const LENSED_SELECTOR = LENSED.map((entry) => entry.selector).join(", "); + +/** Maps are built at most this many pixels; larger elements scale a smaller map up. */ +const MAX_MAP_PIXELS = 160_000; +/** Cached maps not in use beyond this count are dropped, oldest first. */ +const CACHE_LIMIT = 48; + +const clamp = (value: number, low: number, high: number) => Math.min(high, Math.max(low, value)); +/** The squircle dome Apple favors: flat middle, soft turn, steep at the edge. */ +const profile = (x: number) => Math.pow(1 - Math.pow(1 - x, 4), 0.25); + +/** + * Distance inward from a rounded rectangle's edge, and the outward direction + * there, for a point relative to the centre. + */ +function edgeOf(px: number, py: number, hw: number, hh: number, r: number) { + const ax = Math.abs(px); + const ay = Math.abs(py); + const qx = ax - (hw - r); + const qy = ay - (hh - r); + if (qx > 0 && qy > 0) { + const len = Math.hypot(qx, qy) || 1e-6; + return { d: r - len, dx: (Math.sign(px) * qx) / len, dy: (Math.sign(py) * qy) / len }; + } + if (qx > qy) return { d: hw - ax, dx: Math.sign(px) || 1, dy: 0 }; + return { d: hh - ay, dx: 0, dy: Math.sign(py) || 1 }; +} + +function normalize(v: [number, number, number]): [number, number, number] { + const n = Math.hypot(v[0], v[1], v[2]) || 1; + return [v[0] / n, v[1] / n, v[2] / n]; +} + +export type LensMaps = Readonly<{ + /** RGBA, R = x shift and G = y shift with 128 for none. */ + map: Uint8ClampedArray; + /** RGBA, white with the rim light in alpha. */ + rim: Uint8ClampedArray; + /** Map size in its own pixels (the element may be larger). */ + width: number; + height: number; + /** Displacement scale for the filter, in CSS px. */ + scale: number; +}>; + +/** + * Build the displacement map and rim light for an element of `width` by + * `height` CSS px with corner radius `radius`. Pure, so it is testable and + * cacheable; encoding to images happens in the runtime. + */ +export function buildLensMaps( + width: number, + height: number, + radius: number, + params: LensParams, +): LensMaps { + const w = Math.max(1, Math.round(width)); + const h = Math.max(1, Math.round(height)); + const r = clamp(radius, 0, Math.min(w, h) / 2); + const step = Math.max(1, Math.ceil(Math.sqrt((w * h) / MAX_MAP_PIXELS))); + const mw = Math.ceil(w / step); + const mh = Math.ceil(h / step); + const bezel = Math.min(params.bezel * Math.min(w, h), params.bezelMax); + const map = new Uint8ClampedArray(mw * mh * 4); + const rim = new Uint8ClampedArray(mw * mh * 4); + // Light from the upper left, and a weaker one from the lower right so the + // far edge glows too, as the real material does. + const lightA = normalize([-0.55, -0.75, 0.42]); + const lightB = normalize([0.55, 0.75, 0.3]); + const dd = 0.004; + for (let y = 0; y < mh; y++) { + for (let x = 0; x < mw; x++) { + const px = (x + 0.5) * step - w / 2; + const py = (y + 0.5) * step - h / 2; + const e = edgeOf(px, py, w / 2, h / 2, r); + const t = clamp(e.d / bezel, 0, 1); + // Rays come straight down, meet the dome at its local slope, refract + // once (Snell), and the lateral shift grows toward the edge. + const slope = (profile(clamp(t + dd, 0, 1)) - profile(clamp(t - dd, 0, 1))) / (2 * dd); + const theta = Math.min(Math.atan(slope), params.thetaMax); + const refracted = Math.asin(Math.sin(theta) / params.ior); + const bend = e.d < 0 || t >= 1 ? 0 : Math.tan(theta - refracted) / Math.tan(params.thetaMax); + const o = (y * mw + x) * 4; + // The eye at the edge sees inner content: sample toward the centre. + map[o] = Math.round(128 - e.dx * bend * 127); + map[o + 1] = Math.round(128 - e.dy * bend * 127); + map[o + 2] = 128; + map[o + 3] = 255; + // Rim light: the surface normal tilts outward by theta. + const n = normalize([Math.sin(theta) * e.dx, Math.sin(theta) * e.dy, Math.cos(theta)]); + const kA = Math.pow(Math.max(0, n[0] * lightA[0] + n[1] * lightA[1] + n[2] * lightA[2]), 5); + const kB = Math.pow(Math.max(0, n[0] * lightB[0] + n[1] * lightB[1] + n[2] * lightB[2]), 7); + const inside = e.d >= 0 ? 1 : 0; + const glow = + inside * + Math.pow(clamp(1 - t, 0, 1), params.rimPow) * + (0.95 * kA + 0.6 * kB) * + params.rimGain; + rim[o] = 255; + rim[o + 1] = 255; + rim[o + 2] = 255; + rim[o + 3] = Math.round(clamp(glow, 0, 1) * 255); + } + } + return { map, rim, width: mw, height: mh, scale: params.shift }; +} + +/** The SVG filter: the map bends the backdrop, once per channel when chroma splits them. */ +export function lensFilterMarkup( + id: string, + width: number, + height: number, + mapUrl: string, + scale: number, + chroma: number, +): string { + const open = ``; + const image = ``; + const displace = (factor: number, result: string) => + ``; + if (!chroma) return `${open}${image}${displace(1, "out")}`; + const keep = (channel: "r" | "g" | "b", input: string, result: string) => { + const row = { + r: "1 0 0 0 0 0 0 0 0 0 0 0 0 0 0", + g: "0 0 0 0 0 0 1 0 0 0 0 0 0 0 0", + b: "0 0 0 0 0 0 0 0 0 0 0 0 1 0 0", + }[channel]; + return ``; + }; + return ( + `${open}${image}` + + displace(1 - 0.07 * chroma, "dr") + + keep("r", "dr", "cr") + + displace(1, "dg") + + keep("g", "dg", "cg") + + displace(1 + 0.09 * chroma, "db") + + keep("b", "db", "cb") + + `` + + `` + ); +} + +// ---- Runtime -------------------------------------------------------------- + +type CacheEntry = { + filterId: string | null; + rimUrl: string; + users: number; + filter: SVGElement | null; +}; + +function toDataUrl(data: Uint8ClampedArray, width: number, height: number): string { + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext("2d"); + if (!context) return ""; + context.putImageData(new ImageData(data, width, height), 0, 0); + return canvas.toDataURL("image/png"); +} + +/** Only Chromium runs an SVG filter inside backdrop-filter; others get the rim without the bend. */ +function supportsLensFilter(): boolean { + const data = (navigator as Navigator & { userAgentData?: { brands: Array<{ brand: string }> } }) + .userAgentData; + if (data) return data.brands.some((entry) => /chromium/i.test(entry.brand)); + const ua = navigator.userAgent; + return /Chrome\/\d+/.test(ua) && !/Firefox|FxiOS/.test(ua); +} + +/** The element's corner radius, or its backdrop pseudo's when the rounding lives there (the composer). */ +function cornerRadius(element: Element, width: number, height: number): number { + for (const pseudo of [undefined, "::before"]) { + const raw = getComputedStyle(element, pseudo).borderTopLeftRadius.trim(); + const value = Number.parseFloat(raw); + if (!Number.isFinite(value) || value <= 0) continue; + return raw.endsWith("%") ? (Math.min(width, height) * value) / 100 : value; + } + return 0; +} + +function lensedEntry(element: Element): (typeof LENSED)[number] | null { + for (const entry of LENSED) if (element.matches(entry.selector)) return entry; + return null; +} + +function createRuntime() { + const lens = supportsLensFilter(); + const cache = new Map(); + const tracked = new Map(); + let defs: SVGDefsElement | null = null; + let frame = 0; + const pending = new Set(); + + function defsHost(): SVGDefsElement { + if (defs?.isConnected) return defs; + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.setAttribute("aria-hidden", "true"); + svg.setAttribute("data-prism-glass", ""); + svg.setAttribute("style", "position:absolute;width:0;height:0;overflow:hidden"); + defs = document.createElementNS("http://www.w3.org/2000/svg", "defs"); + svg.append(defs); + document.body.append(svg); + return defs; + } + + function entryFor( + key: string, + width: number, + height: number, + radius: number, + params: LensParams, + ): CacheEntry { + const cached = cache.get(key); + if (cached) { + cache.delete(key); + cache.set(key, cached); // most recently used last + return cached; + } + const maps = buildLensMaps(width, height, radius, params); + const rimUrl = toDataUrl(maps.rim, maps.width, maps.height); + let filterId: string | null = null; + let filter: SVGElement | null = null; + if (lens) { + filterId = `prism-lens-${cache.size}-${Math.round(width)}x${Math.round(height)}-${Math.round(radius)}`; + const host = defsHost(); + host.insertAdjacentHTML( + "beforeend", + lensFilterMarkup( + filterId, + Math.round(width), + Math.round(height), + toDataUrl(maps.map, maps.width, maps.height), + maps.scale, + params.chroma, + ), + ); + filter = host.lastElementChild as SVGElement; + } + const entry: CacheEntry = { filterId, rimUrl, users: 0, filter }; + cache.set(key, entry); + trim(); + return entry; + } + + function trim() { + if (cache.size <= CACHE_LIMIT) return; + for (const [key, entry] of cache) { + if (cache.size <= CACHE_LIMIT) break; + if (entry.users > 0) continue; + entry.filter?.remove(); + cache.delete(key); + } + } + + function release(element: Element) { + const state = tracked.get(element); + if (!state) return; + if (state.key) { + const entry = cache.get(state.key); + if (entry) entry.users -= 1; + } + state.observer.disconnect(); + tracked.delete(element); + pending.delete(element); + (element as HTMLElement).style.removeProperty("--prism-lens"); + (element as HTMLElement).style.removeProperty("--prism-rim"); + } + + function apply(element: Element) { + const state = tracked.get(element); + const entry = lensedEntry(element); + if (!state || !entry || !element.isConnected) return; + const params = entry.params; + const width = (element as HTMLElement).offsetWidth; + const height = (element as HTMLElement).offsetHeight; + if (width < 2 || height < 2) return; + const radius = cornerRadius(element, width, height) || entry.radius || 0; + const key = `${params === LENS_OVERLAY ? "o" : "c"}:${width}x${height}:${Math.round(radius)}`; + if (key === state.key) return; + if (state.key) { + const previous = cache.get(state.key); + if (previous) previous.users -= 1; + } + const cached = entryFor(key, width, height, radius, params); + cached.users += 1; + state.key = key; + const style = (element as HTMLElement).style; + if (cached.filterId) style.setProperty("--prism-lens", `url(#${cached.filterId})`); + style.setProperty("--prism-rim", `url("${cached.rimUrl}")`); + } + + function flush() { + frame = 0; + const batch = [...pending]; + pending.clear(); + for (const element of batch) apply(element); + } + + function schedule(element: Element) { + pending.add(element); + if (!frame) frame = requestAnimationFrame(flush); + } + + function track(element: Element) { + if (tracked.has(element) || !lensedEntry(element)) return; + const observer = new ResizeObserver(() => schedule(element)); + tracked.set(element, { key: null, observer }); + observer.observe(element); + schedule(element); + } + + function scan(root: ParentNode) { + if (root instanceof Element && root.matches(LENSED_SELECTOR)) track(root); + for (const element of root.querySelectorAll(LENSED_SELECTOR)) track(element); + } + + const mutations = new MutationObserver((records) => { + for (const record of records) { + for (const node of record.removedNodes) { + if (!(node instanceof Element)) continue; + for (const element of Array.from(tracked.keys())) + if (node === element || node.contains(element)) release(element); + } + for (const node of record.addedNodes) if (node instanceof Element) scan(node); + } + }); + + return { + start() { + document.documentElement.dataset.prismLens = lens ? "svg" : "none"; + scan(document.body); + mutations.observe(document.body, { childList: true, subtree: true }); + }, + stop() { + mutations.disconnect(); + if (frame) cancelAnimationFrame(frame); + frame = 0; + for (const element of Array.from(tracked.keys())) release(element); + for (const entry of cache.values()) entry.filter?.remove(); + cache.clear(); + defs?.parentElement?.remove(); + defs = null; + delete document.documentElement.dataset.prismLens; + }, + }; +} + +/** + * Runs the glass while the Prism theme is on: watches the document's theme + * id and starts or stops with it. Safe to call once at startup. + */ +export function installPrismGlass(): () => void { + if (typeof document === "undefined" || typeof ResizeObserver === "undefined") return () => {}; + let runtime: ReturnType | null = null; + const sync = () => { + const on = document.documentElement.dataset.themeId === "prism"; + if (on && !runtime) { + runtime = createRuntime(); + runtime.start(); + } else if (!on && runtime) { + runtime.stop(); + runtime = null; + } + }; + const observer = new MutationObserver(sync); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["data-theme-id"], + }); + if (document.body) sync(); + else document.addEventListener("DOMContentLoaded", sync, { once: true }); + return () => { + observer.disconnect(); + runtime?.stop(); + runtime = null; + }; +} From ca6e4bbc4cf97d4b60a63ad209d5a5c50c3361f4 Mon Sep 17 00:00:00 2001 From: Logan Sack <12778322+logancsack@users.noreply.github.com> Date: Thu, 1 Oct 2026 04:59:39 +0000 Subject: [PATCH 2/4] feat(web): Prism's lens is for controls, calibrated against iOS 26 The lens stays on the composer, primary buttons and the composer's send button, the Aldo dock, and the thread toolbar's pills; sidebars, sheets, dialogs, and menus go back to frosted glass, which is what a surface that size wants. Checked against Apple's screenshots, the rim was a wide glowing band where theirs is a line a couple of pixels wide with bright arcs on the corners that face the light, and the bend is subtle on small controls. The rim is now a pixel-width Gaussian with a faint inner shading for thickness, the bend follows the control's size (a 28px button shifts 4px, the composer 14px), the blur is a regular 6px, and the chroma split is halved. Buttons announce their variant so the stylesheet can find the primary ones. Co-Authored-By: Claude Fable 5.1 --- .../chat/ComposerPrimaryActions.tsx | 1 + apps/web/src/components/ui/button.tsx | 1 + apps/web/src/index.css | 158 ++++++++++-------- apps/web/src/prismGlass.test.ts | 13 +- apps/web/src/prismGlass.ts | 76 +++++---- 5 files changed, 146 insertions(+), 103 deletions(-) diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.tsx index 82fa3359bc12..3ed292bc953e 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.tsx @@ -222,6 +222,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ const sendButton = (