diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 00000000..3044119b --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,5 @@ +{ + "name": "opencode", + "image": "oven/bun:1.3", + "postCreateCommand": "apt-get update && apt-get install -y python3 make g++ git lcov" +} \ No newline at end of file diff --git a/packages/ui/src/theme/resolve.test.ts b/packages/ui/src/theme/resolve.test.ts new file mode 100644 index 00000000..d8e102a7 --- /dev/null +++ b/packages/ui/src/theme/resolve.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, test } from "bun:test" +import type { HexColor, ThemeVariant } from "./types" +import { + contentTone, + contrastRatio, + hueDistance, + modifiedDiffTone, + readableTextOn, + relativeLuminance, + resolveThemeVariant, +} from "./resolve" + +const seeds: ThemeVariant = { + seeds: { + neutral: "#808080" as HexColor, + primary: "#3b7dd8" as HexColor, + success: "#3d9a57" as HexColor, + warning: "#d68c27" as HexColor, + error: "#d13438" as HexColor, + info: "#0092a8" as HexColor, + interactive: "#3b7dd8" as HexColor, + diffAdd: "#3d9a57" as HexColor, + diffDelete: "#d13438" as HexColor, + }, +} + +const palette: ThemeVariant = { + palette: { + neutral: "#808080" as HexColor, + ink: "#1a1a1a" as HexColor, + primary: "#3b7dd8" as HexColor, + success: "#3d9a57" as HexColor, + warning: "#d68c27" as HexColor, + error: "#d13438" as HexColor, + info: "#0092a8" as HexColor, + }, +} + +describe("relativeLuminance", () => { + test("white is brighter than black", () => { + expect(relativeLuminance("#ffffff" as HexColor)).toBeGreaterThan(relativeLuminance("#000000" as HexColor)) + }) + + test("stays within the 0..1 range", () => { + const value = relativeLuminance("#3b7dd8" as HexColor) + expect(value).toBeGreaterThanOrEqual(0) + expect(value).toBeLessThanOrEqual(1) + }) +}) + +describe("contrastRatio", () => { + test("black on white is the maximum 21:1", () => { + expect(contrastRatio("#000000" as HexColor, "#ffffff" as HexColor)).toBeCloseTo(21, 1) + }) + + test("a color against itself is 1:1", () => { + expect(contrastRatio("#3b7dd8" as HexColor, "#3b7dd8" as HexColor)).toBeCloseTo(1, 5) + }) + + test("argument order does not change the ratio", () => { + const a = "#000000" as HexColor + const b = "#ffffff" as HexColor + expect(contrastRatio(a, b)).toBeCloseTo(contrastRatio(b, a), 10) + }) +}) + +describe("readableTextOn", () => { + test("picks black text on a light fill", () => { + expect(readableTextOn("#ffffff" as HexColor)).toBe("#000000") + }) + + test("picks white text on a dark fill", () => { + expect(readableTextOn("#000000" as HexColor)).toBe("#ffffff") + }) +}) + +describe("hueDistance", () => { + test("identical hues are zero apart", () => { + expect(hueDistance(120, 120)).toBe(0) + }) + + test("wraps around the 360 degree boundary", () => { + expect(hueDistance(350, 10)).toBeCloseTo(20, 5) + }) + + test("opposite hues are 180 apart", () => { + expect(hueDistance(0, 180)).toBeCloseTo(180, 5) + }) + + test("is symmetric", () => { + expect(hueDistance(30, 200)).toBeCloseTo(hueDistance(200, 30), 5) + }) +}) + +describe("contentTone", () => { + test("returns a hex color", () => { + const scale = Array(12).fill("#3d9a57") as HexColor[] + expect(contentTone("#3d9a57" as HexColor, scale, false)).toMatch(/^#/) + }) + + test("light and dark modes produce different tones", () => { + const scale = Array(12).fill("#3d9a57") as HexColor[] + const light = contentTone("#3d9a57" as HexColor, scale, false) + const dark = contentTone("#3d9a57" as HexColor, scale, true) + expect(light).not.toBe(dark) + }) +}) + +describe("modifiedDiffTone", () => { + const scale = Array(12).fill("#d68c27") as HexColor[] + + test("non-compact themes use the fixed light fallback", () => { + const colors = { compact: false, warning: "#d68c27", error: "#d13438" } as never + expect(modifiedDiffTone(colors, scale, false)).toBe("#FF8C00") + }) + + test("non-compact themes use the fixed dark fallback", () => { + const colors = { compact: false, warning: "#d68c27", error: "#d13438" } as never + expect(modifiedDiffTone(colors, scale, true)).toBe("#ffba92") + }) + + test("compact themes fall back when warning and delete hues are close", () => { + const colors = { compact: true, warning: "#d13438", error: "#d13438" } as never + expect(modifiedDiffTone(colors, scale, false)).toBe("#FF8C00") + }) +}) + +describe("resolveThemeVariant", () => { + test("resolves a seeds-based variant in light mode", () => { + const tokens = resolveThemeVariant(seeds, false) + expect(tokens["background-base"]).toBeDefined() + expect(tokens["text-base"]).toBeDefined() + expect(tokens["text-stronger"]).toBe(tokens["text-strong"]) + }) + + test("resolves a seeds-based variant in dark mode", () => { + const tokens = resolveThemeVariant(seeds, true) + expect(tokens["background-base"]).toBeDefined() + expect(Object.keys(tokens).length).toBeGreaterThan(100) + }) + + test("light and dark variants differ", () => { + expect(resolveThemeVariant(seeds, false)["background-base"]).not.toBe( + resolveThemeVariant(seeds, true)["background-base"], + ) + }) + + test("resolves a palette-based variant", () => { + const tokens = resolveThemeVariant(palette, false) + expect(tokens["markdown-text"]).toBe(tokens["text-base"]) + }) + + test("text tokens on brand surfaces are readable black or white", () => { + const tokens = resolveThemeVariant(seeds, false) + expect(["#000000", "#ffffff"]).toContain(tokens["text-on-brand-base"]) + }) + + test("overrides replace generated tokens", () => { + const tokens = resolveThemeVariant({ ...seeds, overrides: { "background-base": "#123456" } }, false) + expect(tokens["background-base"]).toBe("#123456") + }) +}) \ No newline at end of file diff --git a/packages/ui/src/theme/resolve.ts b/packages/ui/src/theme/resolve.ts index 2cf6711e..12fe84cb 100644 --- a/packages/ui/src/theme/resolve.ts +++ b/packages/ui/src/theme/resolve.ts @@ -1,6 +1,45 @@ import type { ColorValue, DesktopTheme, HexColor, ResolvedTheme, ThemeVariant } from "./types" import { blend, generateNeutralScale, generateScale, hexToOklch, hexToRgb, shift, withAlpha } from "./color" +function channelLift(v: number): number { + return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4) +} + +export function relativeLuminance(hex: HexColor): number { + const rgb = hexToRgb(hex) + return 0.2126 * channelLift(rgb.r) + 0.7152 * channelLift(rgb.g) + 0.0722 * channelLift(rgb.b) +} + +export function contrastRatio(a: HexColor, b: HexColor): number { + const x = relativeLuminance(a) + const y = relativeLuminance(b) + return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05) +} + +export function readableTextOn(fill: HexColor): HexColor { + const light = "#ffffff" as HexColor + const dark = "#000000" as HexColor + return contrastRatio(light, fill) > contrastRatio(dark, fill) ? light : dark +} + +export function contentTone(seed: HexColor, scale: HexColor[], isDark: boolean): HexColor { + const base = hexToOklch(seed) + const value = isDark && base.l > 0.84 ? shift(seed, { c: 1.18 }) : scale[10] + return shift(value, { l: isDark ? 0.034 : -0.024, c: isDark ? 1.3 : 1.18 }) +} + +export function hueDistance(a: number, b: number): number { + return Math.abs(((((b - a) % 360) + 540) % 360) - 180) +} + +export function modifiedDiffTone(colors: ThemeColors, warningScale: HexColor[], isDark: boolean): HexColor { + const fallback = (isDark ? "#ffba92" : "#FF8C00") as HexColor + if (!colors.compact) return fallback + const delta = hueDistance(hexToOklch(colors.warning).h, hexToOklch(colors.diffDelete ?? colors.error).h) + if (delta < 48) return fallback + return contentTone(colors.warning, warningScale, isDark) +} + export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): ResolvedTheme { const colors = getColors(variant) const { overrides = {} } = variant @@ -37,19 +76,9 @@ export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): Res const backgroundOverride = overrides["background-base"] const backgroundHex = getHex(backgroundOverride) const overlay = Boolean(backgroundOverride) && !backgroundHex - const content = (seed: HexColor, scale: HexColor[]) => { - const base = hexToOklch(seed) - const value = isDark ? (base.l > 0.84 ? shift(seed, { c: 1.18 }) : scale[10]) : scale[10] - return shift(value, { l: isDark ? 0.034 : -0.024, c: isDark ? 1.3 : 1.18 }) - } - const modified = () => { - if (!colors.compact) return isDark ? "#ffba92" : "#FF8C00" - const warningHue = hexToOklch(colors.warning).h - const deleteHue = hexToOklch(colors.diffDelete ?? colors.error).h - const delta = Math.abs(((((deleteHue - warningHue) % 360) + 540) % 360) - 180) - if (delta < 48) return isDark ? "#ffba92" : "#FF8C00" - return content(colors.warning, warning) - } + const content = (seed: HexColor, scale: HexColor[]) => contentTone(seed, scale, isDark) + const modified = () => modifiedDiffTone(colors, warning, isDark) + const surface = ( seed: HexColor, alpha: { base: number; weak: number; weaker: number; strong: number; stronger: number }, @@ -93,24 +122,7 @@ export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): Res const infob = info[isDark ? 6 : 4] const infow = info[isDark ? 5 : 3] const infos = info[10] - const lum = (hex: HexColor) => { - const rgb = hexToRgb(hex) - const lift = (v: number) => (v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4)) - return 0.2126 * lift(rgb.r) + 0.7152 * lift(rgb.g) + 0.0722 * lift(rgb.b) - } - const hit = (a: HexColor, b: HexColor) => { - const x = lum(a) - const y = lum(b) - const light = Math.max(x, y) - const dark = Math.min(x, y) - return (light + 0.05) / (dark + 0.05) - } - const on = (fill: HexColor) => { - const light = "#ffffff" as HexColor - const dark = "#000000" as HexColor - return hit(light, fill) > hit(dark, fill) ? light : dark - } - + const on = readableTextOn const tokens: ResolvedTheme = {} tokens["background-base"] = neutral[0]