From ceddda4f2185fa0f62f30734900f026fdf88341a Mon Sep 17 00:00:00 2001 From: Xinmin Zeng <135568692+fancyboi999@users.noreply.github.com> Date: Fri, 30 Oct 2026 10:49:09 +0800 Subject: [PATCH 1/6] fix(shared): keep HTML chart colors readable across themes --- packages/shared/package.json | 2 ++ packages/shared/src/htmlRender.ts | 17 +++++++--- packages/shared/src/htmlRenderColors.ts | 44 +++++++++++++++++++++++++ pnpm-lock.yaml | 6 ++++ 4 files changed, 65 insertions(+), 4 deletions(-) create mode 100644 packages/shared/src/htmlRenderColors.ts diff --git a/packages/shared/package.json b/packages/shared/package.json index cc113b00fe4d..d30bcb2c452b 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -520,6 +520,7 @@ "@noble/curves": "catalog:", "@noble/hashes": "catalog:", "@t3tools/contracts": "workspace:*", + "culori": "^4.0.2", "effect": "catalog:", "jose": "catalog:", "mdast-util-directive": "catalog:", @@ -539,6 +540,7 @@ "devDependencies": { "@effect/platform-node": "catalog:", "@effect/vitest": "catalog:", + "@types/culori": "^4.0.1", "@types/mdast": "^4.0.4", "@types/node": "catalog:", "micromark-util-types": "catalog:", diff --git a/packages/shared/src/htmlRender.ts b/packages/shared/src/htmlRender.ts index 12af0f39336b..4601995b7c6e 100644 --- a/packages/shared/src/htmlRender.ts +++ b/packages/shared/src/htmlRender.ts @@ -5,6 +5,7 @@ import { type ThemeColors, } from "./themePalettes.ts"; import { EVENT_CODE_SHORTCUT_KEYS } from "./keybindings.ts"; +import { readableHtmlRenderColor } from "./htmlRenderColors.ts"; export const HTML_RENDER_TOOL_NAME = "html_render"; export const HTML_RENDER_MIN_HEIGHT = 80; @@ -178,6 +179,15 @@ export function htmlRenderTheme( fonts: HtmlRenderFonts = HTML_RENDER_DEFAULT_FONTS, ): HtmlRenderTheme { const fixed = FIXED_COLORS[appearance]; + const accent = readableHtmlRenderColor(colors.accent, colors.canvas, 3); + const accentForeground = + accent === colors.accent + ? colors.accentForeground + : readableHtmlRenderColor(colors.accentForeground, accent, 4.5); + const chart = [ + accent, + ...fixed.chart.map((color) => readableHtmlRenderColor(color, colors.canvas, 3)), + ]; return { appearance, variables: { @@ -196,8 +206,8 @@ export function htmlRenderTheme( "--ring": colors.focus, "--primary": colors.messageAction, "--primary-foreground": colors.messageActionForeground, - "--accent": colors.accent, - "--accent-foreground": colors.accentForeground, + "--accent": accent, + "--accent-foreground": accentForeground, "--accent-surface": colors.accentSurface, "--accent-surface-foreground": colors.accentSurfaceForeground, "--destructive": colors.error, @@ -212,8 +222,7 @@ export function htmlRenderTheme( "--info-foreground": fixed.infoForeground, "--code-background": colors.codeBackground, "--code-foreground": colors.codeForeground, - "--chart-1": colors.accent, - ...Object.fromEntries(fixed.chart.map((color, index) => [`--chart-${index + 2}`, color])), + ...Object.fromEntries(chart.map((color, index) => [`--chart-${index + 1}`, color])), "--radius": "0.625rem", "--font-sans": fonts.sans, "--font-mono": fonts.mono, diff --git a/packages/shared/src/htmlRenderColors.ts b/packages/shared/src/htmlRenderColors.ts new file mode 100644 index 000000000000..2e2f132b6be1 --- /dev/null +++ b/packages/shared/src/htmlRenderColors.ts @@ -0,0 +1,44 @@ +import "culori/css"; +import { clampChroma, converter, formatHex, wcagContrast } from "culori/fn"; + +const toRgb = converter("rgb"); +const toOklch = converter("oklch"); + +export function readableHtmlRenderColor(color: string, surface: string, minimum: number): string { + const foreground = toRgb(color); + const background = toRgb(surface); + // A translucent canvas needs the host's underlying surface to determine contrast. + if (!foreground || !background || (background.alpha ?? 1) < 1) return color; + + const alpha = Math.max(0, Math.min(1, foreground.alpha ?? 1)); + const painted = { + mode: "rgb" as const, + r: foreground.r * alpha + background.r * (1 - alpha), + g: foreground.g * alpha + background.g * (1 - alpha), + b: foreground.b * alpha + background.b * (1 - alpha), + }; + if (wcagContrast(painted, background) >= minimum) return color; + + const base = toOklch(painted); + const encode = (lightness: number) => + formatHex(clampChroma({ ...base, l: lightness }, "oklch", "rgb")); + const original = encode(base.l); + if (wcagContrast(original, background) >= minimum) return original; + + const lighter = wcagContrast("#ffffff", background) >= wcagContrast("#000000", background); + let low = lighter ? base.l : 0; + let high = lighter ? 1 : base.l; + let result = encode(lighter ? 1 : 0); + for (let step = 0; step < 24; step += 1) { + const mid = (low + high) / 2; + const candidate = encode(mid); + // Measure the encoded sRGB paint so rounding cannot put it below the threshold. + if (wcagContrast(candidate, background) >= minimum) { + result = candidate; + if (lighter) high = mid; + else low = mid; + } else if (lighter) low = mid; + else high = mid; + } + return result; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 196781c388b0..08f47711c6f4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1901,6 +1901,9 @@ importers: '@t3tools/contracts': specifier: workspace:* version: link:../contracts + culori: + specifier: ^4.0.2 + version: 4.0.2 effect: specifier: 4.0.5 version: 4.0.5 @@ -1953,6 +1956,9 @@ importers: '@effect/vitest': specifier: 4.0.5 version: 4.0.5(patch_hash=2731c9c3761c595f25ddc625a7d61ca376b389d5b3049ecb837c8940f21cfa02)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.0.5)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + '@types/culori': + specifier: ^4.0.1 + version: 4.0.1 '@types/mdast': specifier: ^4.0.4 version: 4.0.4 From 6598517ddecab9c15797a2eca8b48edaaeaf8690 Mon Sep 17 00:00:00 2001 From: Xinmin Zeng <135568692+fancyboi999@users.noreply.github.com> Date: Fri, 30 Oct 2026 11:34:19 +0800 Subject: [PATCH 2/6] test(shared): cover HTML graphic contrast mapping --- packages/shared/src/htmlRender.test.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/packages/shared/src/htmlRender.test.ts b/packages/shared/src/htmlRender.test.ts index 793fde8b8246..515da61af1f3 100644 --- a/packages/shared/src/htmlRender.test.ts +++ b/packages/shared/src/htmlRender.test.ts @@ -1,14 +1,40 @@ import { describe, expect, it } from "vite-plus/test"; +import { wcagContrast } from "culori"; import { + htmlRenderTheme, injectHtmlRenderBootstrap, readHtmlRenderImageRequest, readHtmlRenderLinkRequest, } from "./htmlRender.ts"; +import { T3_CODE_DARK_THEME_COLORS } from "./themePalettes.ts"; import { htmlRenderFromToolItem } from "./toolOutput.ts"; const reference = { attachmentId: "thread-abc-123.html", title: "Chart", height: 420 }; +describe("htmlRenderTheme graphic contrast", () => { + it.each([ + { name: "the reported dark accent", canvas: "#161515", accent: "#21180e" }, + { name: "a midgray accent and canvas", canvas: "#757575", accent: "#747474" }, + ])("keeps graphic ink and paired text readable for $name", ({ canvas, accent }) => { + const { variables } = htmlRenderTheme( + { ...T3_CODE_DARK_THEME_COLORS, canvas, accent, accentForeground: "#ffffff" }, + "dark", + ); + + expect(variables["--background"]).toBe(canvas); + for (const key of [ + "--accent", + ...Array.from({ length: 6 }, (_, index) => `--chart-${index + 1}`), + ]) { + expect(wcagContrast(variables[key]!, canvas), key).toBeGreaterThanOrEqual(3); + } + expect( + wcagContrast(variables["--accent-foreground"]!, variables["--accent"]!), + ).toBeGreaterThanOrEqual(4.5); + }); +}); + describe("injectHtmlRenderBootstrap", () => { it("puts the theme ahead of the page's own head content", () => { const html = From 91663536a096148f699b49bcf470317eb8e7093e Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 31 Oct 2026 01:22:14 +0000 Subject: [PATCH 3/6] test(shared): preserve readable HTML chart accents --- packages/shared/src/htmlRender.test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/shared/src/htmlRender.test.ts b/packages/shared/src/htmlRender.test.ts index 515da61af1f3..b7103080c41b 100644 --- a/packages/shared/src/htmlRender.test.ts +++ b/packages/shared/src/htmlRender.test.ts @@ -16,6 +16,7 @@ describe("htmlRenderTheme graphic contrast", () => { it.each([ { name: "the reported dark accent", canvas: "#161515", accent: "#21180e" }, { name: "a midgray accent and canvas", canvas: "#757575", accent: "#747474" }, + { name: "a pale accent on a light canvas", canvas: "#fafafa", accent: "#eeeeee" }, ])("keeps graphic ink and paired text readable for $name", ({ canvas, accent }) => { const { variables } = htmlRenderTheme( { ...T3_CODE_DARK_THEME_COLORS, canvas, accent, accentForeground: "#ffffff" }, @@ -33,6 +34,20 @@ describe("htmlRenderTheme graphic contrast", () => { wcagContrast(variables["--accent-foreground"]!, variables["--accent"]!), ).toBeGreaterThanOrEqual(4.5); }); + + it.each([ + { canvas: "#161515", accent: "#ffffff" }, + { canvas: "#ffffff", accent: "#000000" }, + { canvas: "#161515", accent: "rgb(255 255 255 / 80%)" }, + { canvas: "#161515", accent: "var(--brand)" }, + { canvas: "rgb(0 0 0 / 50%)", accent: "#111111" }, + ])("preserves passthrough accent $accent on $canvas", ({ canvas, accent }) => { + const { variables } = htmlRenderTheme({ ...T3_CODE_DARK_THEME_COLORS, canvas, accent }, "dark"); + + expect(variables["--accent"]).toBe(accent); + expect(variables["--chart-1"]).toBe(accent); + expect(variables["--accent-foreground"]).toBe(T3_CODE_DARK_THEME_COLORS.accentForeground); + }); }); describe("injectHtmlRenderBootstrap", () => { From 55b283c9378ea2fbd49380b9853d483413911175 Mon Sep 17 00:00:00 2001 From: Xinmin Zeng <135568692+fancyboi999@users.noreply.github.com> Date: Fri, 6 Nov 2026 09:27:21 +0800 Subject: [PATCH 4/6] test(shared): check chart contrast in both appearances --- packages/shared/src/htmlRender.test.ts | 32 +++++++++++++++----------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/packages/shared/src/htmlRender.test.ts b/packages/shared/src/htmlRender.test.ts index b7103080c41b..253a1e9f7ab9 100644 --- a/packages/shared/src/htmlRender.test.ts +++ b/packages/shared/src/htmlRender.test.ts @@ -18,21 +18,27 @@ describe("htmlRenderTheme graphic contrast", () => { { name: "a midgray accent and canvas", canvas: "#757575", accent: "#747474" }, { name: "a pale accent on a light canvas", canvas: "#fafafa", accent: "#eeeeee" }, ])("keeps graphic ink and paired text readable for $name", ({ canvas, accent }) => { - const { variables } = htmlRenderTheme( - { ...T3_CODE_DARK_THEME_COLORS, canvas, accent, accentForeground: "#ffffff" }, - "dark", - ); + for (const appearance of ["dark", "light"] as const) { + const { variables } = htmlRenderTheme( + { ...T3_CODE_DARK_THEME_COLORS, canvas, accent, accentForeground: "#ffffff" }, + appearance, + ); - expect(variables["--background"]).toBe(canvas); - for (const key of [ - "--accent", - ...Array.from({ length: 6 }, (_, index) => `--chart-${index + 1}`), - ]) { - expect(wcagContrast(variables[key]!, canvas), key).toBeGreaterThanOrEqual(3); + expect(variables["--background"]).toBe(canvas); + for (const key of [ + "--accent", + ...Array.from({ length: 6 }, (_, index) => `--chart-${index + 1}`), + ]) { + expect( + wcagContrast(variables[key]!, canvas), + `${appearance} ${key}`, + ).toBeGreaterThanOrEqual(3); + } + expect( + wcagContrast(variables["--accent-foreground"]!, variables["--accent"]!), + appearance, + ).toBeGreaterThanOrEqual(4.5); } - expect( - wcagContrast(variables["--accent-foreground"]!, variables["--accent"]!), - ).toBeGreaterThanOrEqual(4.5); }); it.each([ From 20ac3b3efcd518fac24b59ffd09359e1415a35b5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 21:25:18 +0000 Subject: [PATCH 5/6] fix(shared): keep chart ink readable on cards as well as the canvas Adjusting accent and chart colors against the canvas alone could push a color that was readable on --card below 3:1 there. Measure against both the canvas and the card, and scan lightness for the nearest color that clears both, since contrast against two surfaces is not monotonic. AI-assisted; verified with the tests and tools listed above. Co-Authored-By: Claude Opus 5.5 --- packages/shared/src/htmlRender.test.ts | 58 ++++++++++++-------- packages/shared/src/htmlRender.ts | 8 +-- packages/shared/src/htmlRenderColors.ts | 70 +++++++++++++++---------- 3 files changed, 82 insertions(+), 54 deletions(-) diff --git a/packages/shared/src/htmlRender.test.ts b/packages/shared/src/htmlRender.test.ts index 4fe8912d3bf3..bbc1eb2dd9e0 100644 --- a/packages/shared/src/htmlRender.test.ts +++ b/packages/shared/src/htmlRender.test.ts @@ -17,29 +17,40 @@ describe("htmlRenderTheme graphic contrast", () => { { name: "the reported dark accent", canvas: "#161515", accent: "#21180e" }, { name: "a midgray accent and canvas", canvas: "#757575", accent: "#747474" }, { name: "a pale accent on a light canvas", canvas: "#fafafa", accent: "#eeeeee" }, - ])("keeps graphic ink and paired text readable for $name", ({ canvas, accent }) => { - for (const appearance of ["dark", "light"] as const) { - const { variables } = htmlRenderTheme( - { ...T3_CODE_DARK_THEME_COLORS, canvas, accent, accentForeground: "#ffffff" }, - appearance, - ); - - expect(variables["--background"]).toBe(canvas); - for (const key of [ - "--accent", - ...Array.from({ length: 6 }, (_, index) => `--chart-${index + 1}`), - ]) { + { + name: "a dark accent that only a midgray card can carry", + canvas: "#161515", + accent: "#21180e", + surface: "#777777", + }, + ])( + "keeps graphic ink and paired text readable for $name", + ({ canvas, accent, surface = T3_CODE_DARK_THEME_COLORS.surface }) => { + for (const appearance of ["dark", "light"] as const) { + const { variables } = htmlRenderTheme( + { ...T3_CODE_DARK_THEME_COLORS, canvas, surface, accent, accentForeground: "#ffffff" }, + appearance, + ); + + expect(variables["--background"]).toBe(canvas); + for (const key of [ + "--accent", + ...Array.from({ length: 6 }, (_, index) => `--chart-${index + 1}`), + ]) { + for (const background of [canvas, surface]) { + expect( + wcagContrast(variables[key]!, background), + `${appearance} ${key} on ${background}`, + ).toBeGreaterThanOrEqual(3); + } + } expect( - wcagContrast(variables[key]!, canvas), - `${appearance} ${key}`, - ).toBeGreaterThanOrEqual(3); + wcagContrast(variables["--accent-foreground"]!, variables["--accent"]!), + appearance, + ).toBeGreaterThanOrEqual(4.5); } - expect( - wcagContrast(variables["--accent-foreground"]!, variables["--accent"]!), - appearance, - ).toBeGreaterThanOrEqual(4.5); - } - }); + }, + ); it.each([ { canvas: "#161515", accent: "#ffffff" }, @@ -48,7 +59,10 @@ describe("htmlRenderTheme graphic contrast", () => { { canvas: "#161515", accent: "var(--brand)" }, { canvas: "rgb(0 0 0 / 50%)", accent: "#111111" }, ])("preserves passthrough accent $accent on $canvas", ({ canvas, accent }) => { - const { variables } = htmlRenderTheme({ ...T3_CODE_DARK_THEME_COLORS, canvas, accent }, "dark"); + const { variables } = htmlRenderTheme( + { ...T3_CODE_DARK_THEME_COLORS, canvas, surface: canvas, accent }, + "dark", + ); expect(variables["--accent"]).toBe(accent); expect(variables["--chart-1"]).toBe(accent); diff --git a/packages/shared/src/htmlRender.ts b/packages/shared/src/htmlRender.ts index a4268d79b12c..a67f64bd5711 100644 --- a/packages/shared/src/htmlRender.ts +++ b/packages/shared/src/htmlRender.ts @@ -179,14 +179,16 @@ export function htmlRenderTheme( fonts: HtmlRenderFonts = HTML_RENDER_DEFAULT_FONTS, ): HtmlRenderTheme { const fixed = FIXED_COLORS[appearance]; - const accent = readableHtmlRenderColor(colors.accent, colors.canvas, 3); + // Graphics sit on the canvas or, per the render guide, inside a --card box. + const graphicSurfaces = [colors.canvas, colors.surface]; + const accent = readableHtmlRenderColor(colors.accent, graphicSurfaces, 3); const accentForeground = accent === colors.accent ? colors.accentForeground - : readableHtmlRenderColor(colors.accentForeground, accent, 4.5); + : readableHtmlRenderColor(colors.accentForeground, [accent], 4.5); const chart = [ accent, - ...fixed.chart.map((color) => readableHtmlRenderColor(color, colors.canvas, 3)), + ...fixed.chart.map((color) => readableHtmlRenderColor(color, graphicSurfaces, 3)), ]; return { appearance, diff --git a/packages/shared/src/htmlRenderColors.ts b/packages/shared/src/htmlRenderColors.ts index 2e2f132b6be1..a197d43981b5 100644 --- a/packages/shared/src/htmlRenderColors.ts +++ b/packages/shared/src/htmlRenderColors.ts @@ -1,44 +1,56 @@ import "culori/css"; -import { clampChroma, converter, formatHex, wcagContrast } from "culori/fn"; +import { clampChroma, converter, formatHex, wcagContrast, type Rgb } from "culori/fn"; const toRgb = converter("rgb"); const toOklch = converter("oklch"); +const LIGHTNESS_STEPS = 200; -export function readableHtmlRenderColor(color: string, surface: string, minimum: number): string { +/** Keeps `color` unless it falls below `minimum` contrast on any of `surfaces`. */ +export function readableHtmlRenderColor( + color: string, + surfaces: readonly string[], + minimum: number, +): string { const foreground = toRgb(color); - const background = toRgb(surface); - // A translucent canvas needs the host's underlying surface to determine contrast. - if (!foreground || !background || (background.alpha ?? 1) < 1) return color; + const backgrounds = surfaces.map((surface) => toRgb(surface)); + const opaque = backgrounds.filter( + (background): background is Rgb => background !== undefined && (background.alpha ?? 1) >= 1, + ); + // A translucent surface needs the host's underlying surface to determine contrast. + if (!foreground || opaque.length === 0 || opaque.length < backgrounds.length) return color; const alpha = Math.max(0, Math.min(1, foreground.alpha ?? 1)); - const painted = { - mode: "rgb" as const, + const paintedOn = (background: Rgb): Rgb => ({ + mode: "rgb", r: foreground.r * alpha + background.r * (1 - alpha), g: foreground.g * alpha + background.g * (1 - alpha), b: foreground.b * alpha + background.b * (1 - alpha), - }; - if (wcagContrast(painted, background) >= minimum) return color; + }); + const originalWorst = Math.min( + ...opaque.map((background) => wcagContrast(paintedOn(background), background)), + ); + if (originalWorst >= minimum) return color; - const base = toOklch(painted); - const encode = (lightness: number) => - formatHex(clampChroma({ ...base, l: lightness }, "oklch", "rgb")); - const original = encode(base.l); - if (wcagContrast(original, background) >= minimum) return original; - - const lighter = wcagContrast("#ffffff", background) >= wcagContrast("#000000", background); - let low = lighter ? base.l : 0; - let high = lighter ? 1 : base.l; - let result = encode(lighter ? 1 : 0); - for (let step = 0; step < 24; step += 1) { - const mid = (low + high) / 2; - const candidate = encode(mid); + const base = toOklch(paintedOn(opaque[0]!)); + let nearest: string | undefined; + let nearestDistance = Infinity; + let strongest = color; + let strongestWorst = originalWorst; + // Contrast against several surfaces is not monotonic in lightness, so scan it. + for (let step = 0; step <= LIGHTNESS_STEPS; step += 1) { + const lightness = step / LIGHTNESS_STEPS; // Measure the encoded sRGB paint so rounding cannot put it below the threshold. - if (wcagContrast(candidate, background) >= minimum) { - result = candidate; - if (lighter) high = mid; - else low = mid; - } else if (lighter) low = mid; - else high = mid; + const candidate = formatHex(clampChroma({ ...base, l: lightness }, "oklch", "rgb")); + const worst = Math.min(...opaque.map((background) => wcagContrast(candidate, background))); + const distance = Math.abs(lightness - base.l); + if (worst >= minimum && distance < nearestDistance) { + nearest = candidate; + nearestDistance = distance; + } + if (worst > strongestWorst) { + strongest = candidate; + strongestWorst = worst; + } } - return result; + return nearest ?? strongest; } From ba93478cab35c577e58e30ad08d4d156547ba176 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 21:37:32 +0000 Subject: [PATCH 6/6] fix(shared): never trade away a surface the chart color already reads on When no color reaches 3:1 on both the canvas and the card, the fallback picked the best worst-case contrast, which could turn a white accent that read at 8:1 on the canvas into black at 2.6:1. The fallback now only accepts colors that keep every surface the original already satisfied, and otherwise keeps the original. AI-assisted; verified with the tests and tools listed above. Co-Authored-By: Claude Opus 5.5 --- packages/shared/src/htmlRender.test.ts | 6 ++++-- packages/shared/src/htmlRenderColors.ts | 14 ++++++++++---- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/shared/src/htmlRender.test.ts b/packages/shared/src/htmlRender.test.ts index bbc1eb2dd9e0..353a17b1cee4 100644 --- a/packages/shared/src/htmlRender.test.ts +++ b/packages/shared/src/htmlRender.test.ts @@ -58,9 +58,11 @@ describe("htmlRenderTheme graphic contrast", () => { { canvas: "#161515", accent: "rgb(255 255 255 / 80%)" }, { canvas: "#161515", accent: "var(--brand)" }, { canvas: "rgb(0 0 0 / 50%)", accent: "#111111" }, - ])("preserves passthrough accent $accent on $canvas", ({ canvas, accent }) => { + // No color reaches 3:1 on both; keep the one that already reads on the canvas. + { canvas: "#505050", surface: "#aaaaaa", accent: "#ffffff" }, + ])("preserves passthrough accent $accent on $canvas", ({ canvas, surface = canvas, accent }) => { const { variables } = htmlRenderTheme( - { ...T3_CODE_DARK_THEME_COLORS, canvas, surface: canvas, accent }, + { ...T3_CODE_DARK_THEME_COLORS, canvas, surface, accent }, "dark", ); diff --git a/packages/shared/src/htmlRenderColors.ts b/packages/shared/src/htmlRenderColors.ts index a197d43981b5..41adbaca2451 100644 --- a/packages/shared/src/htmlRenderColors.ts +++ b/packages/shared/src/htmlRenderColors.ts @@ -26,9 +26,10 @@ export function readableHtmlRenderColor( g: foreground.g * alpha + background.g * (1 - alpha), b: foreground.b * alpha + background.b * (1 - alpha), }); - const originalWorst = Math.min( - ...opaque.map((background) => wcagContrast(paintedOn(background), background)), + const originalContrasts = opaque.map((background) => + wcagContrast(paintedOn(background), background), ); + const originalWorst = Math.min(...originalContrasts); if (originalWorst >= minimum) return color; const base = toOklch(paintedOn(opaque[0]!)); @@ -41,13 +42,18 @@ export function readableHtmlRenderColor( const lightness = step / LIGHTNESS_STEPS; // Measure the encoded sRGB paint so rounding cannot put it below the threshold. const candidate = formatHex(clampChroma({ ...base, l: lightness }, "oklch", "rgb")); - const worst = Math.min(...opaque.map((background) => wcagContrast(candidate, background))); + const contrasts = opaque.map((background) => wcagContrast(candidate, background)); + const worst = Math.min(...contrasts); const distance = Math.abs(lightness - base.l); if (worst >= minimum && distance < nearestDistance) { nearest = candidate; nearestDistance = distance; } - if (worst > strongestWorst) { + // A fallback must not give up a surface the original color already reads on. + const keepsSatisfied = contrasts.every( + (contrast, index) => contrast >= minimum || originalContrasts[index]! < minimum, + ); + if (keepsSatisfied && worst > strongestWorst) { strongest = candidate; strongestWorst = worst; }