Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .macroscope/check-run-agents/ui-consistency.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ The goal is not to minimize CSS or class counts at any cost. The goal is to put
- light-only declarations use `@variant light`;
- raw `.dark` should remain only in the `dark` and `light` custom-variant definitions.
- 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.
- 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.
- Inspect emitted production CSS after unusual variants, arbitrary selectors, nested pseudo-elements, or attribute matching. Source syntax that looks valid is insufficient.
- 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.
- 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.
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/settings/DesktopClientSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
import * as DesktopClientSettings from "./DesktopClientSettings.ts";

const clientSettings: ClientSettings = {
appearanceContrast: 100,
browserDefaultViewport: { _tag: "preset", width: 1024, height: 600, presetId: "nest-hub" },
browserDefaultZoomFactor: 1.25,
browserDefaultAppearance: "dark",
Expand Down
45 changes: 45 additions & 0 deletions apps/web/src/appearanceContrast.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { describe, expect, it, vi } from "vite-plus/test";

import { applyAppearanceContrast } from "./appearanceContrast";

function makeRoot() {
const setProperty = vi.fn();
const toggleAttribute = vi.fn();
return {
root: { style: { setProperty }, toggleAttribute } as unknown as HTMLElement,
setProperty,
toggleAttribute,
};
}

describe("applyAppearanceContrast", () => {
it("boosts semantic contrast above the default", () => {
const { root, setProperty, toggleAttribute } = makeRoot();

applyAppearanceContrast(root, 135);

expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-base", "100%");
expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-boost", "35%");
expect(toggleAttribute).toHaveBeenCalledWith("data-appearance-contrast", true);
});

it("softens semantic contrast below the default", () => {
const { root, setProperty, toggleAttribute } = makeRoot();

applyAppearanceContrast(root, 70);

expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-base", "70%");
expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-boost", "0%");
expect(toggleAttribute).toHaveBeenCalledWith("data-appearance-contrast", true);
});

it("disables contrast mixing at the default", () => {
const { root, setProperty, toggleAttribute } = makeRoot();

applyAppearanceContrast(root, 100);

expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-base", "100%");
expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-boost", "0%");
expect(toggleAttribute).toHaveBeenCalledWith("data-appearance-contrast", false);
});
});
7 changes: 7 additions & 0 deletions apps/web/src/appearanceContrast.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
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.toggleAttribute("data-appearance-contrast", contrast !== 100);
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
}
62 changes: 62 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 @@ -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
Expand Down Expand Up @@ -543,6 +548,7 @@ export function useSettingsRestore(onRestored?: () => void) {
settings.browserDefaultZoomFactor,
settings.browserDefaultAppearance,
settings.browserAutoShowFloatingPreview,
settings.appearanceContrast,
settings.enableAgentBrowserAccess,
settings.confirmQuit,
settings.confirmThreadArchive,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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

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 +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."
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
68 changes: 58 additions & 10 deletions apps/web/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil

:root {
--app-scrollbar-width: 6px;
--appearance-contrast-base: 100%;
--appearance-contrast-boost: 0%;
--appearance-contrast-target: black;
Comment thread
cursor[bot] marked this conversation as resolved.
--app-scrollbar-thumb: rgb(217 217 217);
--app-scrollbar-thumb-hover: rgb(191 191 191);
/*
Expand Down Expand Up @@ -105,6 +108,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
--workspace-titlebar-control-gap: 0.75rem;

@variant dark {
--appearance-contrast-target: white;
--app-scrollbar-thumb: rgb(255 255 255 / 8%);
--app-scrollbar-thumb-hover: rgb(255 255 255 / 12%);
--glass-blur: 16px;
Expand Down Expand Up @@ -156,16 +160,40 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
--color-info: var(--info);
--color-destructive-foreground: var(--destructive-foreground);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-input: color-mix(
in oklab,
color-mix(in oklab, var(--input) var(--appearance-contrast-base), var(--background)),
var(--appearance-contrast-target) var(--appearance-contrast-boost)
);
--color-border: color-mix(
in oklab,
color-mix(in oklab, var(--border) var(--appearance-contrast-base), var(--background)),
var(--appearance-contrast-target) var(--appearance-contrast-boost)
);
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted-foreground: color-mix(
in oklab,
color-mix(in oklab, var(--muted-foreground) var(--appearance-contrast-base), var(--background)),
var(--appearance-contrast-target) var(--appearance-contrast-boost)
);
--color-muted: var(--muted);
--color-placeholder: var(--placeholder);
--color-secondary-label: var(--secondary-label);
--color-icon-muted: var(--icon-muted);
--color-placeholder: color-mix(
in oklab,
color-mix(in oklab, var(--placeholder) var(--appearance-contrast-base), var(--background)),
var(--appearance-contrast-target) var(--appearance-contrast-boost)
);
--color-secondary-label: color-mix(
in oklab,
color-mix(in oklab, var(--secondary-label) var(--appearance-contrast-base), var(--background)),
var(--appearance-contrast-target) var(--appearance-contrast-boost)
);
--color-icon-muted: color-mix(
in oklab,
color-mix(in oklab, var(--icon-muted) var(--appearance-contrast-base), var(--background)),
var(--appearance-contrast-target) var(--appearance-contrast-boost)
);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
Expand All @@ -174,7 +202,11 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-foreground: color-mix(
in oklab,
color-mix(in oklab, var(--foreground) var(--appearance-contrast-base), var(--background)),
var(--appearance-contrast-target) var(--appearance-contrast-boost)
);
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
--color-background: var(--background);
--color-surface-raised: var(--surface-raised);
--color-message: var(--message-surface);
Expand All @@ -183,13 +215,29 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
--color-message-action-foreground: var(--message-action-foreground);
--color-message-action-hover: var(--message-action-hover);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-muted-foreground: var(--sidebar-muted-foreground);
--color-sidebar-foreground: color-mix(
in oklab,
color-mix(in oklab, var(--sidebar-foreground) var(--appearance-contrast-base), var(--sidebar)),
var(--appearance-contrast-target) var(--appearance-contrast-boost)
);
--color-sidebar-muted-foreground: color-mix(
in oklab,
color-mix(
in oklab,
var(--sidebar-muted-foreground) var(--appearance-contrast-base),
var(--sidebar)
),
var(--appearance-contrast-target) var(--appearance-contrast-boost)
);
--color-sidebar-control-surface: var(--sidebar-control-surface);
--color-sidebar-row-hover: var(--sidebar-row-hover);
--color-sidebar-row-active: var(--sidebar-row-active);
--color-sidebar-row-selected: var(--sidebar-row-selected);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-border: color-mix(
in oklab,
color-mix(in oklab, var(--sidebar-border) var(--appearance-contrast-base), var(--sidebar)),
var(--appearance-contrast-target) var(--appearance-contrast-boost)
);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
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