Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions apps/web/src/components/chat/ComposerPrimaryActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
const sendButton = (
<button
type="submit"
data-composer-send=""
className={cn(
"relative isolate flex h-9 w-9 items-center justify-center overflow-hidden rounded-full shadow-xs transition-all duration-150 enabled:cursor-pointer enabled:inset-shadow-[0_1px_--theme(--color-white/16%)] hover:scale-105 active:inset-shadow-[0_1px_--theme(--color-black/8%)] active:shadow-none disabled:pointer-events-none disabled:opacity-30 disabled:shadow-none disabled:hover:scale-100 sm:h-8 sm:w-8",
stageBackdropVariant
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/ui/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ function Button({ className, variant, size, render, ...props }: ButtonProps) {
const defaultProps = {
className: cn(buttonVariants({ className, size, variant })),
"data-slot": "button",
"data-variant": variant ?? "default",
type: typeValue,
};

Expand Down
181 changes: 91 additions & 90 deletions apps/web/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -1403,6 +1403,9 @@ html[data-theme-id="prism"] {
--prism-fill-hover: color-mix(in srgb, var(--prism-ink) 8%, transparent);
--prism-fill-active: color-mix(in srgb, var(--prism-ink) 11%, transparent);
--prism-highlight: oklch(1 0 0 / 85%);
--prism-frost-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%);
/* The canvas stacks two or three deep (route, view, column), so it stays
thin for the scene to come through. */
--prism-canvas-opacity: 34%;
Expand All @@ -1416,25 +1419,21 @@ 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, for controls only: a light tint, a faint specular fill,
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) 46%, transparent);
--prism-lg-fill: linear-gradient(
135deg,
oklch(1 0 0 / 70%) 0%,
oklch(1 0 0 / 34%) 42%,
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%
oklch(1 0 0 / 60%) 0%,
oklch(1 0 0 / 30%) 45%,
oklch(1 0 0 / 20%) 70%,
oklch(1 0 0 / 45%) 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(0.2 0.02 268 / 16%);
--prism-lg-blur: blur(6px) saturate(1.3);
--prism-lg-shadow:
0 10px 30px -12px oklch(0.25 0.03 268 / 35%), 0 1px 2px oklch(0.25 0.03 268 / 12%);
--prism-shadow: 0 24px 64px -28px oklch(0.25 0.03 268 / 40%);
--prism-chrome-text: linear-gradient(
180deg,
Expand Down Expand Up @@ -1472,32 +1471,27 @@ html[data-theme-id="prism"] {
@variant dark {
--prism-ink: oklch(1 0 0);
--prism-highlight: oklch(1 0 0 / 14%);
--prism-frost-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-canvas-opacity: 20%;
--prism-pane-opacity: 58%;
--prism-frost: color-mix(in srgb, var(--app-theme-surface-overlay) 82%, transparent);
--prism-frost-sidebar: color-mix(in srgb, var(--app-theme-sidebar) 84%, transparent);
--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) 40%, transparent);
--prism-lg-fill: linear-gradient(
135deg,
oklch(1 0 0 / 16%) 0%,
oklch(1 0 0 / 5%) 42%,
oklch(1 0 0 / 9%) 0%,
oklch(1 0 0 / 3%) 45%,
oklch(1 0 0 / 2%) 70%,
oklch(1 0 0 / 9%) 100%
oklch(1 0 0 / 6%) 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 / 12%);
--prism-lg-blur: blur(6px) saturate(1.4) brightness(1.03);
--prism-lg-shadow: 0 10px 30px -12px oklch(0 0 0 / 60%), 0 1px 2px oklch(0 0 0 / 25%);
--prism-shadow: 0 28px 72px -28px oklch(0 0 0 / 85%);
--prism-chrome-text: linear-gradient(
180deg,
Expand Down Expand Up @@ -1642,7 +1636,7 @@ html[data-theme-id="prism"]
) {
background: var(--prism-frost);
border-color: var(--prism-line-strong);
box-shadow: var(--prism-lg-inset), var(--prism-shadow);
box-shadow: var(--prism-frost-inset), var(--prism-shadow);
-webkit-backdrop-filter: var(--prism-frost-blur);
backdrop-filter: var(--prism-frost-blur);
}
Expand Down Expand Up @@ -1700,49 +1694,37 @@ 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 that sit on the scene: the composer, primary buttons, the Aldo
dock, and the thread toolbar's pills. Each is a thick lens: a light tint
over the bent, lightly blurred backdrop, a faint specular fill, a thin rim
lit from the upper left, and a chromatic edge. prismGlass.ts measures
these elements and hands them --prism-rim and --prism-lens; where the lens
can't run (Safari, Firefox) the rim and tint still read as glass. */

/* The composer: its backdrop pseudo is the 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. */
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);
box-shadow: var(--prism-lg-inset), var(--prism-shadow);
-webkit-backdrop-filter: var(--prism-lg-blur);
backdrop-filter: var(--prism-lg-blur);
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-lg-shadow);
-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;
}

Expand All @@ -1759,31 +1741,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;
box-shadow: var(--prism-lg-inset), var(--prism-lg-shadow);
-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"] {
Expand All @@ -1792,13 +1757,39 @@ 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. */
/* Primary buttons and the composer's send button: the action color as
glass, light enough for its ink. */
html[data-theme-id="prism"] [data-slot="button"][data-variant="default"],
html[data-theme-id="prism"] [data-composer-send] {
background-color: color-mix(in srgb, var(--primary) 74%, transparent);
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),
0 6px 18px -8px oklch(0 0 0 / 45%);
-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="button"][data-variant="default"]:hover,
html[data-theme-id="prism"] [data-slot="button"][data-variant="default"][data-pressed],
html[data-theme-id="prism"] [data-composer-send]:enabled:hover {
background-color: color-mix(in srgb, var(--primary) 86%, transparent);
}

