diff --git a/apps/web/src/appearanceContrast.test.ts b/apps/web/src/appearanceContrast.test.ts index 3e6c1fad0..4be2f77cc 100644 --- a/apps/web/src/appearanceContrast.test.ts +++ b/apps/web/src/appearanceContrast.test.ts @@ -17,17 +17,21 @@ describe("applyAppearanceContrast", () => { applyAppearanceContrast(root, 135); expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-base", "100%"); - expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-boost", "35%"); + expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-boost", "21%"); expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-border-boost", "8.75%"); }); - it("supports the maximum contrast boost", () => { + // The foreground mix stops short of the target on purpose: a full 100% makes + // every foreground role resolve to the target itself, so normal, muted and + // placeholder text become the same colour at the slider's own maximum. + it("keeps the maximum foreground boost short of a full mix", () => { 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-boost", "60%"); + expect(setProperty).not.toHaveBeenCalledWith("--appearance-contrast-boost", "100%"); expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-border-boost", "25%"); }); diff --git a/apps/web/src/appearanceContrast.ts b/apps/web/src/appearanceContrast.ts index a26dca013..05ed0864d 100644 --- a/apps/web/src/appearanceContrast.ts +++ b/apps/web/src/appearanceContrast.ts @@ -1,10 +1,25 @@ import type { AppearanceContrast } from "@t3tools/contracts/settings"; +/** + * The boost mixes every foreground role toward one shared target, so a full + * 100% resolves them all to exactly that target and the normal/muted/placeholder + * hierarchy disappears — an empty composer becomes indistinguishable from a + * filled one. Measured in a browser against the real tokens: at contrast 200 an + * unscaled boost puts `--contrast-foreground`, `--contrast-muted-foreground`, + * `--contrast-placeholder` and `--contrast-icon-muted` all at `oklab(0 0 0)`. + * + * Capping the mix keeps the top of the range clearly darker than the default + * while holding the roles apart. Lightness separation between normal and muted + * text: 0.278 at the default, 0.111 here, 0 unscaled. + * + * Borders are excluded — they carry a single role, so converging on the target + * costs no hierarchy, and their own quarter-weight already bounds them. + */ +const FOREGROUND_BOOST_RATIO = 0.6; + export function applyAppearanceContrast(root: HTMLElement, contrast: AppearanceContrast): void { + const overshoot = Math.max(contrast - 100, 0); 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}%`, - ); + root.style.setProperty("--appearance-contrast-boost", `${overshoot * FOREGROUND_BOOST_RATIO}%`); + root.style.setProperty("--appearance-contrast-border-boost", `${overshoot / 4}%`); }