Skip to content
Open
2 changes: 2 additions & 0 deletions packages/shared/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -548,6 +548,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:",
Expand All @@ -568,6 +569,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:",
Expand Down
47 changes: 47 additions & 0 deletions packages/shared/src/htmlRender.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,61 @@
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" },
])("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}`),
]) {
expect(
wcagContrast(variables[key]!, canvas),
`${appearance} ${key}`,
).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" },
])("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", () => {
it("puts the theme ahead of the page's own head content", () => {
const html =
Expand Down
17 changes: 13 additions & 4 deletions packages/shared/src/htmlRender.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Comment thread
fancyboi999 marked this conversation as resolved.
Resolved
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: {
Expand All @@ -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,
Expand All @@ -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,
Expand Down
44 changes: 44 additions & 0 deletions packages/shared/src/htmlRenderColors.ts
Original file line number Diff line number Diff line change
@@ -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;
}
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading