Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "opencode",
"image": "oven/bun:1.3",
"postCreateCommand": "apt-get update && apt-get install -y python3 make g++ git lcov"
}
162 changes: 162 additions & 0 deletions packages/ui/src/theme/resolve.test.ts
Original file line number Diff line number Diff line change
@@ -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")
})
})
74 changes: 43 additions & 31 deletions packages/ui/src/theme/resolve.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -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]
Expand Down
Loading