/* Toolbar pills: 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(4px) saturate(1.3);
backdrop-filter: var(--prism-lens, opacity(1)) blur(4px) saturate(1.3);
}

/* Inputs have no blur of their own, so their fill stays thin: the lit top
Expand All @@ -1819,6 +1810,16 @@ 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-aldo-dock], [data-slot="button"][data-variant="default"], [data-composer-send]),
html[data-theme-id="prism"][data-prism-lens="none"] [data-slot="composer-shell"]::before {
box-shadow:
var(--prism-lg-inset),
inset 0 0 18px color-mix(in srgb, var(--prism-ink) 6%, transparent),
var(--prism-lg-shadow);
}

/* The wordmark is set in chrome. */
html[data-theme-id="prism"] .sidebar-brand > span {
background-image: var(--prism-chrome-text);
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
59 changes: 59 additions & 0 deletions apps/web/src/prismGlass.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
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);
// An 80px-tall control bends 8px; the shift never exceeds the parameter.
expect(maps.scale).toBe(8);
expect(buildLensMaps(600, 300, 24, LENS_CONTROL).scale).toBe(LENS_CONTROL.shift);
expect(buildLensMaps(28, 28, 14, LENS_CONTROL).scale).toBe(4);
});

it("lights a thin rim from the upper left, with a fainter answer at the lower right", () => {
const top = pixel(maps.rim, maps.width, 100, 0)[3];
const bottom = pixel(maps.rim, maps.width, 100, maps.height - 1)[3];
const centre = pixel(maps.rim, maps.width, 100, 40)[3];
expect(top).toBeGreaterThan(bottom);
expect(bottom).toBeGreaterThan(0);
expect(centre).toBe(0);
// A few pixels in, the line is gone and only the faint thickness shading remains.
expect(pixel(maps.rim, maps.width, 100, 8)[3]).toBeLessThan(top / 4);
});

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,
);
});
});
Loading