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
4747 - light-only declarations use `@variant light`;
4848 - raw `.dark` should remain only in the `dark` and `light` custom-variant definitions.
4949- 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.
5053- Inspect emitted production CSS after unusual variants, arbitrary selectors, nested pseudo-elements, or attribute matching. Source syntax that looks valid is insufficient.
5154- 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.
5255- 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";
1313import * as DesktopClientSettings from "./DesktopClientSettings.ts";
1414
1515const clientSettings: ClientSettings = {
16 appearanceContrast: 100,
1617 browserDefaultViewport: { _tag: "preset", width: 1024, height: 600, presetId: "nest-hub" },
1718 browserDefaultZoomFactor: 1.25,
1819 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 {
1010 colorScheme: root.classList.contains("dark") ? "dark" : "light",
1111 radius: readVariable(styles, "--radius", "0.625rem"),
1212 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)"),
1414 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)"),
1616 primary: readVariable(styles, "--primary", "oklch(0.488 0.217 264)"),
1717 primaryForeground: readVariable(styles, "--primary-foreground", "white"),
1818 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)"),
2020 accent: readVariable(styles, "--accent", "rgb(0 0 0 / 4%)"),
21 accentForeground: readVariable(styles, "--accent-foreground", "oklch(0.269 0 0)"),
22 border: readVariable(styles, "--border", "rgb(0 0 0 / 8%)"),
23 input: readVariable(styles, "--input", "rgb(0 0 0 / 10%)"),
21 accentForeground: readVariable(styles, "--contrast-accent-foreground", "oklch(0.269 0 0)"),
22 border: readVariable(styles, "--contrast-border", "rgb(0 0 0 / 8%)"),
23 input: readVariable(styles, "--contrast-input", "rgb(0 0 0 / 10%)"),
2424 ring: readVariable(styles, "--ring", "oklch(0.488 0.217 264)"),
2525 fontSans: readVariable(styles, "--font-sans", styles.fontFamily || "system-ui, sans-serif"),
2626 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({
14121412 >
14131413 {/* The full path: the chip already shows the shortened form, and a link
14141414 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">
14161416 {targetPath}
14171417 </div>
14181418 </TooltipPopup>
apps/web/src/components/chat/ChangedFilesTree.tsx+1-1
......@@ -66,7 +66,7 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: {
6666 className={cn(
6767 "flex items-center justify-between gap-2 rounded-xl",
6868 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))]",
7070 )}
7171 >
7272 <button
apps/web/src/components/chat/ModelListRow.tsx+1-1
......@@ -52,7 +52,7 @@ export const ModelListRow = memo(function ModelListRow(props: {
5252 contentClassName="flex w-full items-center gap-3"
5353 className={cn(
5454 "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))]",
5656 props.disabledReason &&
5757 "data-disabled:pointer-events-auto data-disabled:cursor-not-allowed data-disabled:hover:bg-transparent",
5858 )}
apps/web/src/components/chat/ModelPickerSidebar.tsx+2-2
......@@ -107,7 +107,7 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: {
107107 render={
108108 <button
109109 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",
111111 )}
112112 onClick={() => handleSelect("favorites")}
113113 type="button"
......@@ -152,7 +152,7 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: {
152152 const button = (
153153 <button
154154 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",
156156 isDisabled && "opacity-50 cursor-not-allowed hover:bg-transparent",
157157 )}
158158 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: {
166166 showBadge={showInstanceBadge}
167167 className="size-4"
168168 iconClassName={cn("size-4", props.activeProviderIconClassName)}
169 indicatorBackground="var(--input)"
169 indicatorBackground="var(--contrast-input)"
170170 badgeClassName={cn(
171171 "right-[-0.125rem] bottom-[-0.125rem] h-3 min-w-3",
172172 "px-0.5 text-[7px]",
apps/web/src/components/clerk/clerkAppearance.test.ts+5-5
......@@ -59,12 +59,12 @@ describe("clerkAppearance", () => {
5959 colorDanger: "var(--error)",
6060 colorSuccess: "var(--success)",
6161 colorWarning: "var(--warning)",
62 colorNeutral: "var(--foreground)",
63 colorForeground: "var(--foreground)",
64 colorMuted: "color-mix(in srgb, var(--card) 98%, var(--foreground))",
65 colorMutedForeground: "var(--muted-foreground)",
62 colorNeutral: "var(--contrast-foreground)",
63 colorForeground: "var(--contrast-foreground)",
64 colorMuted: "color-mix(in srgb, var(--card) 98%, var(--contrast-foreground))",
65 colorMutedForeground: "var(--contrast-muted-foreground)",
6666 colorBackground: "var(--card)",
67 colorInputForeground: "var(--foreground)",
67 colorInputForeground: "var(--contrast-foreground)",
6868 colorInput: "var(--secondary)",
6969 colorRing: "var(--ring)",
7070 },
apps/web/src/components/clerk/clerkAppearance.ts+5-5
......@@ -13,14 +13,14 @@ export const clerkAppearance = {
1313 colorDanger: "var(--error)",
1414 colorSuccess: "var(--success)",
1515 colorWarning: "var(--warning)",
16 colorNeutral: "var(--foreground)",
17 colorForeground: "var(--foreground)",
16 colorNeutral: "var(--contrast-foreground)",
17 colorForeground: "var(--contrast-foreground)",
1818 // The stock dark theme's muted token is translucent. Clerk uses this as
1919 // the footer's background, so derive an opaque muted surface from the card.
20 colorMuted: "color-mix(in srgb, var(--card) 98%, var(--foreground))",
21 colorMutedForeground: "var(--muted-foreground)",
20 colorMuted: "color-mix(in srgb, var(--card) 98%, var(--contrast-foreground))",
21 colorMutedForeground: "var(--contrast-muted-foreground)",
2222 colorBackground: "var(--card)",
23 colorInputForeground: "var(--foreground)",
23 colorInputForeground: "var(--contrast-foreground)",
2424 colorInput: "var(--secondary)",
2525 colorRing: "var(--ring)",
2626 },
apps/web/src/components/color-selector.tsx+1-1
......@@ -13,7 +13,7 @@ interface ColorSelectorProps {
1313}
1414
1515const colorMap = {
16 default: "var(--foreground)",
16 default: "var(--contrast-foreground)",
1717 red: "var(--color-red-500)",
1818 green: "var(--color-green-500)",
1919 blue: "var(--color-blue-500)",
apps/web/src/components/files/FileBrowserPanel.tsx+1-1
......@@ -378,7 +378,7 @@ export default function FileBrowserPanel({
378378 className="min-h-0 flex-1 overflow-hidden"
379379 style={{
380380 colorScheme: resolvedTheme,
381 ["--trees-fg-override" as string]: "var(--foreground)",
381 ["--trees-fg-override" as string]: "var(--contrast-foreground)",
382382 }}
383383 />
384384 )}
apps/web/src/components/pullRequest/pullRequestPresentation.tsx+1-1
......@@ -225,7 +225,7 @@ const REVIEW_OUTCOME_PRESENTATION = {
225225 toneClassName: "text-muted-foreground/70",
226226 ringClassName: "ring-2 ring-muted-foreground/60",
227227 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))]",
229229 badgeVariant: "outline",
230230 },
231231} as const satisfies Record<
apps/web/src/components/settings/SettingsPanels.tsx+62
......@@ -20,6 +20,7 @@ import {
2020 DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE,
2121 DEFAULT_UNIFIED_SETTINGS,
2222 type EnvironmentIdentificationMode,
23 MAX_APPEARANCE_CONTRAST,
2324 MAX_CODE_FONT_SIZE,
2425 MAX_GLASS_OPACITY,
2526 MAX_INTERFACE_FONT_SIZE,
......@@ -27,6 +28,7 @@ import {
2728 MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS,
2829 MAX_TERMINAL_FONT_SIZE,
2930 MIN_CODE_FONT_SIZE,
31 MIN_APPEARANCE_CONTRAST,
3032 MIN_GLASS_OPACITY,
3133 MIN_INTERFACE_FONT_SIZE,
3234 MIN_PROMPT_FONT_SIZE,
......@@ -475,6 +477,9 @@ export function useSettingsRestore(onRestored?: () => void) {
475477 ...(theme !== "system" ? ["Theme"] : []),
476478 ...(!followSystem ? ["Follow system"] : []),
477479 ...(themeHalves !== null ? ["Theme mix"] : []),
480 ...(settings.appearanceContrast !== DEFAULT_UNIFIED_SETTINGS.appearanceContrast
481 ? ["Contrast"]
482 : []),
478483 ...(settings.glassOpacity !== DEFAULT_UNIFIED_SETTINGS.glassOpacity ? ["Glass opacity"] : []),
479484 ...(settings.environmentIdentificationMode !==
480485 DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode
......@@ -543,6 +548,7 @@ export function useSettingsRestore(onRestored?: () => void) {
543548 settings.browserDefaultZoomFactor,
544549 settings.browserDefaultAppearance,
545550 settings.browserAutoShowFloatingPreview,
551 settings.appearanceContrast,
546552 settings.enableAgentBrowserAccess,
547553 settings.confirmQuit,
548554 settings.confirmThreadArchive,
......@@ -638,6 +644,7 @@ export function useSettingsRestore(onRestored?: () => void) {
638644 return;
639645 }
640646 updateSettings({
647 appearanceContrast: DEFAULT_UNIFIED_SETTINGS.appearanceContrast,
641648 timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat,
642649 wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap,
643650 diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace,
......@@ -986,6 +993,13 @@ export function AppearanceSettingsPanel() {
986993 "--settings-slider-progress": `${glassOpacityRatio * 100}%`,
987994 "--settings-slider-fill-offset": `${0.5 - glassOpacityRatio}rem`,
988995 } 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
9901004 return (
9911005 <SettingsPageContainer>
......@@ -1006,6 +1020,54 @@ export function AppearanceSettingsPanel() {
10061020 />
10071021 </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
10091071 <SettingsRow
10101072 {...searchableSetting("setting-glass-opacity")}
10111073 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 = [
5757 // stable scroll destination for both.
5858 targetId: "appearance",
5959 },
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 },
6066 {
6167 // Prefixed because the slider control already owns the `glass-opacity` id.
6268 id: "setting-glass-opacity",
apps/web/src/components/ui/button.test.tsx+1-1
......@@ -13,7 +13,7 @@ describe("button geometry tokens", () => {
1313 );
1414
1515 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)]");
1717 expect(html).toContain("text-[var(--control-icon-color)]");
1818 expect(html).not.toContain("opacity-80");
1919 });
apps/web/src/components/ui/button.tsx+4-4
......@@ -43,14 +43,14 @@ const buttonVariants = cva(
4343 "destructive-outline":
4444 "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",
4545 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",
4747 "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",
4949 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",
5151 link: "border-transparent underline-offset-4 [:hover,[data-pressed]]:underline",
5252 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",
5454 secondary:
5555 "border-transparent bg-secondary text-secondary-foreground [:active,[data-pressed]]:bg-secondary/80 [:hover,[data-pressed]]:bg-secondary/90",
5656 },
apps/web/src/components/usage/usageProviders.ts+1-1
......@@ -16,7 +16,7 @@ type UsageProviderPresentation = {
1616export const PROVIDER_PRESENTATION = {
1717 codex: {
1818 label: "Codex",
19 color: "var(--foreground)",
19 color: "var(--contrast-foreground)",
2020 mark: OpenAI,
2121 },
2222 claude: {
apps/web/src/contextMenuFallback.test.ts+1-1
......@@ -315,7 +315,7 @@ describe("showContextMenuFallback", () => {
315315 expect(siblingButton).toBeTruthy();
316316 expect(childButton?.focused).toBe(true);
317317 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)");
319319 siblingButton?.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
320320 expect(childButton?.focused).toBe(false);
321321 expect(childButton?.style.background).toBe("transparent");
apps/web/src/contextMenuFallback.ts+7-6
......@@ -278,7 +278,7 @@ export function showContextMenuFallback<T extends string>(
278278 menu.className =
279279 "dropdown-glass fixed z-[10000] min-w-32 max-w-sm overflow-hidden rounded-lg bg-clip-padding text-popover-foreground outline-none";
280280 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;";
282282 menu.style.left = `${preferredLeft}px`;
283283 menu.style.top = `${preferredTop}px`;
284284 menu.dataset.level = String(level);
......@@ -293,7 +293,8 @@ export function showContextMenuFallback<T extends string>(
293293 if (item.separatorBefore === true && inner.children.length > 0) {
294294 const separator = document.createElement("div");
295295 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);";
297298 separator.dataset.contextMenuSeparator = "true";
298299 separator.setAttribute("role", "separator");
299300 inner.appendChild(separator);
......@@ -323,12 +324,12 @@ export function showContextMenuFallback<T extends string>(
323324 ? `${rowBase} text-destructive-foreground hover:bg-destructive/10 hover:text-destructive-foreground`
324325 : `${rowBase} text-foreground hover:bg-accent hover:text-accent-foreground`;
325326 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;";
327328 if (isLeafDestructive) {
328329 button.style.color = "var(--destructive-foreground)";
329330 }
330331 if (isDisabled) {
331 button.style.color = "var(--muted-foreground)";
332 button.style.color = "var(--contrast-muted-foreground)";
332333 button.style.opacity = "0.64";
333334 button.style.pointerEvents = "none";
334335 }
......@@ -373,10 +374,10 @@ export function showContextMenuFallback<T extends string>(
373374 button.style.color = isHighlighted
374375 ? isLeafDestructive
375376 ? "var(--destructive-foreground)"
376 : "var(--accent-foreground)"
377 : "var(--contrast-accent-foreground)"
377378 : isLeafDestructive
378379 ? "var(--destructive-foreground)"
379 : "var(--foreground)";
380 : "var(--contrast-foreground)";
380381 };
381382 button.addEventListener("mouseenter", () => {
382383 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
7878
7979:root {
8080 --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;
8185 --app-scrollbar-thumb: rgb(217 217 217);
8286 --app-scrollbar-thumb-hover: rgb(191 191 191);
8387 /*
......@@ -87,7 +91,11 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
8791 --control-radius: 0.5rem;
8892 --sidebar-content-inset: 0.5rem;
8993 --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 );
9199 --sidebar-row-content-inset: 0.625rem;
92100 --command-shell-inset: 0.5rem;
93101 --command-content-inset: 1rem;
......@@ -105,6 +113,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
105113 --workspace-titlebar-control-gap: 0.75rem;
106114
107115 @variant dark {
116 --appearance-contrast-target: white;
108117 --app-scrollbar-thumb: rgb(255 255 255 / 8%);
109118 --app-scrollbar-thumb-hover: rgb(255 255 255 / 12%);
110119 --glass-blur: 16px;
......@@ -156,40 +165,40 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
156165 --color-info: var(--info);
157166 --color-destructive-foreground: var(--destructive-foreground);
158167 --color-ring: var(--ring);
159 --color-input: var(--input);
160 --color-border: var(--border);
168 --color-input: var(--contrast-input);
169 --color-border: var(--contrast-border);
161170 --color-destructive: var(--destructive);
162 --color-accent-foreground: var(--accent-foreground);
171 --color-accent-foreground: var(--contrast-accent-foreground);
163172 --color-accent: var(--accent);
164 --color-muted-foreground: var(--muted-foreground);
173 --color-muted-foreground: var(--contrast-muted-foreground);
165174 --color-muted: var(--muted);
166 --color-placeholder: var(--placeholder);
167 --color-secondary-label: var(--secondary-label);
168 --color-icon-muted: var(--icon-muted);
169 --color-secondary-foreground: var(--secondary-foreground);
175 --color-placeholder: var(--contrast-placeholder);
176 --color-secondary-label: var(--contrast-secondary-label);
177 --color-icon-muted: var(--contrast-icon-muted);
178 --color-secondary-foreground: var(--contrast-secondary-foreground);
170179 --color-secondary: var(--secondary);
171180 --color-primary-foreground: var(--primary-foreground);
172181 --color-primary: var(--primary);
173 --color-popover-foreground: var(--popover-foreground);
182 --color-popover-foreground: var(--contrast-popover-foreground);
174183 --color-popover: var(--popover);
175 --color-card-foreground: var(--card-foreground);
184 --color-card-foreground: var(--contrast-card-foreground);
176185 --color-card: var(--card);
177 --color-foreground: var(--foreground);
186 --color-foreground: var(--contrast-foreground);
178187 --color-background: var(--background);
179188 --color-surface-raised: var(--surface-raised);
180189 --color-message: var(--message-surface);
181 --color-message-foreground: var(--message-foreground);
190 --color-message-foreground: var(--contrast-message-foreground);
182191 --color-message-action: var(--message-action);
183192 --color-message-action-foreground: var(--message-action-foreground);
184193 --color-message-action-hover: var(--message-action-hover);
185194 --color-sidebar: var(--sidebar);
186 --color-sidebar-foreground: var(--sidebar-foreground);
187 --color-sidebar-muted-foreground: var(--sidebar-muted-foreground);
195 --color-sidebar-foreground: var(--contrast-sidebar-foreground);
196 --color-sidebar-muted-foreground: var(--contrast-sidebar-muted-foreground);
188197 --color-sidebar-control-surface: var(--sidebar-control-surface);
189198 --color-sidebar-row-hover: var(--sidebar-row-hover);
190199 --color-sidebar-row-active: var(--sidebar-row-active);
191200 --color-sidebar-row-selected: var(--sidebar-row-selected);
192 --color-sidebar-border: var(--sidebar-border);
201 --color-sidebar-border: var(--contrast-sidebar-border);
193202 --radius-sm: calc(var(--radius) - 4px);
194203 --radius-md: calc(var(--radius) - 2px);
195204 --radius-lg: var(--radius);
......@@ -304,7 +313,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
304313 background: color-mix(in srgb, var(--background) var(--glass-opacity), transparent);
305314 -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
306315 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);
308317 box-shadow: 0 24px 64px -24px rgb(0 0 0 / 65%);
309318
310319 @variant dark {
......@@ -337,7 +346,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
337346 );
338347 -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
339348 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
342351 @supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) {
343352 background: var(--popover) !important;
......@@ -757,7 +766,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
757766 .chat-composer-shoulder-tab {
758767 border-color: var(
759768 --chat-composer-outline,
760 color-mix(in srgb, var(--foreground) 8%, transparent)
769 color-mix(in srgb, var(--contrast-foreground) 8%, transparent)
761770 );
762771 background: color-mix(
763772 in srgb,
......@@ -781,7 +790,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
781790 --chat-composer-attached-surface: var(--chat-composer-glass-surface, var(--card));
782791 --chat-composer-attached-outline: var(
783792 --chat-composer-outline,
784 color-mix(in srgb, var(--foreground) 8%, transparent)
793 color-mix(in srgb, var(--contrast-foreground) 8%, transparent)
785794 );
786795 background: color-mix(
787796 in srgb,
......@@ -817,7 +826,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
817826 --chat-composer-attached-surface: var(--chat-composer-glass-surface, var(--card));
818827 --chat-composer-attached-outline: var(
819828 --chat-composer-outline,
820 color-mix(in srgb, var(--foreground) 8%, transparent)
829 color-mix(in srgb, var(--contrast-foreground) 8%, transparent)
821830 );
822831 --chat-composer-attached-tint: transparent;
823832 position: relative;
......@@ -1238,17 +1247,17 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
12381247 background: linear-gradient(
12391248 to right,
12401249 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)
12421251 var(--settings-slider-fill-position) 100%
12431252 );
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);
12451254 }
12461255
12471256 .settings-slider::-moz-range-track {
12481257 height: 0.375rem;
12491258 border-radius: 9999px;
1250 background: color-mix(in srgb, var(--muted-foreground) 22%, transparent);
1251 box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--border) 55%, transparent);
1259 background: color-mix(in srgb, var(--contrast-muted-foreground) 22%, transparent);
1260 box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--contrast-border) 55%, transparent);
12521261 }
12531262
12541263 .settings-slider::-moz-range-progress {
......@@ -1265,7 +1274,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
12651274 border: 2px solid var(--primary);
12661275 border-radius: 9999px;
12671276 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);
12691278 transition:
12701279 transform 120ms ease,
12711280 box-shadow 120ms ease;
......@@ -1277,7 +1286,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
12771286 border: 2px solid var(--primary);
12781287 border-radius: 9999px;
12791288 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);
12811290 transition:
12821291 transform 120ms ease,
12831292 box-shadow 120ms ease;
......@@ -1285,12 +1294,12 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
12851294
12861295 .settings-slider:hover::-webkit-slider-thumb {
12871296 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);
12891298 }
12901299
12911300 .settings-slider:hover::-moz-range-thumb {
12921301 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);
12941303 }
12951304
12961305 .settings-slider:active::-webkit-slider-thumb {
......@@ -1524,9 +1533,9 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
15241533 --sidebar-foreground: var(--foreground);
15251534 --sidebar-muted-foreground: var(--muted-foreground);
15261535 --sidebar-control-surface: var(--muted);
1527 --sidebar-row-hover: color-mix(in srgb, var(--foreground) 8%, transparent);
1528 --sidebar-row-active: color-mix(in srgb, var(--foreground) 11%, transparent);
1529 --sidebar-row-selected: color-mix(in srgb, var(--foreground) 7%, transparent);
1536 --sidebar-row-hover: color-mix(in srgb, var(--contrast-foreground) 8%, transparent);
1537 --sidebar-row-active: color-mix(in srgb, var(--contrast-foreground) 11%, transparent);
1538 --sidebar-row-selected: color-mix(in srgb, var(--contrast-foreground) 7%, transparent);
15301539 --sidebar-border: var(--border);
15311540 --sidebar-stage-fade: var(--card);
15321541 }
......@@ -1711,11 +1720,11 @@ html[data-theme-token-probe] *::after {
17111720 max-width: 12rem;
17121721 overflow: hidden;
17131722 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));
17151724 border-radius: 0.4rem;
17161725 background: var(--popover);
17171726 box-shadow: 0 4px 16px rgb(0 0 0 / 24%);
1718 color: var(--popover-foreground);
1727 color: var(--contrast-popover-foreground);
17191728 font-size: 0.6875rem;
17201729 font-weight: 600;
17211730 line-height: 1rem;
......@@ -1736,16 +1745,16 @@ html[data-theme-token-probe] *::after {
17361745 dark-mode whites while preserving the same theme file across clients. */
17371746html[data-theme-id] [data-chat-header] {
17381747 background-color: var(--toolbar-background);
1739 color: var(--toolbar-foreground);
1748 color: var(--contrast-toolbar-foreground);
17401749}
17411750
17421751html[data-theme-id] [data-chat-header] [data-slot="button"],
17431752html[data-theme-id] [data-chat-header] [data-slot="menu-trigger"],
17441753html[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);
17461755 background-color: var(--toolbar-control);
1747 border-color: var(--toolbar-border);
1748 color: var(--toolbar-control-foreground);
1756 border-color: var(--contrast-toolbar-border);
1757 color: var(--contrast-toolbar-control-foreground);
17491758}
17501759
17511760/* 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"],
17571766html[data-theme-id] [data-panel-layout-controls] [data-slot="tooltip-trigger"],
17581767html[data-theme-id] [data-workspace-titlebar-controls] [data-slot="toggle"],
17591768html[data-theme-id] [data-workspace-titlebar-controls] [data-slot="tooltip-trigger"] {
1760 --control-icon-color: var(--toolbar-foreground);
1761 color: var(--toolbar-foreground);
1769 --control-icon-color: var(--contrast-toolbar-foreground);
1770 color: var(--contrast-toolbar-foreground);
17621771}
17631772
17641773html[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] {
17711780}
17721781
17731782html[data-theme-id] [data-chat-header] [data-slot="separator"] {
1774 background-color: var(--toolbar-border);
1783 background-color: var(--contrast-toolbar-border);
17751784}
17761785
17771786/* 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"] {
17791788 surfaces with the accent, which fights syntax highlighting. */
17801789html[data-theme-id] .chat-markdown .chat-markdown-codeblock {
17811790 background-color: var(--code-background);
1782 border-color: var(--border);
1791 border-color: var(--contrast-border);
17831792 color: var(--code-foreground);
17841793}
17851794
......@@ -1814,7 +1823,7 @@ html[data-theme-id="t3-chat"] {
18141823 }
18151824
18161825 & .chat-markdown :not(pre) > code {
1817 color: var(--message-foreground);
1826 color: var(--contrast-message-foreground);
18181827 }
18191828 }
18201829}
......@@ -1844,10 +1853,10 @@ html[data-theme-id] [data-app-sidebar] {
18441853 --sidebar-row-selected: var(--app-theme-sidebar-row-selected);
18451854 --sidebar-border: var(--app-theme-sidebar-border);
18461855 --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
18491858 @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);
18511860 }
18521861}
18531862
......@@ -1855,10 +1864,127 @@ html[data-theme-id] [data-app-sidebar] {
18551864 keeps that color while hovered. Do not neutralize this branded edge. */
18561865html[data-theme-id="t3-chat"] [data-app-sidebar] {
18571866 @variant dark {
1858 border-color: var(--sidebar-border);
1867 border-color: var(--contrast-sidebar-border);
18591868 }
18601869}
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
18621988body {
18631989 /* Reference the theme token (not a literal stack) so the Settings ->
18641990 Appearance runtime override of --font-sans reaches all interface text. */
......@@ -2004,7 +2130,7 @@ code {
20042130 margin: 1.25rem 0 0.5rem;
20052131 font-weight: 600;
20062132 line-height: 1.3;
2007 color: var(--foreground);
2133 color: var(--contrast-foreground);
20082134}
20092135
20102136.chat-markdown h1 {
......@@ -2026,7 +2152,7 @@ code {
20262152}
20272153
20282154.chat-markdown h6 {
2029 color: var(--muted-foreground);
2155 color: var(--contrast-muted-foreground);
20302156}
20312157
20322158.chat-markdown ul {
......@@ -2103,16 +2229,16 @@ code {
21032229}
21042230
21052231.chat-markdown blockquote {
2106 border-left: 2px solid var(--border);
2232 border-left: 2px solid var(--contrast-border);
21072233 padding-left: 0.8rem;
2108 color: var(--muted-foreground);
2234 color: var(--contrast-muted-foreground);
21092235}
21102236
21112237.chat-markdown section[data-footnotes] {
21122238 margin-top: 1.25rem;
2113 border-top: 1px solid var(--border);
2239 border-top: 1px solid var(--contrast-border);
21142240 padding-top: 0.75rem;
2115 color: var(--muted-foreground);
2241 color: var(--contrast-muted-foreground);
21162242 font-size: 0.75rem;
21172243}
21182244
......@@ -2137,17 +2263,17 @@ code {
21372263}
21382264
21392265.chat-markdown :not(pre) > code {
2140 border: 1px solid var(--border);
2266 border: 1px solid var(--contrast-border);
21412267 border-radius: 0.375rem;
21422268 background: var(--muted);
21432269 padding: 0.1rem 0.35rem;
2144 color: var(--foreground);
2270 color: var(--contrast-foreground);
21452271 font-size: 0.75rem;
21462272}
21472273
21482274.chat-markdown a.chat-markdown-file-link,
21492275.chat-markdown a.chat-markdown-file-link:hover {
2150 color: var(--foreground);
2276 color: var(--contrast-foreground);
21512277 text-decoration: none;
21522278}
21532279
......@@ -2159,12 +2285,12 @@ code {
21592285.chat-markdown pre {
21602286 max-width: 100%;
21612287 overflow-x: auto;
2162 border: 1px solid var(--border);
2288 border: 1px solid var(--contrast-border);
21632289 border-radius: 0.75rem;
21642290 background: var(--muted);
21652291 padding: 0.8rem 0.9rem;
21662292 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;
21682294}
21692295
21702296.chat-markdown pre code {
......@@ -2179,21 +2305,21 @@ code {
21792305
21802306.chat-markdown pre::-webkit-scrollbar-thumb {
21812307 border-radius: 999px;
2182 background: color-mix(in srgb, var(--border) 78%, transparent);
2308 background: color-mix(in srgb, var(--contrast-border) 78%, transparent);
21832309}
21842310
21852311.chat-markdown .chat-markdown-codeblock-header,
21862312.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);
21882314}
21892315
21902316.chat-markdown .chat-markdown-chrome-action:hover {
2191 color: var(--foreground);
2317 color: var(--contrast-foreground);
21922318}
21932319
21942320.chat-markdown .chat-markdown-chrome-action[aria-pressed="true"] {
2195 color: var(--foreground);
2196 background: color-mix(in srgb, var(--foreground) 8%, transparent);
2321 color: var(--contrast-foreground);
2322 background: color-mix(in srgb, var(--contrast-foreground) 8%, transparent);
21972323}
21982324
21992325.chat-markdown .chat-markdown-codeblock pre {
......@@ -2233,14 +2359,14 @@ code {
22332359}
22342360
22352361.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);
22372363 padding-block: 0.55rem;
22382364 font-weight: 600;
22392365 white-space: nowrap;
22402366}
22412367
22422368.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);
22442370}
22452371
22462372/* Collapsed (default): single-line cells truncate so arbitrary chat content
apps/web/src/routes/__root.tsx+12
......@@ -30,6 +30,7 @@ import {
3030} from "../components/ui/toast";
3131import { resolveAndPersistPreferredEditor } from "../editorPreferences";
3232import { applyAppearanceFontVariables } from "~/appearanceFonts";
33import { applyAppearanceContrast } from "~/appearanceContrast";
3334import { useClientSettings } from "../hooks/useSettings";
3435import { PlanAgentSelectionHeal } from "../planAgentSelectionHeal";
3536import {
......@@ -131,6 +132,7 @@ function RootRouteView() {
131132 <ToastProvider>
132133 <AnchoredToastProvider>
133134 <DocumentTitleSync />
135 <ContrastAppearanceSync />
134136 <GlassAppearanceSync />
135137 <FontAppearanceSync />
136138 {primaryEnvironmentAuthenticated ? <AuthenticatedTracingBootstrap /> : null}
......@@ -152,6 +154,16 @@ function RootRouteView() {
152154 );
153155}
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
155167function GlassAppearanceSync() {
156168 const glassOpacity = useClientSettings((settings) => settings.glassOpacity);
157169
packages/contracts/src/settings.test.ts+16
......@@ -51,6 +51,22 @@ describe("ClientSettings glass opacity", () => {
5151 });
5252});
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
5470describe("ClientSettings environment identification", () => {
5571 it("defaults to artwork and accepts each presentation mode", () => {
5672 expect(decodeClientSettings({}).environmentIdentificationMode).toBe("artwork");
packages/contracts/src/settings.ts+12
......@@ -75,6 +75,14 @@ export const GlassOpacity = Schema.Int.check(
7575);
7676export type GlassOpacity = typeof GlassOpacity.Type;
7777export 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;
7886/**
7987 * Font size preferences, in CSS pixels. The ranges are deliberately narrow:
8088 * 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
133141export const DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW = true;
134142
135143export const ClientSettingsSchema = Schema.Struct({
144 appearanceContrast: AppearanceContrast.pipe(
145 Schema.withDecodingDefault(Effect.succeed(DEFAULT_APPEARANCE_CONTRAST)),
146 ),
136147 browserDefaultViewport: PreviewViewportSetting.pipe(
137148 Schema.withDecodingDefault(Effect.succeed(DEFAULT_BROWSER_VIEWPORT)),
138149 ),
......@@ -860,6 +871,7 @@ export const ServerSettingsPatch = Schema.Struct({
860871export type ServerSettingsPatch = typeof ServerSettingsPatch.Type;
861872
862873export const ClientSettingsPatch = Schema.Struct({
874 appearanceContrast: Schema.optionalKey(AppearanceContrast),
863875 browserDefaultViewport: Schema.optionalKey(PreviewViewportSetting),
864876 browserDefaultZoomFactor: Schema.optionalKey(PreviewZoomFactor),
865877 browserDefaultAppearance: Schema.optionalKey(PreviewAppearancePreference),