Skip to content
Merged
21 changes: 21 additions & 0 deletions apps/web/src/appearanceContrast.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { describe, expect, it, vi } from "vite-plus/test";

import { applyAppearanceContrast } from "./appearanceContrast";

describe("applyAppearanceContrast", () => {
it("applies a non-default contrast filter", () => {
const setProperty = vi.fn();

applyAppearanceContrast({ style: { setProperty } } as unknown as HTMLElement, 135);

expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-filter", "contrast(135%)");
});

it("disables the filter at the default contrast", () => {
const setProperty = vi.fn();

applyAppearanceContrast({ style: { setProperty } } as unknown as HTMLElement, 100);

expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-filter", "none");
});
});
8 changes: 8 additions & 0 deletions apps/web/src/appearanceContrast.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { AppearanceContrast } from "@t3tools/contracts/settings";

export function applyAppearanceContrast(root: HTMLElement, contrast: AppearanceContrast): void {
root.style.setProperty(
"--appearance-contrast-filter",
contrast === 100 ? "none" : `contrast(${contrast}%)`,
);
}
57 changes: 57 additions & 0 deletions apps/web/src/components/settings/SettingsPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Comment thread
cursor[bot] marked this conversation as resolved.
MIN_GLASS_OPACITY,
MIN_INTERFACE_FONT_SIZE,
MIN_PROMPT_FONT_SIZE,
Expand Down Expand Up @@ -986,6 +988,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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 --settings-slider-progress / --settings-slider-fill-offset pair, the output badge classes, and the range-clamping onChange. The fill-offset geometry is durable and shared, so it would be better owned by a small SettingsSlider primitive (label, min, max, step, value, onValueChange) with the call site keeping only width and copy; otherwise the thumb geometry can drift between the three sliders as soon as one is tweaked. Not blocking on its own.

Posted via Macroscope — UI Consistency

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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>
Expand All @@ -1006,6 +1015,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."
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/components/settings/settingsSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@ export const SETTINGS_SEARCH_ITEMS = [
// stable scroll destination for both.
targetId: "appearance",
},
{
// Prefixed because the slider control already owns the `appearance-contrast` id.
id: "setting-appearance-contrast",
title: "Contrast",
to: "/settings/appearance",
},
{
// Prefixed because the slider control already owns the `glass-opacity` id.
id: "setting-glass-opacity",
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil

:root {
--app-scrollbar-width: 6px;
--appearance-contrast-filter: none;
--app-scrollbar-thumb: rgb(217 217 217);
--app-scrollbar-thumb-hover: rgb(191 191 191);
/*
Expand All @@ -103,6 +104,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
--workspace-native-controls-inset: 0px;
--workspace-titlebar-control-size: 1.75rem;
--workspace-titlebar-control-gap: 0.75rem;
filter: var(--appearance-contrast-filter);
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated

@variant dark {
--app-scrollbar-thumb: rgb(255 255 255 / 8%);
Expand Down
12 changes: 12 additions & 0 deletions apps/web/src/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
} from "../components/ui/toast";
import { resolveAndPersistPreferredEditor } from "../editorPreferences";
import { applyAppearanceFontVariables } from "~/appearanceFonts";
import { applyAppearanceContrast } from "~/appearanceContrast";
import { useClientSettings } from "../hooks/useSettings";
import { PlanAgentSelectionHeal } from "../planAgentSelectionHeal";
import {
Expand Down Expand Up @@ -131,6 +132,7 @@ function RootRouteView() {
<ToastProvider>
<AnchoredToastProvider>
<DocumentTitleSync />
<ContrastAppearanceSync />
<GlassAppearanceSync />
<FontAppearanceSync />
{primaryEnvironmentAuthenticated ? <AuthenticatedTracingBootstrap /> : null}
Expand All @@ -152,6 +154,16 @@ function RootRouteView() {
);
}

function ContrastAppearanceSync() {
const appearanceContrast = useClientSettings((settings) => settings.appearanceContrast);

useEffect(() => {
applyAppearanceContrast(document.documentElement, appearanceContrast);
}, [appearanceContrast]);

return null;
}

function GlassAppearanceSync() {
const glassOpacity = useClientSettings((settings) => settings.glassOpacity);

Expand Down
16 changes: 16 additions & 0 deletions packages/contracts/src/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,22 @@ describe("ClientSettings glass opacity", () => {
});
});

describe("ClientSettings appearance contrast", () => {
it("defaults to the theme's original contrast", () => {
expect(decodeClientSettings({}).appearanceContrast).toBe(100);
});

it.each([49, 151, 92.5])("rejects an invalid appearance contrast: %s", (value) => {
expect(() => decodeClientSettings({ appearanceContrast: value })).toThrow();
expect(() => decodeClientSettingsPatch({ appearanceContrast: value })).toThrow();
});

it.each([50, 100, 150])("accepts an appearance contrast in range: %s", (value) => {
expect(decodeClientSettings({ appearanceContrast: value }).appearanceContrast).toBe(value);
expect(decodeClientSettingsPatch({ appearanceContrast: value }).appearanceContrast).toBe(value);
});
});

describe("ClientSettings environment identification", () => {
it("defaults to artwork and accepts each presentation mode", () => {
expect(decodeClientSettings({}).environmentIdentificationMode).toBe("artwork");
Expand Down
12 changes: 12 additions & 0 deletions packages/contracts/src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,14 @@ export const GlassOpacity = Schema.Int.check(
);
export type GlassOpacity = typeof GlassOpacity.Type;
export const DEFAULT_GLASS_OPACITY: GlassOpacity = 80;

export const MIN_APPEARANCE_CONTRAST = 50;
export const MAX_APPEARANCE_CONTRAST = 150;
export const AppearanceContrast = Schema.Int.check(
Schema.isBetween({ minimum: MIN_APPEARANCE_CONTRAST, maximum: MAX_APPEARANCE_CONTRAST }),
);
export type AppearanceContrast = typeof AppearanceContrast.Type;
export const DEFAULT_APPEARANCE_CONTRAST: AppearanceContrast = 100;
/**
* Font size preferences, in CSS pixels. The ranges are deliberately narrow:
* the interface size scales every rem-based dimension in the app, so the
Expand Down Expand Up @@ -133,6 +141,9 @@ export const DEFAULT_BROWSER_VIEWPORT: PreviewViewportSetting = FILL_PREVIEW_VIE
export const DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW = true;

export const ClientSettingsSchema = Schema.Struct({
appearanceContrast: AppearanceContrast.pipe(
Schema.withDecodingDefault(Effect.succeed(DEFAULT_APPEARANCE_CONTRAST)),
),
browserDefaultViewport: PreviewViewportSetting.pipe(
Schema.withDecodingDefault(Effect.succeed(DEFAULT_BROWSER_VIEWPORT)),
),
Expand Down Expand Up @@ -860,6 +871,7 @@ export const ServerSettingsPatch = Schema.Struct({
export type ServerSettingsPatch = typeof ServerSettingsPatch.Type;

export const ClientSettingsPatch = Schema.Struct({
appearanceContrast: Schema.optionalKey(AppearanceContrast),
browserDefaultViewport: Schema.optionalKey(PreviewViewportSetting),
browserDefaultZoomFactor: Schema.optionalKey(PreviewZoomFactor),
browserDefaultAppearance: Schema.optionalKey(PreviewAppearancePreference),
Expand Down
Loading