diff --git a/packages/shared/package.json b/packages/shared/package.json index 9df772f66d23..84f1e53fb5cb 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -560,6 +560,7 @@ "@noble/curves": "catalog:", "@noble/hashes": "catalog:", "@t3tools/contracts": "workspace:*", + "culori": "^4.0.2", "effect": "catalog:", "ffi-rs": "1.3.2", "mdast-util-directive": "catalog:", @@ -580,6 +581,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.test.ts b/packages/shared/src/htmlRender.test.ts index 9f6315541547..353a17b1cee4 100644 --- a/packages/shared/src/htmlRender.test.ts +++ b/packages/shared/src/htmlRender.test.ts @@ -1,14 +1,77 @@ 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" }, + { name: "a pale accent on a light canvas", canvas: "#fafafa", accent: "#eeeeee" }, + { + 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["--accent-foreground"]!, variables["--accent"]!), + appearance, + ).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" }, + // 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, 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", () => { it("puts the theme ahead of the page's own head content", () => { const html = diff --git a/packages/shared/src/htmlRender.ts b/packages/shared/src/htmlRender.ts index fbcfd6d0f891..a67f64bd5711 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,17 @@ export function htmlRenderTheme( fonts: HtmlRenderFonts = HTML_RENDER_DEFAULT_FONTS, ): HtmlRenderTheme { const fixed = FIXED_COLORS[appearance]; + // 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); + const chart = [ + accent, + ...fixed.chart.map((color) => readableHtmlRenderColor(color, graphicSurfaces, 3)), + ]; return { appearance, variables: { @@ -196,8 +208,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 +224,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..41adbaca2451 --- /dev/null +++ b/packages/shared/src/htmlRenderColors.ts @@ -0,0 +1,62 @@ +import "culori/css"; +import { clampChroma, converter, formatHex, wcagContrast, type Rgb } from "culori/fn"; + +const toRgb = converter("rgb"); +const toOklch = converter("oklch"); +const LIGHTNESS_STEPS = 200; + +/** 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 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 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), + }); + 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]!)); + 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. + const candidate = formatHex(clampChroma({ ...base, l: lightness }, "oklch", "rgb")); + 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; + } + // 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; + } + } + return nearest ?? strongest; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a759f34c7755..45dee6ade186 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2080,6 +2080,9 @@ importers: '@t3tools/contracts': specifier: workspace:* version: link:../contracts + culori: + specifier: ^4.0.2 + version: 4.0.2 effect: specifier: 4.1.3 version: 4.1.3 @@ -2135,6 +2138,9 @@ importers: '@effect/vitest': specifier: 4.1.2 version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.3)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(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