authorgravatar for 269035359+t3-code[bot]@users.noreply.github.comt3-code[bot] <269035359+t3-code[bot]@users.noreply.github.com> 2026-08-22 18:00:21-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-22 18:00:21-04:00
log6e9c57f7ba9eab9e987da7a660664a1df683e9cf
tree3225481ba0247938c6985573fd1b82d2b4dbf334
parent6c693baecf75454cac96d1ae3f78afc73f2cbe53
signature Signed by PGP key B5690EEEBB952194

feat(web): add appearance contrast control (#7906)

Co-authored-by: t3-code[bot] <269035359+t3-code[bot]@users.noreply.github.com> Co-authored-by: maria <254055478+maria-rcks@users.noreply.github.com>

26 files changed, 404 insertions(+), 102 deletions(-)

.macroscope/check-run-agents/ui-consistency.md+3
...@@ -47,6 +47,9 @@ The goal is not to minimize CSS or class counts at any cost. The goal is to put...@@ -47,6 +47,9 @@ The goal is not to minimize CSS or class counts at any cost. The goal is to put
47 - light-only declarations use `@variant light`;47 - light-only declarations use `@variant light`;
48 - raw `.dark` should remain only in the `dark` and `light` custom-variant definitions.48 - raw `.dark` should remain only in the `dark` and `light` custom-variant definitions.
49- Preserve custom themes and runtime token bridges. Removing a variable or selector is safe only when all runtime, inspector, generated, and theme-palette consumers are accounted for.49- Preserve custom themes and runtime token bridges. Removing a variable or selector is safe only when all runtime, inspector, generated, and theme-palette consumers are accounted for.
50- Contrast and accessibility settings that target app chrome must derive from semantic color tokens. Do not apply `filter` to `html`, `body`, or the app root: it also changes user media, previews, terminals, glass backdrop ownership, and view-transition snapshots.
51- Preserve alpha and surface ownership when deriving contrast tokens. Soften translucent borders and inputs toward transparent rather than an opaque canvas, use a modest semantic-foreground mix for stronger borders, and adjust card, popover, accent, secondary, and message foregrounds against their own surfaces when the base foreground changes.
52- Runtime-adjusted roles must be ordinary custom properties shared by the Tailwind bridge, global CSS, imperative style strings, and bridge snapshots sent to other renderers. Audit literal `var(--foreground)`, `var(--border)`, and related role reads so headings, markdown chrome, menus, previews, and utilities do not split into adjusted and unadjusted colors.
50- Inspect emitted production CSS after unusual variants, arbitrary selectors, nested pseudo-elements, or attribute matching. Source syntax that looks valid is insufficient.53- Inspect emitted production CSS after unusual variants, arbitrary selectors, nested pseudo-elements, or attribute matching. Source syntax that looks valid is insufficient.
51- Flag malformed or empty emitted selectors such as empty `:is()` or `:not(:is())`, selector branches that can never match their own class attribute, and transformations that silently drop the intended rule.54- Flag malformed or empty emitted selectors such as empty `:is()` or `:not(:is())`, selector branches that can never match their own class attribute, and transformations that silently drop the intended rule.
52- Prefer source-level logic over clever selectors when behavior depends on consumer-provided class strings. Preserve `MenuPopup`'s current defaulting contract: a string `className` containing a `w-*`, `min-w-*`, or `max-w-*` utility after variant prefixes are stripped suppresses `min-w-32`; a string without one and a functional/non-string `className` keep the default. Arbitrary width values count as width utilities, and the consumer class must be merged last so it retains control. Do not replace this with a raw class-attribute substring selector.55- Prefer source-level logic over clever selectors when behavior depends on consumer-provided class strings. Preserve `MenuPopup`'s current defaulting contract: a string `className` containing a `w-*`, `min-w-*`, or `max-w-*` utility after variant prefixes are stripped suppresses `min-w-32`; a string without one and a functional/non-string `className` keep the default. Arbitrary width values count as width utilities, and the consumer class must be merged last so it retains control. Do not replace this with a raw class-attribute substring selector.
apps/desktop/src/settings/DesktopClientSettings.test.ts+1
...@@ -13,6 +13,7 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";...@@ -13,6 +13,7 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
13import * as DesktopClientSettings from "./DesktopClientSettings.ts";13import * as DesktopClientSettings from "./DesktopClientSettings.ts";
1414
15const clientSettings: ClientSettings = {15const clientSettings: ClientSettings = {
16 appearanceContrast: 100,
16 browserDefaultViewport: { _tag: "preset", width: 1024, height: 600, presetId: "nest-hub" },17 browserDefaultViewport: { _tag: "preset", width: 1024, height: 600, presetId: "nest-hub" },
17 browserDefaultZoomFactor: 1.25,18 browserDefaultZoomFactor: 1.25,
18 browserDefaultAppearance: "dark",19 browserDefaultAppearance: "dark",
apps/web/src/appearanceContrast.test.ts created+53
...@@ -0,0 +1,53 @@
1import { describe, expect, it, vi } from "vite-plus/test";
2
3import { applyAppearanceContrast } from "./appearanceContrast";
4
5function makeRoot() {
6 const setProperty = vi.fn();
7 return {
8 root: { style: { setProperty } } as unknown as HTMLElement,
9 setProperty,
10 };
11}
12
13describe("applyAppearanceContrast", () => {
14 it("boosts semantic contrast above the default", () => {
15 const { root, setProperty } = makeRoot();
16
17 applyAppearanceContrast(root, 135);
18
19 expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-base", "100%");
20 expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-boost", "35%");
21 expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-border-boost", "8.75%");
22 });
23
24 it("supports the maximum contrast boost", () => {
25 const { root, setProperty } = makeRoot();
26
27 applyAppearanceContrast(root, 200);
28
29 expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-base", "100%");
30 expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-boost", "100%");
31 expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-border-boost", "25%");
32 });
33
34 it("softens semantic contrast below the default", () => {
35 const { root, setProperty } = makeRoot();
36
37 applyAppearanceContrast(root, 70);
38
39 expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-base", "70%");
40 expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-boost", "0%");
41 expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-border-boost", "0%");
42 });
43
44 it("disables contrast mixing at the default", () => {
45 const { root, setProperty } = makeRoot();
46
47 applyAppearanceContrast(root, 100);
48
49 expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-base", "100%");
50 expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-boost", "0%");
51 expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-border-boost", "0%");
52 });
53});
apps/web/src/appearanceContrast.ts created+10
...@@ -0,0 +1,10 @@
1import type { AppearanceContrast } from "@t3tools/contracts/settings";
2
3export function applyAppearanceContrast(root: HTMLElement, contrast: AppearanceContrast): void {
4 root.style.setProperty("--appearance-contrast-base", `${Math.min(contrast, 100)}%`);
5 root.style.setProperty("--appearance-contrast-boost", `${Math.max(contrast - 100, 0)}%`);
6 root.style.setProperty(
7 "--appearance-contrast-border-boost",
8 `${Math.max(contrast - 100, 0) / 4}%`,
9 );
10}
apps/web/src/browser/annotationTheme.ts+6-6
...@@ -10,17 +10,17 @@ export function readPreviewAnnotationTheme(): DesktopPreviewAnnotationTheme {...@@ -10,17 +10,17 @@ export function readPreviewAnnotationTheme(): DesktopPreviewAnnotationTheme {
10 colorScheme: root.classList.contains("dark") ? "dark" : "light",10 colorScheme: root.classList.contains("dark") ? "dark" : "light",
11 radius: readVariable(styles, "--radius", "0.625rem"),11 radius: readVariable(styles, "--radius", "0.625rem"),
12 background: readVariable(styles, "--background", "white"),12 background: readVariable(styles, "--background", "white"),
13 foreground: readVariable(styles, "--foreground", "oklch(0.269 0 0)"),13 foreground: readVariable(styles, "--contrast-foreground", "oklch(0.269 0 0)"),
14 popover: readVariable(styles, "--popover", "white"),14 popover: readVariable(styles, "--popover", "white"),
15 popoverForeground: readVariable(styles, "--popover-foreground", "oklch(0.269 0 0)"),15 popoverForeground: readVariable(styles, "--contrast-popover-foreground", "oklch(0.269 0 0)"),
16 primary: readVariable(styles, "--primary", "oklch(0.488 0.217 264)"),16 primary: readVariable(styles, "--primary", "oklch(0.488 0.217 264)"),
17 primaryForeground: readVariable(styles, "--primary-foreground", "white"),17 primaryForeground: readVariable(styles, "--primary-foreground", "white"),
18 muted: readVariable(styles, "--muted", "rgb(0 0 0 / 4%)"),18 muted: readVariable(styles, "--muted", "rgb(0 0 0 / 4%)"),
19 mutedForeground: readVariable(styles, "--muted-foreground", "oklch(0.556 0 0)"),19 mutedForeground: readVariable(styles, "--contrast-muted-foreground", "oklch(0.556 0 0)"),
20 accent: readVariable(styles, "--accent", "rgb(0 0 0 / 4%)"),20 accent: readVariable(styles, "--accent", "rgb(0 0 0 / 4%)"),
21 accentForeground: readVariable(styles, "--accent-foreground", "oklch(0.269 0 0)"),21 accentForeground: readVariable(styles, "--contrast-accent-foreground", "oklch(0.269 0 0)"),
22 border: readVariable(styles, "--border", "rgb(0 0 0 / 8%)"),22 border: readVariable(styles, "--contrast-border", "rgb(0 0 0 / 8%)"),
23 input: readVariable(styles, "--input", "rgb(0 0 0 / 10%)"),23 input: readVariable(styles, "--contrast-input", "rgb(0 0 0 / 10%)"),
24 ring: readVariable(styles, "--ring", "oklch(0.488 0.217 264)"),24 ring: readVariable(styles, "--ring", "oklch(0.488 0.217 264)"),
25 fontSans: readVariable(styles, "--font-sans", styles.fontFamily || "system-ui, sans-serif"),25 fontSans: readVariable(styles, "--font-sans", styles.fontFamily || "system-ui, sans-serif"),
26 fontMono: readVariable(styles, "--font-mono", "ui-monospace, monospace"),26 fontMono: readVariable(styles, "--font-mono", "ui-monospace, monospace"),
apps/web/src/components/ChatMarkdown.tsx+1-1
...@@ -1412,7 +1412,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({...@@ -1412,7 +1412,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
1412 >1412 >
1413 {/* The full path: the chip already shows the shortened form, and a link1413 {/* The full path: the chip already shows the shortened form, and a link
1414 to the workspace root collapses to a bare label that repeats it. */}1414 to the workspace root collapses to a bare label that repeats it. */}
1415 <div className="overflow-x-auto whitespace-nowrap [scrollbar-color:color-mix(in_srgb,var(--border)_78%,transparent)_transparent] [scrollbar-width:thin] [&::-webkit-scrollbar]:h-1.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-[color-mix(in_srgb,var(--border)_78%,transparent)] [&::-webkit-scrollbar-track]:bg-transparent">1415 <div className="overflow-x-auto whitespace-nowrap [scrollbar-color:color-mix(in_srgb,var(--contrast-border)_78%,transparent)_transparent] [scrollbar-width:thin] [&::-webkit-scrollbar]:h-1.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-[color-mix(in_srgb,var(--contrast-border)_78%,transparent)] [&::-webkit-scrollbar-track]:bg-transparent">
1416 {targetPath}1416 {targetPath}
1417 </div>1417 </div>
1418 </TooltipPopup>1418 </TooltipPopup>
apps/web/src/components/chat/ChangedFilesTree.tsx+1-1
...@@ -66,7 +66,7 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: {...@@ -66,7 +66,7 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: {
66 className={cn(66 className={cn(
67 "flex items-center justify-between gap-2 rounded-xl",67 "flex items-center justify-between gap-2 rounded-xl",
68 expanded &&68 expanded &&
69 "sticky top-2 z-10 mb-2 bg-secondary dark:bg-[color-mix(in_srgb,var(--foreground)_2.5%,var(--background))]",69 "sticky top-2 z-10 mb-2 bg-secondary dark:bg-[color-mix(in_srgb,var(--contrast-foreground)_2.5%,var(--background))]",
70 )}70 )}
71 >71 >
72 <button72 <button
apps/web/src/components/chat/ModelListRow.tsx+1-1
...@@ -52,7 +52,7 @@ export const ModelListRow = memo(function ModelListRow(props: {...@@ -52,7 +52,7 @@ export const ModelListRow = memo(function ModelListRow(props: {
52 contentClassName="flex w-full items-center gap-3"52 contentClassName="flex w-full items-center gap-3"
53 className={cn(53 className={cn(
54 "group relative w-full !min-w-0 max-w-full cursor-pointer rounded-md px-2 py-2 transition-[background-color,box-shadow,color]",54 "group relative w-full !min-w-0 max-w-full cursor-pointer rounded-md px-2 py-2 transition-[background-color,box-shadow,color]",
55 "hover:bg-[color-mix(in_srgb,var(--popover)_90%,var(--foreground))] data-highlighted:bg-[color-mix(in_srgb,var(--popover)_90%,var(--foreground))] data-selected:bg-foreground/[0.08] data-selected:text-foreground data-selected:ring-0 [&[data-highlighted][data-selected]]:bg-[color-mix(in_srgb,var(--popover)_90%,var(--foreground))]",55 "hover:bg-[color-mix(in_srgb,var(--popover)_90%,var(--contrast-foreground))] data-highlighted:bg-[color-mix(in_srgb,var(--popover)_90%,var(--contrast-foreground))] data-selected:bg-foreground/[0.08] data-selected:text-foreground data-selected:ring-0 [&[data-highlighted][data-selected]]:bg-[color-mix(in_srgb,var(--popover)_90%,var(--contrast-foreground))]",
56 props.disabledReason &&56 props.disabledReason &&
57 "data-disabled:pointer-events-auto data-disabled:cursor-not-allowed data-disabled:hover:bg-transparent",57 "data-disabled:pointer-events-auto data-disabled:cursor-not-allowed data-disabled:hover:bg-transparent",
58 )}58 )}
apps/web/src/components/chat/ModelPickerSidebar.tsx+2-2
...@@ -107,7 +107,7 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: {...@@ -107,7 +107,7 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: {
107 render={107 render={
108 <button108 <button
109 className={cn(109 className={cn(
110 "relative isolate flex w-full cursor-pointer aspect-square items-center justify-center rounded-md transition-colors hover:bg-[color-mix(in_srgb,var(--popover)_90%,var(--foreground))] focus-visible:bg-[color-mix(in_srgb,var(--popover)_90%,var(--foreground))] focus-visible:outline-none",110 "relative isolate flex w-full cursor-pointer aspect-square items-center justify-center rounded-md transition-colors hover:bg-[color-mix(in_srgb,var(--popover)_90%,var(--contrast-foreground))] focus-visible:bg-[color-mix(in_srgb,var(--popover)_90%,var(--contrast-foreground))] focus-visible:outline-none",
111 )}111 )}
112 onClick={() => handleSelect("favorites")}112 onClick={() => handleSelect("favorites")}
113 type="button"113 type="button"
...@@ -152,7 +152,7 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: {...@@ -152,7 +152,7 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: {
152 const button = (152 const button = (
153 <button153 <button
154 className={cn(154 className={cn(
155 "relative isolate flex w-full cursor-pointer aspect-square items-center justify-center rounded-md transition-colors hover:bg-[color-mix(in_srgb,var(--popover)_90%,var(--foreground))] focus-visible:bg-[color-mix(in_srgb,var(--popover)_90%,var(--foreground))] focus-visible:outline-none",155 "relative isolate flex w-full cursor-pointer aspect-square items-center justify-center rounded-md transition-colors hover:bg-[color-mix(in_srgb,var(--popover)_90%,var(--contrast-foreground))] focus-visible:bg-[color-mix(in_srgb,var(--popover)_90%,var(--contrast-foreground))] focus-visible:outline-none",
156 isDisabled && "opacity-50 cursor-not-allowed hover:bg-transparent",156 isDisabled && "opacity-50 cursor-not-allowed hover:bg-transparent",
157 )}157 )}
158 data-provider-accent-color={entry.accentColor}158 data-provider-accent-color={entry.accentColor}
apps/web/src/components/chat/ProviderModelPicker.tsx+1-1
...@@ -166,7 +166,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {...@@ -166,7 +166,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
166 showBadge={showInstanceBadge}166 showBadge={showInstanceBadge}
167 className="size-4"167 className="size-4"
168 iconClassName={cn("size-4", props.activeProviderIconClassName)}168 iconClassName={cn("size-4", props.activeProviderIconClassName)}
169 indicatorBackground="var(--input)"169 indicatorBackground="var(--contrast-input)"
170 badgeClassName={cn(170 badgeClassName={cn(
171 "right-[-0.125rem] bottom-[-0.125rem] h-3 min-w-3",171 "right-[-0.125rem] bottom-[-0.125rem] h-3 min-w-3",
172 "px-0.5 text-[7px]",172 "px-0.5 text-[7px]",
apps/web/src/components/clerk/clerkAppearance.test.ts+5-5
...@@ -59,12 +59,12 @@ describe("clerkAppearance", () => {...@@ -59,12 +59,12 @@ describe("clerkAppearance", () => {
59 colorDanger: "var(--error)",59 colorDanger: "var(--error)",
60 colorSuccess: "var(--success)",60 colorSuccess: "var(--success)",
61 colorWarning: "var(--warning)",61 colorWarning: "var(--warning)",
62 colorNeutral: "var(--foreground)",62 colorNeutral: "var(--contrast-foreground)",
63 colorForeground: "var(--foreground)",63 colorForeground: "var(--contrast-foreground)",
64 colorMuted: "color-mix(in srgb, var(--card) 98%, var(--foreground))",64 colorMuted: "color-mix(in srgb, var(--card) 98%, var(--contrast-foreground))",
65 colorMutedForeground: "var(--muted-foreground)",65 colorMutedForeground: "var(--contrast-muted-foreground)",
66 colorBackground: "var(--card)",66 colorBackground: "var(--card)",
67 colorInputForeground: "var(--foreground)",67 colorInputForeground: "var(--contrast-foreground)",
68 colorInput: "var(--secondary)",68 colorInput: "var(--secondary)",
69 colorRing: "var(--ring)",69 colorRing: "var(--ring)",
70 },70 },
apps/web/src/components/clerk/clerkAppearance.ts+5-5
...@@ -13,14 +13,14 @@ export const clerkAppearance = {...@@ -13,14 +13,14 @@ export const clerkAppearance = {
13 colorDanger: "var(--error)",13 colorDanger: "var(--error)",
14 colorSuccess: "var(--success)",14 colorSuccess: "var(--success)",
15 colorWarning: "var(--warning)",15 colorWarning: "var(--warning)",
16 colorNeutral: "var(--foreground)",16 colorNeutral: "var(--contrast-foreground)",
17 colorForeground: "var(--foreground)",17 colorForeground: "var(--contrast-foreground)",
18 // The stock dark theme's muted token is translucent. Clerk uses this as18 // The stock dark theme's muted token is translucent. Clerk uses this as
19 // the footer's background, so derive an opaque muted surface from the card.19 // the footer's background, so derive an opaque muted surface from the card.
20 colorMuted: "color-mix(in srgb, var(--card) 98%, var(--foreground))",20 colorMuted: "color-mix(in srgb, var(--card) 98%, var(--contrast-foreground))",
21 colorMutedForeground: "var(--muted-foreground)",21 colorMutedForeground: "var(--contrast-muted-foreground)",
22 colorBackground: "var(--card)",22 colorBackground: "var(--card)",
23 colorInputForeground: "var(--foreground)",23 colorInputForeground: "var(--contrast-foreground)",
24 colorInput: "var(--secondary)",24 colorInput: "var(--secondary)",
25 colorRing: "var(--ring)",25 colorRing: "var(--ring)",
26 },26 },
apps/web/src/components/color-selector.tsx+1-1
...@@ -13,7 +13,7 @@ interface ColorSelectorProps {...@@ -13,7 +13,7 @@ interface ColorSelectorProps {
13}13}
1414
15const colorMap = {15const colorMap = {
16 default: "var(--foreground)",16 default: "var(--contrast-foreground)",
17 red: "var(--color-red-500)",17 red: "var(--color-red-500)",
18 green: "var(--color-green-500)",18 green: "var(--color-green-500)",
19 blue: "var(--color-blue-500)",19 blue: "var(--color-blue-500)",
apps/web/src/components/files/FileBrowserPanel.tsx+1-1
...@@ -378,7 +378,7 @@ export default function FileBrowserPanel({...@@ -378,7 +378,7 @@ export default function FileBrowserPanel({
378 className="min-h-0 flex-1 overflow-hidden"378 className="min-h-0 flex-1 overflow-hidden"
379 style={{379 style={{
380 colorScheme: resolvedTheme,380 colorScheme: resolvedTheme,
381 ["--trees-fg-override" as string]: "var(--foreground)",381 ["--trees-fg-override" as string]: "var(--contrast-foreground)",
382 }}382 }}
383 />383 />
384 )}384 )}
apps/web/src/components/pullRequest/pullRequestPresentation.tsx+1-1
...@@ -225,7 +225,7 @@ const REVIEW_OUTCOME_PRESENTATION = {...@@ -225,7 +225,7 @@ const REVIEW_OUTCOME_PRESENTATION = {
225 toneClassName: "text-muted-foreground/70",225 toneClassName: "text-muted-foreground/70",
226 ringClassName: "ring-2 ring-muted-foreground/60",226 ringClassName: "ring-2 ring-muted-foreground/60",
227 staleRingClassName:227 staleRingClassName:
228 "ring-2 ring-[color-mix(in_srgb,var(--muted-foreground)_30%,var(--background))]",228 "ring-2 ring-[color-mix(in_srgb,var(--contrast-muted-foreground)_30%,var(--background))]",
229 badgeVariant: "outline",229 badgeVariant: "outline",
230 },230 },
231} as const satisfies Record<231} as const satisfies Record<
apps/web/src/components/settings/SettingsPanels.tsx+62
...@@ -20,6 +20,7 @@ import {...@@ -20,6 +20,7 @@ import {
20 DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE,20 DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE,
21 DEFAULT_UNIFIED_SETTINGS,21 DEFAULT_UNIFIED_SETTINGS,
22 type EnvironmentIdentificationMode,22 type EnvironmentIdentificationMode,
23 MAX_APPEARANCE_CONTRAST,
23 MAX_CODE_FONT_SIZE,24 MAX_CODE_FONT_SIZE,
24 MAX_GLASS_OPACITY,25 MAX_GLASS_OPACITY,
25 MAX_INTERFACE_FONT_SIZE,26 MAX_INTERFACE_FONT_SIZE,
...@@ -27,6 +28,7 @@ import {...@@ -27,6 +28,7 @@ import {
27 MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS,28 MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS,
28 MAX_TERMINAL_FONT_SIZE,29 MAX_TERMINAL_FONT_SIZE,
29 MIN_CODE_FONT_SIZE,30 MIN_CODE_FONT_SIZE,
31 MIN_APPEARANCE_CONTRAST,
30 MIN_GLASS_OPACITY,32 MIN_GLASS_OPACITY,
31 MIN_INTERFACE_FONT_SIZE,33 MIN_INTERFACE_FONT_SIZE,
32 MIN_PROMPT_FONT_SIZE,34 MIN_PROMPT_FONT_SIZE,
...@@ -475,6 +477,9 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -475,6 +477,9 @@ export function useSettingsRestore(onRestored?: () => void) {
475 ...(theme !== "system" ? ["Theme"] : []),477 ...(theme !== "system" ? ["Theme"] : []),
476 ...(!followSystem ? ["Follow system"] : []),478 ...(!followSystem ? ["Follow system"] : []),
477 ...(themeHalves !== null ? ["Theme mix"] : []),479 ...(themeHalves !== null ? ["Theme mix"] : []),
480 ...(settings.appearanceContrast !== DEFAULT_UNIFIED_SETTINGS.appearanceContrast
481 ? ["Contrast"]
482 : []),
478 ...(settings.glassOpacity !== DEFAULT_UNIFIED_SETTINGS.glassOpacity ? ["Glass opacity"] : []),483 ...(settings.glassOpacity !== DEFAULT_UNIFIED_SETTINGS.glassOpacity ? ["Glass opacity"] : []),
479 ...(settings.environmentIdentificationMode !==484 ...(settings.environmentIdentificationMode !==
480 DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode485 DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode
...@@ -543,6 +548,7 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -543,6 +548,7 @@ export function useSettingsRestore(onRestored?: () => void) {
543 settings.browserDefaultZoomFactor,548 settings.browserDefaultZoomFactor,
544 settings.browserDefaultAppearance,549 settings.browserDefaultAppearance,
545 settings.browserAutoShowFloatingPreview,550 settings.browserAutoShowFloatingPreview,
551 settings.appearanceContrast,
546 settings.enableAgentBrowserAccess,552 settings.enableAgentBrowserAccess,
547 settings.confirmQuit,553 settings.confirmQuit,
548 settings.confirmThreadArchive,554 settings.confirmThreadArchive,
...@@ -638,6 +644,7 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -638,6 +644,7 @@ export function useSettingsRestore(onRestored?: () => void) {
638 return;644 return;
639 }645 }
640 updateSettings({646 updateSettings({
647 appearanceContrast: DEFAULT_UNIFIED_SETTINGS.appearanceContrast,
641 timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat,648 timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat,
642 wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap,649 wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap,
643 diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace,650 diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace,
...@@ -986,6 +993,13 @@ export function AppearanceSettingsPanel() {...@@ -986,6 +993,13 @@ export function AppearanceSettingsPanel() {
986 "--settings-slider-progress": `${glassOpacityRatio * 100}%`,993 "--settings-slider-progress": `${glassOpacityRatio * 100}%`,
987 "--settings-slider-fill-offset": `${0.5 - glassOpacityRatio}rem`,994 "--settings-slider-fill-offset": `${0.5 - glassOpacityRatio}rem`,
988 } as CSSProperties;995 } as CSSProperties;
996 const appearanceContrastRatio =
997 (settings.appearanceContrast - MIN_APPEARANCE_CONTRAST) /
998 (MAX_APPEARANCE_CONTRAST - MIN_APPEARANCE_CONTRAST);
999 const appearanceContrastSliderStyle = {
1000 "--settings-slider-progress": `${appearanceContrastRatio * 100}%`,
1001 "--settings-slider-fill-offset": `${0.5 - appearanceContrastRatio}rem`,
1002 } as CSSProperties;
9891003
990 return (1004 return (
991 <SettingsPageContainer>1005 <SettingsPageContainer>
...@@ -1006,6 +1020,54 @@ export function AppearanceSettingsPanel() {...@@ -1006,6 +1020,54 @@ export function AppearanceSettingsPanel() {
1006 />1020 />
1007 </div>1021 </div>
10081022
1023 <SettingsRow
1024 {...searchableSetting("setting-appearance-contrast")}
1025 description="Adjust the contrast of colors and borders across the interface."
1026 resetAction={
1027 settings.appearanceContrast !== DEFAULT_UNIFIED_SETTINGS.appearanceContrast ? (
1028 <SettingResetButton
1029 label="contrast"
1030 onClick={() =>
1031 updateSettings({
1032 appearanceContrast: DEFAULT_UNIFIED_SETTINGS.appearanceContrast,
1033 })
1034 }
1035 />
1036 ) : null
1037 }
1038 control={
1039 <div className="flex w-full items-center gap-3 sm:w-52">
1040 <output
1041 className="min-w-12 rounded-md bg-muted px-2 py-1 text-center font-mono text-xs font-medium tabular-nums text-foreground"
1042 htmlFor="appearance-contrast"
1043 >
1044 {settings.appearanceContrast}%
1045 </output>
1046 <input
1047 aria-label="Contrast"
1048 className="settings-slider min-w-0 flex-1"
1049 id="appearance-contrast"
1050 max={MAX_APPEARANCE_CONTRAST}
1051 min={MIN_APPEARANCE_CONTRAST}
1052 onChange={(event) => {
1053 const appearanceContrast = Number(event.currentTarget.value);
1054 if (
1055 Number.isInteger(appearanceContrast) &&
1056 appearanceContrast >= MIN_APPEARANCE_CONTRAST &&
1057 appearanceContrast <= MAX_APPEARANCE_CONTRAST
1058 ) {
1059 updateSettings({ appearanceContrast });
1060 }
1061 }}
1062 step={5}
1063 style={appearanceContrastSliderStyle}
1064 type="range"
1065 value={settings.appearanceContrast}
1066 />
1067 </div>
1068 }
1069 />
1070
1009 <SettingsRow1071 <SettingsRow
1010 {...searchableSetting("setting-glass-opacity")}1072 {...searchableSetting("setting-glass-opacity")}
1011 description="Control how transparent glass surfaces are. Higher values make menus, dialogs, and the composer more solid."1073 description="Control how transparent glass surfaces are. Higher values make menus, dialogs, and the composer more solid."
apps/web/src/components/settings/settingsSearch.ts+6
...@@ -57,6 +57,12 @@ export const SETTINGS_SEARCH_ITEMS = [...@@ -57,6 +57,12 @@ export const SETTINGS_SEARCH_ITEMS = [
57 // stable scroll destination for both.57 // stable scroll destination for both.
58 targetId: "appearance",58 targetId: "appearance",
59 },59 },
60 {
61 // Prefixed because the slider control already owns the `appearance-contrast` id.
62 id: "setting-appearance-contrast",
63 title: "Contrast",
64 to: "/settings/appearance",
65 },
60 {66 {
61 // Prefixed because the slider control already owns the `glass-opacity` id.67 // Prefixed because the slider control already owns the `glass-opacity` id.
62 id: "setting-glass-opacity",68 id: "setting-glass-opacity",
apps/web/src/components/ui/button.test.tsx+1-1
...@@ -13,7 +13,7 @@ describe("button geometry tokens", () => {...@@ -13,7 +13,7 @@ describe("button geometry tokens", () => {
13 );13 );
1414
15 expect(html).toContain("rounded-[var(--control-radius)]");15 expect(html).toContain("rounded-[var(--control-radius)]");
16 expect(html).toContain("[--control-icon-color:var(--muted-foreground)]");16 expect(html).toContain("[--control-icon-color:var(--contrast-muted-foreground)]");
17 expect(html).toContain("text-[var(--control-icon-color)]");17 expect(html).toContain("text-[var(--control-icon-color)]");
18 expect(html).not.toContain("opacity-80");18 expect(html).not.toContain("opacity-80");
19 });19 });
apps/web/src/components/ui/button.tsx+4-4
...@@ -43,14 +43,14 @@ const buttonVariants = cva(...@@ -43,14 +43,14 @@ const buttonVariants = cva(
43 "destructive-outline":43 "destructive-outline":
44 "border-input bg-popover not-dark:bg-clip-padding text-destructive-foreground shadow-xs/5 not-disabled:not-active:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] dark:bg-input/32 dark:not-disabled:before:shadow-[0_-1px_--theme(--color-white/2%)] dark:not-disabled:not-active:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] [:disabled,:active,[data-pressed]]:shadow-none [:hover,[data-pressed]]:border-destructive/32 [:hover,[data-pressed]]:bg-destructive/4",44 "border-input bg-popover not-dark:bg-clip-padding text-destructive-foreground shadow-xs/5 not-disabled:not-active:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] dark:bg-input/32 dark:not-disabled:before:shadow-[0_-1px_--theme(--color-white/2%)] dark:not-disabled:not-active:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] [:disabled,:active,[data-pressed]]:shadow-none [:hover,[data-pressed]]:border-destructive/32 [:hover,[data-pressed]]:bg-destructive/4",
45 ghost:45 ghost:
46 "[--control-icon-color:var(--muted-foreground)] border-transparent text-foreground data-pressed:bg-accent [:hover,[data-pressed]]:bg-accent",46 "[--control-icon-color:var(--contrast-muted-foreground)] border-transparent text-foreground data-pressed:bg-accent [:hover,[data-pressed]]:bg-accent",
47 "ghost-muted":47 "ghost-muted":
48 "[--control-icon-color:var(--muted-foreground)] border-transparent text-muted-foreground data-pressed:bg-accent [:hover,[data-pressed]]:bg-accent [:hover,[data-pressed]]:text-foreground",48 "[--control-icon-color:var(--contrast-muted-foreground)] border-transparent text-muted-foreground data-pressed:bg-accent [:hover,[data-pressed]]:bg-accent [:hover,[data-pressed]]:text-foreground",
49 glass:49 glass:
50 "surface-glass [--control-icon-color:var(--muted-foreground)] border-border/60 text-foreground shadow-sm [:hover,[data-pressed]]:border-border",50 "surface-glass [--control-icon-color:var(--contrast-muted-foreground)] border-border/60 text-foreground shadow-sm [:hover,[data-pressed]]:border-border",
51 link: "border-transparent underline-offset-4 [:hover,[data-pressed]]:underline",51 link: "border-transparent underline-offset-4 [:hover,[data-pressed]]:underline",
52 outline:52 outline:
53 "[--control-icon-color:var(--muted-foreground)] border-input bg-popover not-dark:bg-clip-padding text-foreground shadow-xs/5 not-disabled:not-active:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] dark:bg-input/32 dark:not-disabled:before:shadow-[0_-1px_--theme(--color-white/2%)] dark:not-disabled:not-active:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] [:disabled,:active,[data-pressed]]:shadow-none [:hover,[data-pressed]]:bg-accent/50 dark:[:hover,[data-pressed]]:bg-input/64",53 "[--control-icon-color:var(--contrast-muted-foreground)] border-input bg-popover not-dark:bg-clip-padding text-foreground shadow-xs/5 not-disabled:not-active:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] dark:bg-input/32 dark:not-disabled:before:shadow-[0_-1px_--theme(--color-white/2%)] dark:not-disabled:not-active:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] [:disabled,:active,[data-pressed]]:shadow-none [:hover,[data-pressed]]:bg-accent/50 dark:[:hover,[data-pressed]]:bg-input/64",
54 secondary:54 secondary:
55 "border-transparent bg-secondary text-secondary-foreground [:active,[data-pressed]]:bg-secondary/80 [:hover,[data-pressed]]:bg-secondary/90",55 "border-transparent bg-secondary text-secondary-foreground [:active,[data-pressed]]:bg-secondary/80 [:hover,[data-pressed]]:bg-secondary/90",
56 },56 },
apps/web/src/components/usage/usageProviders.ts+1-1
...@@ -16,7 +16,7 @@ type UsageProviderPresentation = {...@@ -16,7 +16,7 @@ type UsageProviderPresentation = {
16export const PROVIDER_PRESENTATION = {16export const PROVIDER_PRESENTATION = {
17 codex: {17 codex: {
18 label: "Codex",18 label: "Codex",
19 color: "var(--foreground)",19 color: "var(--contrast-foreground)",
20 mark: OpenAI,20 mark: OpenAI,
21 },21 },
22 claude: {22 claude: {
apps/web/src/contextMenuFallback.test.ts+1-1
...@@ -315,7 +315,7 @@ describe("showContextMenuFallback", () => {...@@ -315,7 +315,7 @@ describe("showContextMenuFallback", () => {
315 expect(siblingButton).toBeTruthy();315 expect(siblingButton).toBeTruthy();
316 expect(childButton?.focused).toBe(true);316 expect(childButton?.focused).toBe(true);
317 expect(childButton?.style.background).toBe("var(--accent)");317 expect(childButton?.style.background).toBe("var(--accent)");
318 expect(childButton?.style.color).toBe("var(--accent-foreground)");318 expect(childButton?.style.color).toBe("var(--contrast-accent-foreground)");
319 siblingButton?.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));319 siblingButton?.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
320 expect(childButton?.focused).toBe(false);320 expect(childButton?.focused).toBe(false);
321 expect(childButton?.style.background).toBe("transparent");321 expect(childButton?.style.background).toBe("transparent");
apps/web/src/contextMenuFallback.ts+7-6
...@@ -278,7 +278,7 @@ export function showContextMenuFallback<T extends string>(...@@ -278,7 +278,7 @@ export function showContextMenuFallback<T extends string>(
278 menu.className =278 menu.className =
279 "dropdown-glass fixed z-[10000] min-w-32 max-w-sm overflow-hidden rounded-lg bg-clip-padding text-popover-foreground outline-none";279 "dropdown-glass fixed z-[10000] min-w-32 max-w-sm overflow-hidden rounded-lg bg-clip-padding text-popover-foreground outline-none";
280 menu.style.cssText =280 menu.style.cssText =
281 "position:fixed;z-index:10000;min-width:8rem;max-width:24rem;overflow:hidden;border-radius:var(--radius-lg);background-clip:padding-box;color:var(--popover-foreground);outline:none;pointer-events:auto;";281 "position:fixed;z-index:10000;min-width:8rem;max-width:24rem;overflow:hidden;border-radius:var(--radius-lg);background-clip:padding-box;color:var(--contrast-popover-foreground);outline:none;pointer-events:auto;";
282 menu.style.left = `${preferredLeft}px`;282 menu.style.left = `${preferredLeft}px`;
283 menu.style.top = `${preferredTop}px`;283 menu.style.top = `${preferredTop}px`;
284 menu.dataset.level = String(level);284 menu.dataset.level = String(level);
...@@ -293,7 +293,8 @@ export function showContextMenuFallback<T extends string>(...@@ -293,7 +293,8 @@ export function showContextMenuFallback<T extends string>(
293 if (item.separatorBefore === true && inner.children.length > 0) {293 if (item.separatorBefore === true && inner.children.length > 0) {
294 const separator = document.createElement("div");294 const separator = document.createElement("div");
295 separator.className = "mx-2 my-1 h-px bg-border";295 separator.className = "mx-2 my-1 h-px bg-border";
296 separator.style.cssText = "height:1px;margin:0.25rem 0.5rem;background:var(--border);";296 separator.style.cssText =
297 "height:1px;margin:0.25rem 0.5rem;background:var(--contrast-border);";
297 separator.dataset.contextMenuSeparator = "true";298 separator.dataset.contextMenuSeparator = "true";
298 separator.setAttribute("role", "separator");299 separator.setAttribute("role", "separator");
299 inner.appendChild(separator);300 inner.appendChild(separator);
...@@ -323,12 +324,12 @@ export function showContextMenuFallback<T extends string>(...@@ -323,12 +324,12 @@ export function showContextMenuFallback<T extends string>(
323 ? `${rowBase} text-destructive-foreground hover:bg-destructive/10 hover:text-destructive-foreground`324 ? `${rowBase} text-destructive-foreground hover:bg-destructive/10 hover:text-destructive-foreground`
324 : `${rowBase} text-foreground hover:bg-accent hover:text-accent-foreground`;325 : `${rowBase} text-foreground hover:bg-accent hover:text-accent-foreground`;
325 button.style.cssText =326 button.style.cssText =
326 "display:flex;width:100%;min-height:1.75rem;align-items:center;gap:0.5rem;border:0;border-radius:var(--radius-sm);background:transparent;padding:0.25rem 0.5rem;color:var(--foreground);font-family:var(--font-sans,system-ui,sans-serif);font-size:0.875rem;line-height:1.25rem;text-align:left;cursor:default;";327 "display:flex;width:100%;min-height:1.75rem;align-items:center;gap:0.5rem;border:0;border-radius:var(--radius-sm);background:transparent;padding:0.25rem 0.5rem;color:var(--contrast-foreground);font-family:var(--font-sans,system-ui,sans-serif);font-size:0.875rem;line-height:1.25rem;text-align:left;cursor:default;";
327 if (isLeafDestructive) {328 if (isLeafDestructive) {
328 button.style.color = "var(--destructive-foreground)";329 button.style.color = "var(--destructive-foreground)";
329 }330 }
330 if (isDisabled) {331 if (isDisabled) {
331 button.style.color = "var(--muted-foreground)";332 button.style.color = "var(--contrast-muted-foreground)";
332 button.style.opacity = "0.64";333 button.style.opacity = "0.64";
333 button.style.pointerEvents = "none";334 button.style.pointerEvents = "none";
334 }335 }
...@@ -373,10 +374,10 @@ export function showContextMenuFallback<T extends string>(...@@ -373,10 +374,10 @@ export function showContextMenuFallback<T extends string>(
373 button.style.color = isHighlighted374 button.style.color = isHighlighted
374 ? isLeafDestructive375 ? isLeafDestructive
375 ? "var(--destructive-foreground)"376 ? "var(--destructive-foreground)"
376 : "var(--accent-foreground)"377 : "var(--contrast-accent-foreground)"
377 : isLeafDestructive378 : isLeafDestructive
378 ? "var(--destructive-foreground)"379 ? "var(--destructive-foreground)"
379 : "var(--foreground)";380 : "var(--contrast-foreground)";
380 };381 };
381 button.addEventListener("mouseenter", () => {382 button.addEventListener("mouseenter", () => {
382 button.focus({ preventScroll: true });383 button.focus({ preventScroll: true });
apps/web/src/index.css+190-64
...@@ -78,6 +78,10 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil...@@ -78,6 +78,10 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
7878
79:root {79:root {
80 --app-scrollbar-width: 6px;80 --app-scrollbar-width: 6px;
81 --appearance-contrast-base: 100%;
82 --appearance-contrast-boost: 0%;
83 --appearance-contrast-border-boost: 0%;
84 --appearance-contrast-target: black;
81 --app-scrollbar-thumb: rgb(217 217 217);85 --app-scrollbar-thumb: rgb(217 217 217);
82 --app-scrollbar-thumb-hover: rgb(191 191 191);86 --app-scrollbar-thumb-hover: rgb(191 191 191);
83 /*87 /*
...@@ -87,7 +91,11 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil...@@ -87,7 +91,11 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
87 --control-radius: 0.5rem;91 --control-radius: 0.5rem;
88 --sidebar-content-inset: 0.5rem;92 --sidebar-content-inset: 0.5rem;
89 --sidebar-control-gap: 0.5rem;93 --sidebar-control-gap: 0.5rem;
90 --sidebar-icon-color: color-mix(in srgb, var(--sidebar-muted-foreground) 60%, var(--sidebar));94 --sidebar-icon-color: color-mix(
95 in srgb,
96 var(--contrast-sidebar-muted-foreground) 60%,
97 var(--sidebar)
98 );
91 --sidebar-row-content-inset: 0.625rem;99 --sidebar-row-content-inset: 0.625rem;
92 --command-shell-inset: 0.5rem;100 --command-shell-inset: 0.5rem;
93 --command-content-inset: 1rem;101 --command-content-inset: 1rem;
...@@ -105,6 +113,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil...@@ -105,6 +113,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
105 --workspace-titlebar-control-gap: 0.75rem;113 --workspace-titlebar-control-gap: 0.75rem;
106114
107 @variant dark {115 @variant dark {
116 --appearance-contrast-target: white;
108 --app-scrollbar-thumb: rgb(255 255 255 / 8%);117 --app-scrollbar-thumb: rgb(255 255 255 / 8%);
109 --app-scrollbar-thumb-hover: rgb(255 255 255 / 12%);118 --app-scrollbar-thumb-hover: rgb(255 255 255 / 12%);
110 --glass-blur: 16px;119 --glass-blur: 16px;
...@@ -156,40 +165,40 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil...@@ -156,40 +165,40 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
156 --color-info: var(--info);165 --color-info: var(--info);
157 --color-destructive-foreground: var(--destructive-foreground);166 --color-destructive-foreground: var(--destructive-foreground);
158 --color-ring: var(--ring);167 --color-ring: var(--ring);
159 --color-input: var(--input);168 --color-input: var(--contrast-input);
160 --color-border: var(--border);169 --color-border: var(--contrast-border);
161 --color-destructive: var(--destructive);170 --color-destructive: var(--destructive);
162 --color-accent-foreground: var(--accent-foreground);171 --color-accent-foreground: var(--contrast-accent-foreground);
163 --color-accent: var(--accent);172 --color-accent: var(--accent);
164 --color-muted-foreground: var(--muted-foreground);173 --color-muted-foreground: var(--contrast-muted-foreground);
165 --color-muted: var(--muted);174 --color-muted: var(--muted);
166 --color-placeholder: var(--placeholder);175 --color-placeholder: var(--contrast-placeholder);
167 --color-secondary-label: var(--secondary-label);176 --color-secondary-label: var(--contrast-secondary-label);
168 --color-icon-muted: var(--icon-muted);177 --color-icon-muted: var(--contrast-icon-muted);
169 --color-secondary-foreground: var(--secondary-foreground);178 --color-secondary-foreground: var(--contrast-secondary-foreground);
170 --color-secondary: var(--secondary);179 --color-secondary: var(--secondary);
171 --color-primary-foreground: var(--primary-foreground);180 --color-primary-foreground: var(--primary-foreground);
172 --color-primary: var(--primary);181 --color-primary: var(--primary);
173 --color-popover-foreground: var(--popover-foreground);182 --color-popover-foreground: var(--contrast-popover-foreground);
174 --color-popover: var(--popover);183 --color-popover: var(--popover);
175 --color-card-foreground: var(--card-foreground);184 --color-card-foreground: var(--contrast-card-foreground);
176 --color-card: var(--card);185 --color-card: var(--card);
177 --color-foreground: var(--foreground);186 --color-foreground: var(--contrast-foreground);
178 --color-background: var(--background);187 --color-background: var(--background);
179 --color-surface-raised: var(--surface-raised);188 --color-surface-raised: var(--surface-raised);
180 --color-message: var(--message-surface);189 --color-message: var(--message-surface);
181 --color-message-foreground: var(--message-foreground);190 --color-message-foreground: var(--contrast-message-foreground);
182 --color-message-action: var(--message-action);191 --color-message-action: var(--message-action);
183 --color-message-action-foreground: var(--message-action-foreground);192 --color-message-action-foreground: var(--message-action-foreground);
184 --color-message-action-hover: var(--message-action-hover);193 --color-message-action-hover: var(--message-action-hover);
185 --color-sidebar: var(--sidebar);194 --color-sidebar: var(--sidebar);
186 --color-sidebar-foreground: var(--sidebar-foreground);195 --color-sidebar-foreground: var(--contrast-sidebar-foreground);
187 --color-sidebar-muted-foreground: var(--sidebar-muted-foreground);196 --color-sidebar-muted-foreground: var(--contrast-sidebar-muted-foreground);
188 --color-sidebar-control-surface: var(--sidebar-control-surface);197 --color-sidebar-control-surface: var(--sidebar-control-surface);
189 --color-sidebar-row-hover: var(--sidebar-row-hover);198 --color-sidebar-row-hover: var(--sidebar-row-hover);
190 --color-sidebar-row-active: var(--sidebar-row-active);199 --color-sidebar-row-active: var(--sidebar-row-active);
191 --color-sidebar-row-selected: var(--sidebar-row-selected);200 --color-sidebar-row-selected: var(--sidebar-row-selected);
192 --color-sidebar-border: var(--sidebar-border);201 --color-sidebar-border: var(--contrast-sidebar-border);
193 --radius-sm: calc(var(--radius) - 4px);202 --radius-sm: calc(var(--radius) - 4px);
194 --radius-md: calc(var(--radius) - 2px);203 --radius-md: calc(var(--radius) - 2px);
195 --radius-lg: var(--radius);204 --radius-lg: var(--radius);
...@@ -304,7 +313,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil...@@ -304,7 +313,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
304 background: color-mix(in srgb, var(--background) var(--glass-opacity), transparent);313 background: color-mix(in srgb, var(--background) var(--glass-opacity), transparent);
305 -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));314 -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
306 backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));315 backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
307 border-color: color-mix(in srgb, var(--foreground) 10%, transparent);316 border-color: color-mix(in srgb, var(--contrast-foreground) 10%, transparent);
308 box-shadow: 0 24px 64px -24px rgb(0 0 0 / 65%);317 box-shadow: 0 24px 64px -24px rgb(0 0 0 / 65%);
309318
310 @variant dark {319 @variant dark {
...@@ -337,7 +346,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil...@@ -337,7 +346,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
337 );346 );
338 -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));347 -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
339 backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));348 backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
340 border: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);349 border: 1px solid color-mix(in srgb, var(--contrast-foreground) 10%, transparent);
341350
342 @supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) {351 @supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) {
343 background: var(--popover) !important;352 background: var(--popover) !important;
...@@ -757,7 +766,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil...@@ -757,7 +766,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
757 .chat-composer-shoulder-tab {766 .chat-composer-shoulder-tab {
758 border-color: var(767 border-color: var(
759 --chat-composer-outline,768 --chat-composer-outline,
760 color-mix(in srgb, var(--foreground) 8%, transparent)769 color-mix(in srgb, var(--contrast-foreground) 8%, transparent)
761 );770 );
762 background: color-mix(771 background: color-mix(
763 in srgb,772 in srgb,
...@@ -781,7 +790,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil...@@ -781,7 +790,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
781 --chat-composer-attached-surface: var(--chat-composer-glass-surface, var(--card));790 --chat-composer-attached-surface: var(--chat-composer-glass-surface, var(--card));
782 --chat-composer-attached-outline: var(791 --chat-composer-attached-outline: var(
783 --chat-composer-outline,792 --chat-composer-outline,
784 color-mix(in srgb, var(--foreground) 8%, transparent)793 color-mix(in srgb, var(--contrast-foreground) 8%, transparent)
785 );794 );
786 background: color-mix(795 background: color-mix(
787 in srgb,796 in srgb,
...@@ -817,7 +826,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil...@@ -817,7 +826,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
817 --chat-composer-attached-surface: var(--chat-composer-glass-surface, var(--card));826 --chat-composer-attached-surface: var(--chat-composer-glass-surface, var(--card));
818 --chat-composer-attached-outline: var(827 --chat-composer-attached-outline: var(
819 --chat-composer-outline,828 --chat-composer-outline,
820 color-mix(in srgb, var(--foreground) 8%, transparent)829 color-mix(in srgb, var(--contrast-foreground) 8%, transparent)
821 );830 );
822 --chat-composer-attached-tint: transparent;831 --chat-composer-attached-tint: transparent;
823 position: relative;832 position: relative;
...@@ -1238,17 +1247,17 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil...@@ -1238,17 +1247,17 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
1238 background: linear-gradient(1247 background: linear-gradient(
1239 to right,1248 to right,
1240 var(--primary) 0 var(--settings-slider-fill-position),1249 var(--primary) 0 var(--settings-slider-fill-position),
1241 color-mix(in srgb, var(--muted-foreground) 22%, transparent)1250 color-mix(in srgb, var(--contrast-muted-foreground) 22%, transparent)
1242 var(--settings-slider-fill-position) 100%1251 var(--settings-slider-fill-position) 100%
1243 );1252 );
1244 box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--border) 55%, transparent);1253 box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--contrast-border) 55%, transparent);
1245 }1254 }
12461255
1247 .settings-slider::-moz-range-track {1256 .settings-slider::-moz-range-track {
1248 height: 0.375rem;1257 height: 0.375rem;
1249 border-radius: 9999px;1258 border-radius: 9999px;
1250 background: color-mix(in srgb, var(--muted-foreground) 22%, transparent);1259 background: color-mix(in srgb, var(--contrast-muted-foreground) 22%, transparent);
1251 box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--border) 55%, transparent);1260 box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--contrast-border) 55%, transparent);
1252 }1261 }
12531262
1254 .settings-slider::-moz-range-progress {1263 .settings-slider::-moz-range-progress {
...@@ -1265,7 +1274,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil...@@ -1265,7 +1274,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
1265 border: 2px solid var(--primary);1274 border: 2px solid var(--primary);
1266 border-radius: 9999px;1275 border-radius: 9999px;
1267 background: var(--background);1276 background: var(--background);
1268 box-shadow: 0 1px 2px color-mix(in srgb, var(--foreground) 12%, transparent);1277 box-shadow: 0 1px 2px color-mix(in srgb, var(--contrast-foreground) 12%, transparent);
1269 transition:1278 transition:
1270 transform 120ms ease,1279 transform 120ms ease,
1271 box-shadow 120ms ease;1280 box-shadow 120ms ease;
...@@ -1277,7 +1286,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil...@@ -1277,7 +1286,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
1277 border: 2px solid var(--primary);1286 border: 2px solid var(--primary);
1278 border-radius: 9999px;1287 border-radius: 9999px;
1279 background: var(--background);1288 background: var(--background);
1280 box-shadow: 0 1px 2px color-mix(in srgb, var(--foreground) 12%, transparent);1289 box-shadow: 0 1px 2px color-mix(in srgb, var(--contrast-foreground) 12%, transparent);
1281 transition:1290 transition:
1282 transform 120ms ease,1291 transform 120ms ease,
1283 box-shadow 120ms ease;1292 box-shadow 120ms ease;
...@@ -1285,12 +1294,12 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil...@@ -1285,12 +1294,12 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
12851294
1286 .settings-slider:hover::-webkit-slider-thumb {1295 .settings-slider:hover::-webkit-slider-thumb {
1287 transform: scale(1.08);1296 transform: scale(1.08);
1288 box-shadow: 0 1px 3px color-mix(in srgb, var(--foreground) 20%, transparent);1297 box-shadow: 0 1px 3px color-mix(in srgb, var(--contrast-foreground) 20%, transparent);
1289 }1298 }
12901299
1291 .settings-slider:hover::-moz-range-thumb {1300 .settings-slider:hover::-moz-range-thumb {
1292 transform: scale(1.08);1301 transform: scale(1.08);
1293 box-shadow: 0 1px 3px color-mix(in srgb, var(--foreground) 20%, transparent);1302 box-shadow: 0 1px 3px color-mix(in srgb, var(--contrast-foreground) 20%, transparent);
1294 }1303 }
12951304
1296 .settings-slider:active::-webkit-slider-thumb {1305 .settings-slider:active::-webkit-slider-thumb {
...@@ -1524,9 +1533,9 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil...@@ -1524,9 +1533,9 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
1524 --sidebar-foreground: var(--foreground);1533 --sidebar-foreground: var(--foreground);
1525 --sidebar-muted-foreground: var(--muted-foreground);1534 --sidebar-muted-foreground: var(--muted-foreground);
1526 --sidebar-control-surface: var(--muted);1535 --sidebar-control-surface: var(--muted);
1527 --sidebar-row-hover: color-mix(in srgb, var(--foreground) 8%, transparent);1536 --sidebar-row-hover: color-mix(in srgb, var(--contrast-foreground) 8%, transparent);
1528 --sidebar-row-active: color-mix(in srgb, var(--foreground) 11%, transparent);1537 --sidebar-row-active: color-mix(in srgb, var(--contrast-foreground) 11%, transparent);
1529 --sidebar-row-selected: color-mix(in srgb, var(--foreground) 7%, transparent);1538 --sidebar-row-selected: color-mix(in srgb, var(--contrast-foreground) 7%, transparent);
1530 --sidebar-border: var(--border);1539 --sidebar-border: var(--border);
1531 --sidebar-stage-fade: var(--card);1540 --sidebar-stage-fade: var(--card);
1532 }1541 }
...@@ -1711,11 +1720,11 @@ html[data-theme-token-probe] *::after {...@@ -1711,11 +1720,11 @@ html[data-theme-token-probe] *::after {
1711 max-width: 12rem;1720 max-width: 12rem;
1712 overflow: hidden;1721 overflow: hidden;
1713 padding: 0.2rem 0.45rem;1722 padding: 0.2rem 0.45rem;
1714 border: 1px solid color-mix(in oklab, var(--ring) 48%, var(--border));1723 border: 1px solid color-mix(in oklab, var(--ring) 48%, var(--contrast-border));
1715 border-radius: 0.4rem;1724 border-radius: 0.4rem;
1716 background: var(--popover);1725 background: var(--popover);
1717 box-shadow: 0 4px 16px rgb(0 0 0 / 24%);1726 box-shadow: 0 4px 16px rgb(0 0 0 / 24%);
1718 color: var(--popover-foreground);1727 color: var(--contrast-popover-foreground);
1719 font-size: 0.6875rem;1728 font-size: 0.6875rem;
1720 font-weight: 600;1729 font-weight: 600;
1721 line-height: 1rem;1730 line-height: 1rem;
...@@ -1736,16 +1745,16 @@ html[data-theme-token-probe] *::after {...@@ -1736,16 +1745,16 @@ html[data-theme-token-probe] *::after {
1736 dark-mode whites while preserving the same theme file across clients. */1745 dark-mode whites while preserving the same theme file across clients. */
1737html[data-theme-id] [data-chat-header] {1746html[data-theme-id] [data-chat-header] {
1738 background-color: var(--toolbar-background);1747 background-color: var(--toolbar-background);
1739 color: var(--toolbar-foreground);1748 color: var(--contrast-toolbar-foreground);
1740}1749}
17411750
1742html[data-theme-id] [data-chat-header] [data-slot="button"],1751html[data-theme-id] [data-chat-header] [data-slot="button"],
1743html[data-theme-id] [data-chat-header] [data-slot="menu-trigger"],1752html[data-theme-id] [data-chat-header] [data-slot="menu-trigger"],
1744html[data-theme-id] [data-chat-header] [data-toolbar-control] {1753html[data-theme-id] [data-chat-header] [data-toolbar-control] {
1745 --control-icon-color: var(--toolbar-control-foreground);1754 --control-icon-color: var(--contrast-toolbar-control-foreground);
1746 background-color: var(--toolbar-control);1755 background-color: var(--toolbar-control);
1747 border-color: var(--toolbar-border);1756 border-color: var(--contrast-toolbar-border);
1748 color: var(--toolbar-control-foreground);1757 color: var(--contrast-toolbar-control-foreground);
1749}1758}
17501759
1751/* The panel layout toggles stay ghost: they render both inside the header and1760/* The panel layout toggles stay ghost: they render both inside the header and
...@@ -1757,8 +1766,8 @@ html[data-theme-id] [data-panel-layout-controls] [data-slot="toggle"],...@@ -1757,8 +1766,8 @@ html[data-theme-id] [data-panel-layout-controls] [data-slot="toggle"],
1757html[data-theme-id] [data-panel-layout-controls] [data-slot="tooltip-trigger"],1766html[data-theme-id] [data-panel-layout-controls] [data-slot="tooltip-trigger"],
1758html[data-theme-id] [data-workspace-titlebar-controls] [data-slot="toggle"],1767html[data-theme-id] [data-workspace-titlebar-controls] [data-slot="toggle"],
1759html[data-theme-id] [data-workspace-titlebar-controls] [data-slot="tooltip-trigger"] {1768html[data-theme-id] [data-workspace-titlebar-controls] [data-slot="tooltip-trigger"] {
1760 --control-icon-color: var(--toolbar-foreground);1769 --control-icon-color: var(--contrast-toolbar-foreground);
1761 color: var(--toolbar-foreground);1770 color: var(--contrast-toolbar-foreground);
1762}1771}
17631772
1764html[data-theme-id] [data-chat-header] [data-slot="button"]:hover,1773html[data-theme-id] [data-chat-header] [data-slot="button"]:hover,
...@@ -1771,7 +1780,7 @@ html[data-theme-id] [data-chat-header] [data-toolbar-control][data-pressed] {...@@ -1771,7 +1780,7 @@ html[data-theme-id] [data-chat-header] [data-toolbar-control][data-pressed] {
1771}1780}
17721781
1773html[data-theme-id] [data-chat-header] [data-slot="separator"] {1782html[data-theme-id] [data-chat-header] [data-slot="separator"] {
1774 background-color: var(--toolbar-border);1783 background-color: var(--contrast-toolbar-border);
1775}1784}
17761785
1777/* Chat code blocks join the other code surfaces on the code tokens. The1786/* Chat code blocks join the other code surfaces on the code tokens. The
...@@ -1779,7 +1788,7 @@ html[data-theme-id] [data-chat-header] [data-slot="separator"] {...@@ -1779,7 +1788,7 @@ html[data-theme-id] [data-chat-header] [data-slot="separator"] {
1779 surfaces with the accent, which fights syntax highlighting. */1788 surfaces with the accent, which fights syntax highlighting. */
1780html[data-theme-id] .chat-markdown .chat-markdown-codeblock {1789html[data-theme-id] .chat-markdown .chat-markdown-codeblock {
1781 background-color: var(--code-background);1790 background-color: var(--code-background);
1782 border-color: var(--border);1791 border-color: var(--contrast-border);
1783 color: var(--code-foreground);1792 color: var(--code-foreground);
1784}1793}
17851794
...@@ -1814,7 +1823,7 @@ html[data-theme-id="t3-chat"] {...@@ -1814,7 +1823,7 @@ html[data-theme-id="t3-chat"] {
1814 }1823 }
18151824
1816 & .chat-markdown :not(pre) > code {1825 & .chat-markdown :not(pre) > code {
1817 color: var(--message-foreground);1826 color: var(--contrast-message-foreground);
1818 }1827 }
1819 }1828 }
1820}1829}
...@@ -1844,10 +1853,10 @@ html[data-theme-id] [data-app-sidebar] {...@@ -1844,10 +1853,10 @@ html[data-theme-id] [data-app-sidebar] {
1844 --sidebar-row-selected: var(--app-theme-sidebar-row-selected);1853 --sidebar-row-selected: var(--app-theme-sidebar-row-selected);
1845 --sidebar-border: var(--app-theme-sidebar-border);1854 --sidebar-border: var(--app-theme-sidebar-border);
1846 --sidebar-stage-fade: var(--app-theme-sidebar);1855 --sidebar-stage-fade: var(--app-theme-sidebar);
1847 border-color: color-mix(in srgb, var(--sidebar-foreground) 10%, transparent);1856 border-color: color-mix(in srgb, var(--contrast-sidebar-foreground) 10%, transparent);
18481857
1849 @variant dark {1858 @variant dark {
1850 border-color: color-mix(in srgb, var(--sidebar-foreground) 8%, transparent);1859 border-color: color-mix(in srgb, var(--contrast-sidebar-foreground) 8%, transparent);
1851 }1860 }
1852}1861}
18531862
...@@ -1855,10 +1864,127 @@ html[data-theme-id] [data-app-sidebar] {...@@ -1855,10 +1864,127 @@ html[data-theme-id] [data-app-sidebar] {
1855 keeps that color while hovered. Do not neutralize this branded edge. */1864 keeps that color while hovered. Do not neutralize this branded edge. */
1856html[data-theme-id="t3-chat"] [data-app-sidebar] {1865html[data-theme-id="t3-chat"] [data-app-sidebar] {
1857 @variant dark {1866 @variant dark {
1858 border-color: var(--sidebar-border);1867 border-color: var(--contrast-sidebar-border);
1859 }1868 }
1860}1869}
18611870
1871/* Contrast stays in ordinary custom properties so both Tailwind utilities and
1872 global/imperative chrome styles resolve the same adjusted role. Redeclare on
1873 the sidebar because it owns a local semantic palette. */
1874:root,
1875[data-app-sidebar] {
1876 --contrast-toolbar-foreground: color-mix(
1877 in oklab,
1878 color-mix(
1879 in oklab,
1880 var(--toolbar-foreground) var(--appearance-contrast-base),
1881 var(--toolbar-background)
1882 ),
1883 var(--appearance-contrast-target) var(--appearance-contrast-boost)
1884 );
1885 --contrast-toolbar-border: color-mix(
1886 in srgb,
1887 color-mix(in srgb, var(--toolbar-border) var(--appearance-contrast-base), transparent),
1888 var(--toolbar-foreground) var(--appearance-contrast-border-boost)
1889 );
1890 --contrast-toolbar-control-foreground: color-mix(
1891 in oklab,
1892 color-mix(
1893 in oklab,
1894 var(--toolbar-control-foreground) var(--appearance-contrast-base),
1895 var(--toolbar-control)
1896 ),
1897 var(--appearance-contrast-target) var(--appearance-contrast-boost)
1898 );
1899 --contrast-input: color-mix(
1900 in srgb,
1901 color-mix(in srgb, var(--input) var(--appearance-contrast-base), transparent),
1902 var(--foreground) var(--appearance-contrast-border-boost)
1903 );
1904 --contrast-border: color-mix(
1905 in srgb,
1906 color-mix(in srgb, var(--border) var(--appearance-contrast-base), transparent),
1907 var(--foreground) var(--appearance-contrast-border-boost)
1908 );
1909 --contrast-foreground: color-mix(
1910 in oklab,
1911 color-mix(in oklab, var(--foreground) var(--appearance-contrast-base), var(--background)),
1912 var(--appearance-contrast-target) var(--appearance-contrast-boost)
1913 );
1914 --contrast-muted-foreground: color-mix(
1915 in oklab,
1916 color-mix(in oklab, var(--muted-foreground) var(--appearance-contrast-base), var(--background)),
1917 var(--appearance-contrast-target) var(--appearance-contrast-boost)
1918 );
1919 --contrast-placeholder: color-mix(
1920 in oklab,
1921 color-mix(in oklab, var(--placeholder) var(--appearance-contrast-base), var(--background)),
1922 var(--appearance-contrast-target) var(--appearance-contrast-boost)
1923 );
1924 --contrast-secondary-label: color-mix(
1925 in oklab,
1926 color-mix(in oklab, var(--secondary-label) var(--appearance-contrast-base), var(--background)),
1927 var(--appearance-contrast-target) var(--appearance-contrast-boost)
1928 );
1929 --contrast-icon-muted: color-mix(
1930 in oklab,
1931 color-mix(in oklab, var(--icon-muted) var(--appearance-contrast-base), var(--background)),
1932 var(--appearance-contrast-target) var(--appearance-contrast-boost)
1933 );
1934 --contrast-card-foreground: color-mix(
1935 in oklab,
1936 color-mix(in oklab, var(--card-foreground) var(--appearance-contrast-base), var(--card)),
1937 var(--appearance-contrast-target) var(--appearance-contrast-boost)
1938 );
1939 --contrast-popover-foreground: color-mix(
1940 in oklab,
1941 color-mix(in oklab, var(--popover-foreground) var(--appearance-contrast-base), var(--popover)),
1942 var(--appearance-contrast-target) var(--appearance-contrast-boost)
1943 );
1944 --contrast-accent-foreground: color-mix(
1945 in oklab,
1946 color-mix(in oklab, var(--accent-foreground) var(--appearance-contrast-base), var(--accent)),
1947 var(--appearance-contrast-target) var(--appearance-contrast-boost)
1948 );
1949 --contrast-secondary-foreground: color-mix(
1950 in oklab,
1951 color-mix(
1952 in oklab,
1953 var(--secondary-foreground) var(--appearance-contrast-base),
1954 var(--secondary)
1955 ),
1956 var(--appearance-contrast-target) var(--appearance-contrast-boost)
1957 );
1958 --contrast-message-foreground: color-mix(
1959 in oklab,
1960 color-mix(
1961 in oklab,
1962 var(--message-foreground) var(--appearance-contrast-base),
1963 var(--message-surface)
1964 ),
1965 var(--appearance-contrast-target) var(--appearance-contrast-boost)
1966 );
1967 --contrast-sidebar-foreground: color-mix(
1968 in oklab,
1969 color-mix(in oklab, var(--sidebar-foreground) var(--appearance-contrast-base), var(--sidebar)),
1970 var(--appearance-contrast-target) var(--appearance-contrast-boost)
1971 );
1972 --contrast-sidebar-muted-foreground: color-mix(
1973 in oklab,
1974 color-mix(
1975 in oklab,
1976 var(--sidebar-muted-foreground) var(--appearance-contrast-base),
1977 var(--sidebar)
1978 ),
1979 var(--appearance-contrast-target) var(--appearance-contrast-boost)
1980 );
1981 --contrast-sidebar-border: color-mix(
1982 in srgb,
1983 color-mix(in srgb, var(--sidebar-border) var(--appearance-contrast-base), transparent),
1984 var(--sidebar-foreground) var(--appearance-contrast-border-boost)
1985 );
1986}
1987
1862body {1988body {
1863 /* Reference the theme token (not a literal stack) so the Settings ->1989 /* Reference the theme token (not a literal stack) so the Settings ->
1864 Appearance runtime override of --font-sans reaches all interface text. */1990 Appearance runtime override of --font-sans reaches all interface text. */
...@@ -2004,7 +2130,7 @@ code {...@@ -2004,7 +2130,7 @@ code {
2004 margin: 1.25rem 0 0.5rem;2130 margin: 1.25rem 0 0.5rem;
2005 font-weight: 600;2131 font-weight: 600;
2006 line-height: 1.3;2132 line-height: 1.3;
2007 color: var(--foreground);2133 color: var(--contrast-foreground);
2008}2134}
20092135
2010.chat-markdown h1 {2136.chat-markdown h1 {
...@@ -2026,7 +2152,7 @@ code {...@@ -2026,7 +2152,7 @@ code {
2026}2152}
20272153
2028.chat-markdown h6 {2154.chat-markdown h6 {
2029 color: var(--muted-foreground);2155 color: var(--contrast-muted-foreground);
2030}2156}
20312157
2032.chat-markdown ul {2158.chat-markdown ul {
...@@ -2103,16 +2229,16 @@ code {...@@ -2103,16 +2229,16 @@ code {
2103}2229}
21042230
2105.chat-markdown blockquote {2231.chat-markdown blockquote {
2106 border-left: 2px solid var(--border);2232 border-left: 2px solid var(--contrast-border);
2107 padding-left: 0.8rem;2233 padding-left: 0.8rem;
2108 color: var(--muted-foreground);2234 color: var(--contrast-muted-foreground);
2109}2235}
21102236
2111.chat-markdown section[data-footnotes] {2237.chat-markdown section[data-footnotes] {
2112 margin-top: 1.25rem;2238 margin-top: 1.25rem;
2113 border-top: 1px solid var(--border);2239 border-top: 1px solid var(--contrast-border);
2114 padding-top: 0.75rem;2240 padding-top: 0.75rem;
2115 color: var(--muted-foreground);2241 color: var(--contrast-muted-foreground);
2116 font-size: 0.75rem;2242 font-size: 0.75rem;
2117}2243}
21182244
...@@ -2137,17 +2263,17 @@ code {...@@ -2137,17 +2263,17 @@ code {
2137}2263}
21382264
2139.chat-markdown :not(pre) > code {2265.chat-markdown :not(pre) > code {
2140 border: 1px solid var(--border);2266 border: 1px solid var(--contrast-border);
2141 border-radius: 0.375rem;2267 border-radius: 0.375rem;
2142 background: var(--muted);2268 background: var(--muted);
2143 padding: 0.1rem 0.35rem;2269 padding: 0.1rem 0.35rem;
2144 color: var(--foreground);2270 color: var(--contrast-foreground);
2145 font-size: 0.75rem;2271 font-size: 0.75rem;
2146}2272}
21472273
2148.chat-markdown a.chat-markdown-file-link,2274.chat-markdown a.chat-markdown-file-link,
2149.chat-markdown a.chat-markdown-file-link:hover {2275.chat-markdown a.chat-markdown-file-link:hover {
2150 color: var(--foreground);2276 color: var(--contrast-foreground);
2151 text-decoration: none;2277 text-decoration: none;
2152}2278}
21532279
...@@ -2159,12 +2285,12 @@ code {...@@ -2159,12 +2285,12 @@ code {
2159.chat-markdown pre {2285.chat-markdown pre {
2160 max-width: 100%;2286 max-width: 100%;
2161 overflow-x: auto;2287 overflow-x: auto;
2162 border: 1px solid var(--border);2288 border: 1px solid var(--contrast-border);
2163 border-radius: 0.75rem;2289 border-radius: 0.75rem;
2164 background: var(--muted);2290 background: var(--muted);
2165 padding: 0.8rem 0.9rem;2291 padding: 0.8rem 0.9rem;
2166 scrollbar-width: thin;2292 scrollbar-width: thin;
2167 scrollbar-color: color-mix(in srgb, var(--border) 78%, transparent) transparent;2293 scrollbar-color: color-mix(in srgb, var(--contrast-border) 78%, transparent) transparent;
2168}2294}
21692295
2170.chat-markdown pre code {2296.chat-markdown pre code {
...@@ -2179,21 +2305,21 @@ code {...@@ -2179,21 +2305,21 @@ code {
21792305
2180.chat-markdown pre::-webkit-scrollbar-thumb {2306.chat-markdown pre::-webkit-scrollbar-thumb {
2181 border-radius: 999px;2307 border-radius: 999px;
2182 background: color-mix(in srgb, var(--border) 78%, transparent);2308 background: color-mix(in srgb, var(--contrast-border) 78%, transparent);
2183}2309}
21842310
2185.chat-markdown .chat-markdown-codeblock-header,2311.chat-markdown .chat-markdown-codeblock-header,
2186.chat-markdown .chat-markdown-chrome-action {2312.chat-markdown .chat-markdown-chrome-action {
2187 color: color-mix(in srgb, var(--foreground) 72%, transparent);2313 color: color-mix(in srgb, var(--contrast-foreground) 72%, transparent);
2188}2314}
21892315
2190.chat-markdown .chat-markdown-chrome-action:hover {2316.chat-markdown .chat-markdown-chrome-action:hover {
2191 color: var(--foreground);2317 color: var(--contrast-foreground);
2192}2318}
21932319
2194.chat-markdown .chat-markdown-chrome-action[aria-pressed="true"] {2320.chat-markdown .chat-markdown-chrome-action[aria-pressed="true"] {
2195 color: var(--foreground);2321 color: var(--contrast-foreground);
2196 background: color-mix(in srgb, var(--foreground) 8%, transparent);2322 background: color-mix(in srgb, var(--contrast-foreground) 8%, transparent);
2197}2323}
21982324
2199.chat-markdown .chat-markdown-codeblock pre {2325.chat-markdown .chat-markdown-codeblock pre {
...@@ -2233,14 +2359,14 @@ code {...@@ -2233,14 +2359,14 @@ code {
2233}2359}
22342360
2235.chat-markdown thead th {2361.chat-markdown thead th {
2236 border-bottom: 1px solid color-mix(in srgb, var(--border) 60%, transparent);2362 border-bottom: 1px solid color-mix(in srgb, var(--contrast-border) 60%, transparent);
2237 padding-block: 0.55rem;2363 padding-block: 0.55rem;
2238 font-weight: 600;2364 font-weight: 600;
2239 white-space: nowrap;2365 white-space: nowrap;
2240}2366}
22412367
2242.chat-markdown tbody td {2368.chat-markdown tbody td {
2243 border-bottom: 1px solid color-mix(in srgb, var(--border) 60%, transparent);2369 border-bottom: 1px solid color-mix(in srgb, var(--contrast-border) 60%, transparent);
2244}2370}
22452371
2246/* Collapsed (default): single-line cells truncate so arbitrary chat content2372/* Collapsed (default): single-line cells truncate so arbitrary chat content
apps/web/src/routes/__root.tsx+12
...@@ -30,6 +30,7 @@ import {...@@ -30,6 +30,7 @@ import {
30} from "../components/ui/toast";30} from "../components/ui/toast";
31import { resolveAndPersistPreferredEditor } from "../editorPreferences";31import { resolveAndPersistPreferredEditor } from "../editorPreferences";
32import { applyAppearanceFontVariables } from "~/appearanceFonts";32import { applyAppearanceFontVariables } from "~/appearanceFonts";
33import { applyAppearanceContrast } from "~/appearanceContrast";
33import { useClientSettings } from "../hooks/useSettings";34import { useClientSettings } from "../hooks/useSettings";
34import { PlanAgentSelectionHeal } from "../planAgentSelectionHeal";35import { PlanAgentSelectionHeal } from "../planAgentSelectionHeal";
35import {36import {
...@@ -131,6 +132,7 @@ function RootRouteView() {...@@ -131,6 +132,7 @@ function RootRouteView() {
131 <ToastProvider>132 <ToastProvider>
132 <AnchoredToastProvider>133 <AnchoredToastProvider>
133 <DocumentTitleSync />134 <DocumentTitleSync />
135 <ContrastAppearanceSync />
134 <GlassAppearanceSync />136 <GlassAppearanceSync />
135 <FontAppearanceSync />137 <FontAppearanceSync />
136 {primaryEnvironmentAuthenticated ? <AuthenticatedTracingBootstrap /> : null}138 {primaryEnvironmentAuthenticated ? <AuthenticatedTracingBootstrap /> : null}
...@@ -152,6 +154,16 @@ function RootRouteView() {...@@ -152,6 +154,16 @@ function RootRouteView() {
152 );154 );
153}155}
154156
157function ContrastAppearanceSync() {
158 const appearanceContrast = useClientSettings((settings) => settings.appearanceContrast);
159
160 useEffect(() => {
161 applyAppearanceContrast(document.documentElement, appearanceContrast);
162 }, [appearanceContrast]);
163
164 return null;
165}
166
155function GlassAppearanceSync() {167function GlassAppearanceSync() {
156 const glassOpacity = useClientSettings((settings) => settings.glassOpacity);168 const glassOpacity = useClientSettings((settings) => settings.glassOpacity);
157169
packages/contracts/src/settings.test.ts+16
...@@ -51,6 +51,22 @@ describe("ClientSettings glass opacity", () => {...@@ -51,6 +51,22 @@ describe("ClientSettings glass opacity", () => {
51 });51 });
52});52});
5353
54describe("ClientSettings appearance contrast", () => {
55 it("defaults to the theme's original contrast", () => {
56 expect(decodeClientSettings({}).appearanceContrast).toBe(100);
57 });
58
59 it.each([49, 201, 92.5])("rejects an invalid appearance contrast: %s", (value) => {
60 expect(() => decodeClientSettings({ appearanceContrast: value })).toThrow();
61 expect(() => decodeClientSettingsPatch({ appearanceContrast: value })).toThrow();
62 });
63
64 it.each([50, 100, 150, 200])("accepts an appearance contrast in range: %s", (value) => {
65 expect(decodeClientSettings({ appearanceContrast: value }).appearanceContrast).toBe(value);
66 expect(decodeClientSettingsPatch({ appearanceContrast: value }).appearanceContrast).toBe(value);
67 });
68});
69
54describe("ClientSettings environment identification", () => {70describe("ClientSettings environment identification", () => {
55 it("defaults to artwork and accepts each presentation mode", () => {71 it("defaults to artwork and accepts each presentation mode", () => {
56 expect(decodeClientSettings({}).environmentIdentificationMode).toBe("artwork");72 expect(decodeClientSettings({}).environmentIdentificationMode).toBe("artwork");
packages/contracts/src/settings.ts+12
...@@ -75,6 +75,14 @@ export const GlassOpacity = Schema.Int.check(...@@ -75,6 +75,14 @@ export const GlassOpacity = Schema.Int.check(
75);75);
76export type GlassOpacity = typeof GlassOpacity.Type;76export type GlassOpacity = typeof GlassOpacity.Type;
77export const DEFAULT_GLASS_OPACITY: GlassOpacity = 80;77export const DEFAULT_GLASS_OPACITY: GlassOpacity = 80;
78
79export const MIN_APPEARANCE_CONTRAST = 50;
80export const MAX_APPEARANCE_CONTRAST = 200;
81export const AppearanceContrast = Schema.Int.check(
82 Schema.isBetween({ minimum: MIN_APPEARANCE_CONTRAST, maximum: MAX_APPEARANCE_CONTRAST }),
83);
84export type AppearanceContrast = typeof AppearanceContrast.Type;
85export const DEFAULT_APPEARANCE_CONTRAST: AppearanceContrast = 100;
78/**86/**
79 * Font size preferences, in CSS pixels. The ranges are deliberately narrow:87 * Font size preferences, in CSS pixels. The ranges are deliberately narrow:
80 * the interface size scales every rem-based dimension in the app, so the88 * the interface size scales every rem-based dimension in the app, so the
...@@ -133,6 +141,9 @@ export const DEFAULT_BROWSER_VIEWPORT: PreviewViewportSetting = FILL_PREVIEW_VIE...@@ -133,6 +141,9 @@ export const DEFAULT_BROWSER_VIEWPORT: PreviewViewportSetting = FILL_PREVIEW_VIE
133export const DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW = true;141export const DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW = true;
134142
135export const ClientSettingsSchema = Schema.Struct({143export const ClientSettingsSchema = Schema.Struct({
144 appearanceContrast: AppearanceContrast.pipe(
145 Schema.withDecodingDefault(Effect.succeed(DEFAULT_APPEARANCE_CONTRAST)),
146 ),
136 browserDefaultViewport: PreviewViewportSetting.pipe(147 browserDefaultViewport: PreviewViewportSetting.pipe(
137 Schema.withDecodingDefault(Effect.succeed(DEFAULT_BROWSER_VIEWPORT)),148 Schema.withDecodingDefault(Effect.succeed(DEFAULT_BROWSER_VIEWPORT)),
138 ),149 ),
...@@ -860,6 +871,7 @@ export const ServerSettingsPatch = Schema.Struct({...@@ -860,6 +871,7 @@ export const ServerSettingsPatch = Schema.Struct({
860export type ServerSettingsPatch = typeof ServerSettingsPatch.Type;871export type ServerSettingsPatch = typeof ServerSettingsPatch.Type;
861872
862export const ClientSettingsPatch = Schema.Struct({873export const ClientSettingsPatch = Schema.Struct({
874 appearanceContrast: Schema.optionalKey(AppearanceContrast),
863 browserDefaultViewport: Schema.optionalKey(PreviewViewportSetting),875 browserDefaultViewport: Schema.optionalKey(PreviewViewportSetting),
864 browserDefaultZoomFactor: Schema.optionalKey(PreviewZoomFactor),876 browserDefaultZoomFactor: Schema.optionalKey(PreviewZoomFactor),
865 browserDefaultAppearance: Schema.optionalKey(PreviewAppearancePreference),877 browserDefaultAppearance: Schema.optionalKey(PreviewAppearancePreference),