-
Notifications
You must be signed in to change notification settings - Fork 4.7k
feat(web): add appearance contrast control #7906
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
e08faf0
2d26aeb
5c8db7a
0249d3a
4b52a03
24ad866
df10a26
d7b49ad
7bb0d15
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| import { describe, expect, it, vi } from "vite-plus/test"; | ||
|
|
||
| import { applyAppearanceContrast } from "./appearanceContrast"; | ||
|
|
||
| function makeRoot() { | ||
| const setProperty = vi.fn(); | ||
| return { | ||
| root: { style: { setProperty } } as unknown as HTMLElement, | ||
| setProperty, | ||
| }; | ||
| } | ||
|
|
||
| describe("applyAppearanceContrast", () => { | ||
| it("boosts semantic contrast above the default", () => { | ||
| const { root, setProperty } = makeRoot(); | ||
|
|
||
| applyAppearanceContrast(root, 135); | ||
|
|
||
| expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-base", "100%"); | ||
| expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-boost", "35%"); | ||
| expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-border-boost", "8.75%"); | ||
| }); | ||
|
|
||
| it("supports the maximum contrast boost", () => { | ||
| const { root, setProperty } = makeRoot(); | ||
|
|
||
| applyAppearanceContrast(root, 200); | ||
|
|
||
| expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-base", "100%"); | ||
| expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-boost", "100%"); | ||
| expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-border-boost", "25%"); | ||
| }); | ||
|
|
||
| it("softens semantic contrast below the default", () => { | ||
| const { root, setProperty } = makeRoot(); | ||
|
|
||
| applyAppearanceContrast(root, 70); | ||
|
|
||
| expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-base", "70%"); | ||
| expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-boost", "0%"); | ||
| expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-border-boost", "0%"); | ||
| }); | ||
|
|
||
| it("disables contrast mixing at the default", () => { | ||
| const { root, setProperty } = makeRoot(); | ||
|
|
||
| applyAppearanceContrast(root, 100); | ||
|
|
||
| expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-base", "100%"); | ||
| expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-boost", "0%"); | ||
| expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-border-boost", "0%"); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| import type { AppearanceContrast } from "@t3tools/contracts/settings"; | ||
|
|
||
| export function applyAppearanceContrast(root: HTMLElement, contrast: AppearanceContrast): void { | ||
| root.style.setProperty("--appearance-contrast-base", `${Math.min(contrast, 100)}%`); | ||
| root.style.setProperty("--appearance-contrast-boost", `${Math.max(contrast - 100, 0)}%`); | ||
| root.style.setProperty( | ||
| "--appearance-contrast-border-boost", | ||
| `${Math.max(contrast - 100, 0) / 4}%`, | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -20,13 +20,15 @@ import { | |
| DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE, | ||
| DEFAULT_UNIFIED_SETTINGS, | ||
| type EnvironmentIdentificationMode, | ||
| MAX_APPEARANCE_CONTRAST, | ||
| MAX_CODE_FONT_SIZE, | ||
| MAX_GLASS_OPACITY, | ||
| MAX_INTERFACE_FONT_SIZE, | ||
| MAX_PROMPT_FONT_SIZE, | ||
| MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, | ||
| MAX_TERMINAL_FONT_SIZE, | ||
| MIN_CODE_FONT_SIZE, | ||
| MIN_APPEARANCE_CONTRAST, | ||
| MIN_GLASS_OPACITY, | ||
| MIN_INTERFACE_FONT_SIZE, | ||
| MIN_PROMPT_FONT_SIZE, | ||
|
|
@@ -475,6 +477,9 @@ export function useSettingsRestore(onRestored?: () => void) { | |
| ...(theme !== "system" ? ["Theme"] : []), | ||
| ...(!followSystem ? ["Follow system"] : []), | ||
| ...(themeHalves !== null ? ["Theme mix"] : []), | ||
| ...(settings.appearanceContrast !== DEFAULT_UNIFIED_SETTINGS.appearanceContrast | ||
| ? ["Contrast"] | ||
| : []), | ||
| ...(settings.glassOpacity !== DEFAULT_UNIFIED_SETTINGS.glassOpacity ? ["Glass opacity"] : []), | ||
| ...(settings.environmentIdentificationMode !== | ||
| DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode | ||
|
|
@@ -543,6 +548,7 @@ export function useSettingsRestore(onRestored?: () => void) { | |
| settings.browserDefaultZoomFactor, | ||
| settings.browserDefaultAppearance, | ||
| settings.browserAutoShowFloatingPreview, | ||
| settings.appearanceContrast, | ||
| settings.enableAgentBrowserAccess, | ||
| settings.confirmQuit, | ||
| settings.confirmThreadArchive, | ||
|
|
@@ -638,6 +644,7 @@ export function useSettingsRestore(onRestored?: () => void) { | |
| return; | ||
| } | ||
| updateSettings({ | ||
| appearanceContrast: DEFAULT_UNIFIED_SETTINGS.appearanceContrast, | ||
| timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat, | ||
| wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap, | ||
| diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace, | ||
|
|
@@ -986,6 +993,13 @@ export function AppearanceSettingsPanel() { | |
| "--settings-slider-progress": `${glassOpacityRatio * 100}%`, | ||
| "--settings-slider-fill-offset": `${0.5 - glassOpacityRatio}rem`, | ||
| } as CSSProperties; | ||
| const appearanceContrastRatio = | ||
| (settings.appearanceContrast - MIN_APPEARANCE_CONTRAST) / | ||
| (MAX_APPEARANCE_CONTRAST - MIN_APPEARANCE_CONTRAST); | ||
| const appearanceContrastSliderStyle = { | ||
| "--settings-slider-progress": `${appearanceContrastRatio * 100}%`, | ||
| "--settings-slider-fill-offset": `${0.5 - appearanceContrastRatio}rem`, | ||
| } as CSSProperties; | ||
|
Comment on lines
+996
to
+1002
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is now the third call site (glass opacity, volume, contrast) duplicating the same slider treatment: the ratio math, the Posted via Macroscope — UI Consistency
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. i checked the current tree and there are two settings-slider call sites: glass opacity and contrast. i kept the established local pattern rather than adding a shared api with only two owners. happy to extract it once a third live owner appears. |
||
|
|
||
| return ( | ||
| <SettingsPageContainer> | ||
|
|
@@ -1006,6 +1020,54 @@ export function AppearanceSettingsPanel() { | |
| /> | ||
| </div> | ||
|
|
||
| <SettingsRow | ||
| {...searchableSetting("setting-appearance-contrast")} | ||
| description="Adjust the contrast of colors and borders across the interface." | ||
| resetAction={ | ||
| settings.appearanceContrast !== DEFAULT_UNIFIED_SETTINGS.appearanceContrast ? ( | ||
| <SettingResetButton | ||
| label="contrast" | ||
| onClick={() => | ||
| updateSettings({ | ||
| appearanceContrast: DEFAULT_UNIFIED_SETTINGS.appearanceContrast, | ||
| }) | ||
| } | ||
| /> | ||
| ) : null | ||
| } | ||
| control={ | ||
| <div className="flex w-full items-center gap-3 sm:w-52"> | ||
| <output | ||
| className="min-w-12 rounded-md bg-muted px-2 py-1 text-center font-mono text-xs font-medium tabular-nums text-foreground" | ||
| htmlFor="appearance-contrast" | ||
| > | ||
| {settings.appearanceContrast}% | ||
| </output> | ||
| <input | ||
| aria-label="Contrast" | ||
| className="settings-slider min-w-0 flex-1" | ||
| id="appearance-contrast" | ||
| max={MAX_APPEARANCE_CONTRAST} | ||
| min={MIN_APPEARANCE_CONTRAST} | ||
| onChange={(event) => { | ||
| const appearanceContrast = Number(event.currentTarget.value); | ||
| if ( | ||
| Number.isInteger(appearanceContrast) && | ||
| appearanceContrast >= MIN_APPEARANCE_CONTRAST && | ||
| appearanceContrast <= MAX_APPEARANCE_CONTRAST | ||
| ) { | ||
| updateSettings({ appearanceContrast }); | ||
| } | ||
| }} | ||
| step={5} | ||
| style={appearanceContrastSliderStyle} | ||
| type="range" | ||
| value={settings.appearanceContrast} | ||
| /> | ||
| </div> | ||
| } | ||
| /> | ||
|
|
||
| <SettingsRow | ||
| {...searchableSetting("setting-glass-opacity")} | ||
| description="Control how transparent glass surfaces are. Higher values make menus, dialogs, and the composer more solid." | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.