From f9d1de8a0337bb7bfe10cde7fa7b3ae3d249fcc8 Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Sun, 23 Aug 2026 10:46:10 -0600 Subject: [PATCH] fix(web): keep the contrast slider from flattening text hierarchy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The appearance contrast boost mixes every foreground role toward one shared target, so at a full 100% they all resolve to exactly that target. Measured in a browser against the real tokens: at the slider's own maximum of 200, `--contrast-foreground`, `--contrast-muted-foreground`, `--contrast-placeholder` and `--contrast-icon-muted` all come back `oklab(0 0 0)`. Body text, muted timestamps and placeholder text become the same colour, so an empty composer reads as a filled one. It degrades before the maximum too — lightness separation between normal and muted text runs 0.278 at the default, 0.111 at 160, 0.055 at 180, then 0. Scaling the foreground mix to 0.6 keeps the top of the range clearly darker than the default while holding the roles apart, and leaves the slider's range and direction untouched. Borders keep the full curve: they carry a single role, so converging costs no hierarchy, and their quarter-weight already bounds them. Arrived with #7906 (upstream) in #68; not a Pylon-authored defect. --- apps/web/src/appearanceContrast.test.ts | 10 +++++++--- apps/web/src/appearanceContrast.ts | 25 ++++++++++++++++++++----- 2 files changed, 27 insertions(+), 8 deletions(-) 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}%`); }