Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
3 changes: 3 additions & 0 deletions .macroscope/check-run-agents/ui-consistency.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ 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.
- 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.
- 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.
- 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
53 changes: 53 additions & 0 deletions apps/web/src/appearanceContrast.test.ts
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%");
});
});
10 changes: 10 additions & 0 deletions apps/web/src/appearanceContrast.ts
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}%`,
);
}
12 changes: 6 additions & 6 deletions apps/web/src/browser/annotationTheme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,17 @@ export function readPreviewAnnotationTheme(): DesktopPreviewAnnotationTheme {
colorScheme: root.classList.contains("dark") ? "dark" : "light",
radius: readVariable(styles, "--radius", "0.625rem"),
background: readVariable(styles, "--background", "white"),
foreground: readVariable(styles, "--foreground", "oklch(0.269 0 0)"),
foreground: readVariable(styles, "--contrast-foreground", "oklch(0.269 0 0)"),
popover: readVariable(styles, "--popover", "white"),
popoverForeground: readVariable(styles, "--popover-foreground", "oklch(0.269 0 0)"),
popoverForeground: readVariable(styles, "--contrast-popover-foreground", "oklch(0.269 0 0)"),
primary: readVariable(styles, "--primary", "oklch(0.488 0.217 264)"),
primaryForeground: readVariable(styles, "--primary-foreground", "white"),
muted: readVariable(styles, "--muted", "rgb(0 0 0 / 4%)"),
mutedForeground: readVariable(styles, "--muted-foreground", "oklch(0.556 0 0)"),
mutedForeground: readVariable(styles, "--contrast-muted-foreground", "oklch(0.556 0 0)"),
accent: readVariable(styles, "--accent", "rgb(0 0 0 / 4%)"),
accentForeground: readVariable(styles, "--accent-foreground", "oklch(0.269 0 0)"),
border: readVariable(styles, "--border", "rgb(0 0 0 / 8%)"),
input: readVariable(styles, "--input", "rgb(0 0 0 / 10%)"),
accentForeground: readVariable(styles, "--contrast-accent-foreground", "oklch(0.269 0 0)"),
border: readVariable(styles, "--contrast-border", "rgb(0 0 0 / 8%)"),
input: readVariable(styles, "--contrast-input", "rgb(0 0 0 / 10%)"),
ring: readVariable(styles, "--ring", "oklch(0.488 0.217 264)"),
fontSans: readVariable(styles, "--font-sans", styles.fontFamily || "system-ui, sans-serif"),
fontMono: readVariable(styles, "--font-mono", "ui-monospace, monospace"),
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1322,7 +1322,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
>
{/* The full path: the chip already shows the shortened form, and a link
to the workspace root collapses to a bare label that repeats it. */}
<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">
<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">
{targetPath}
</div>
</TooltipPopup>
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/chat/ChangedFilesTree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: {
className={cn(
"flex items-center justify-between gap-2 rounded-xl",
expanded &&
"sticky top-2 z-10 mb-2 bg-secondary dark:bg-[color-mix(in_srgb,var(--foreground)_2.5%,var(--background))]",
"sticky top-2 z-10 mb-2 bg-secondary dark:bg-[color-mix(in_srgb,var(--contrast-foreground)_2.5%,var(--background))]",
)}
>
<button
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/chat/ModelListRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export const ModelListRow = memo(function ModelListRow(props: {
contentClassName="flex w-full items-center gap-3"
className={cn(
"group relative w-full !min-w-0 max-w-full cursor-pointer rounded-md px-2 py-2 transition-[background-color,box-shadow,color]",
"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))]",
"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))]",
props.disabledReason &&
"data-disabled:pointer-events-auto data-disabled:cursor-not-allowed data-disabled:hover:bg-transparent",
)}
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/components/chat/ModelPickerSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: {
render={
<button
className={cn(
"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",
"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",
)}
onClick={() => handleSelect("favorites")}
type="button"
Expand Down Expand Up @@ -152,7 +152,7 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: {
const button = (
<button
className={cn(
"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",
"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",
isDisabled && "opacity-50 cursor-not-allowed hover:bg-transparent",
)}
data-provider-accent-color={entry.accentColor}
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/chat/ProviderModelPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
showBadge={showInstanceBadge}
className="size-4"
iconClassName={cn("size-4", props.activeProviderIconClassName)}
indicatorBackground="var(--input)"
indicatorBackground="var(--contrast-input)"
badgeClassName={cn(
"right-[-0.125rem] bottom-[-0.125rem] h-3 min-w-3",
"px-0.5 text-[7px]",
Expand Down
10 changes: 5 additions & 5 deletions apps/web/src/components/clerk/clerkAppearance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,12 +59,12 @@ describe("clerkAppearance", () => {
colorDanger: "var(--error)",
colorSuccess: "var(--success)",
colorWarning: "var(--warning)",
colorNeutral: "var(--foreground)",
colorForeground: "var(--foreground)",
colorMuted: "color-mix(in srgb, var(--card) 98%, var(--foreground))",
colorMutedForeground: "var(--muted-foreground)",
colorNeutral: "var(--contrast-foreground)",
colorForeground: "var(--contrast-foreground)",
colorMuted: "color-mix(in srgb, var(--card) 98%, var(--contrast-foreground))",
colorMutedForeground: "var(--contrast-muted-foreground)",
colorBackground: "var(--card)",
colorInputForeground: "var(--foreground)",
colorInputForeground: "var(--contrast-foreground)",
colorInput: "var(--secondary)",
colorRing: "var(--ring)",
},
Expand Down
10 changes: 5 additions & 5 deletions apps/web/src/components/clerk/clerkAppearance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,14 @@ export const clerkAppearance = {
colorDanger: "var(--error)",
colorSuccess: "var(--success)",
colorWarning: "var(--warning)",
colorNeutral: "var(--foreground)",
colorForeground: "var(--foreground)",
colorNeutral: "var(--contrast-foreground)",
colorForeground: "var(--contrast-foreground)",
// The stock dark theme's muted token is translucent. Clerk uses this as
// the footer's background, so derive an opaque muted surface from the card.
colorMuted: "color-mix(in srgb, var(--card) 98%, var(--foreground))",
colorMutedForeground: "var(--muted-foreground)",
colorMuted: "color-mix(in srgb, var(--card) 98%, var(--contrast-foreground))",
colorMutedForeground: "var(--contrast-muted-foreground)",
colorBackground: "var(--card)",
colorInputForeground: "var(--foreground)",
colorInputForeground: "var(--contrast-foreground)",
colorInput: "var(--secondary)",
colorRing: "var(--ring)",
},
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/color-selector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ interface ColorSelectorProps {
}

const colorMap = {
default: "var(--foreground)",
default: "var(--contrast-foreground)",
red: "var(--color-red-500)",
green: "var(--color-green-500)",
blue: "var(--color-blue-500)",
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/files/FileBrowserPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -378,7 +378,7 @@ export default function FileBrowserPanel({
className="min-h-0 flex-1 overflow-hidden"
style={{
colorScheme: resolvedTheme,
["--trees-fg-override" as string]: "var(--foreground)",
["--trees-fg-override" as string]: "var(--contrast-foreground)",
}}
/>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,7 @@ const REVIEW_OUTCOME_PRESENTATION = {
toneClassName: "text-muted-foreground/70",
ringClassName: "ring-2 ring-muted-foreground/60",
staleRingClassName:
"ring-2 ring-[color-mix(in_srgb,var(--muted-foreground)_30%,var(--background))]",
"ring-2 ring-[color-mix(in_srgb,var(--contrast-muted-foreground)_30%,var(--background))]",
badgeVariant: "outline",
},
} as const satisfies Record<
Expand Down
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
Loading
Loading