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/4] 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 cc113b00fe4da..d30bcb2c452be 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 12af0f39336b2..4601995b7c6ec 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 0000000000000..2e2f132b6be17 --- /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 196781c388b03..08f47711c6f44 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/4] 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 793fde8b8246c..515da61af1f3c 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/4] 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 515da61af1f3c..b7103080c41b5 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/4] 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 b7103080c41b5..253a1e9f7ab94 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([