wip: colors

This commit is contained in:
Adam 2026-03-27 15:08:53 -05:00
commit fa40b7fa6f
3 changed files with 189 additions and 70 deletions

View file

@ -109,14 +109,6 @@ function mix(a: OklchColor, b: OklchColor, t: number): OklchColor {
} }
} }
function paint(base: OklchColor, tone: OklchColor, c: number, max: number): OklchColor {
return fitOklch({
l: tone.l,
c: Math.min(max, Math.max(tone.c, base.c * c)),
h: base.h,
})
}
export function fitOklch(oklch: OklchColor): OklchColor { export function fitOklch(oklch: OklchColor): OklchColor {
const base = { const base = {
l: clamp(oklch.l, 0, 1), l: clamp(oklch.l, 0, 1),
@ -149,35 +141,33 @@ export function oklchToHex(oklch: OklchColor): HexColor {
export function generateScale(seed: HexColor, isDark: boolean): HexColor[] { export function generateScale(seed: HexColor, isDark: boolean): HexColor[] {
const base = hexToOklch(seed) const base = hexToOklch(seed)
const tint = isDark const stop = isDark
? [0.029, 0.064, 0.11, 0.174, 0.263, 0.382, 0.542, 0.746] ? [
: [0.018, 0.042, 0.082, 0.146, 0.238, 0.368, 0.542, 0.764] 0.118,
const shade = isDark ? [0, 0.115, 0.524, 0.871] : [0, 0.124, 0.514, 0.83] 0.138,
0.167,
0.202,
0.246,
0.304,
0.378,
0.468,
clamp(base.l * 0.825, 0.53, 0.705),
clamp(base.l * 0.89, 0.61, 0.79),
clamp(base.l + 0.033, 0.868, 0.943),
0.984,
]
: [0.993, 0.983, 0.962, 0.936, 0.906, 0.866, 0.811, 0.74, base.l, Math.max(0, base.l - 0.036), 0.49, 0.27]
const curve = isDark const curve = isDark
? [0.48, 0.58, 0.69, 0.82, 0.94, 1.05, 1.16, 1.23, 1.04, 0.97, 0.82, 0.6] ? [0.52, 0.68, 0.86, 1.02, 1.14, 1.24, 1.36, 1.48, 1.56, 1.64, 1.62, 1.15]
: [0.24, 0.32, 0.42, 0.56, 0.72, 0.88, 1.04, 1.14, 1, 0.94, 0.82, 0.64] : [0.12, 0.24, 0.46, 0.68, 0.84, 0.98, 1.08, 1.16, 1.22, 1.26, 1.18, 0.98]
const mid = fitOklch({
l: clamp(base.l + (isDark ? 0.009 : 0), isDark ? 0.61 : 0.5, isDark ? 0.75 : 0.68),
c: clamp(base.c * (isDark ? 1.04 : 1), 0, isDark ? 0.29 : 0.26),
h: base.h,
})
const bg = fitOklch({
l: isDark ? clamp(0.13 + base.c * 0.065, 0.11, 0.175) : clamp(0.995 - base.c * 0.1, 0.962, 0.995),
c: Math.min(base.c * (isDark ? 0.38 : 0.18), isDark ? 0.07 : 0.03),
h: base.h,
})
const fg = fitOklch({
l: isDark ? 0.952 : 0.24,
c: Math.min(mid.c * (isDark ? 0.55 : 0.72), isDark ? 0.13 : 0.14),
h: base.h,
})
return [ return stop.map((l, i) =>
...tint.map((step, i) => oklchToHex(paint(base, mix(bg, mid, step), curve[i]!, isDark ? 0.32 : 0.28))), oklchToHex({
...shade.map((step, i) => l,
oklchToHex(paint(base, mix(mid, fg, step), curve[i + tint.length]!, isDark ? 0.32 : 0.28)), c: base.c * curve[i]!,
), h: base.h,
] }),
)
} }
export function generateNeutralScale(seed: HexColor, isDark: boolean): HexColor[] { export function generateNeutralScale(seed: HexColor, isDark: boolean): HexColor[] {

View file

@ -47,6 +47,135 @@ describe("theme resolve", () => {
expect(tokens["text-stronger"]).toBe(tokens["text-strong"]) expect(tokens["text-stronger"]).toBe(tokens["text-strong"])
}) })
test("keeps dark body text separated from strong text", () => {
const tokens = resolveThemeVariant(
{
seeds: {
neutral: "#1f1f1f",
primary: "#fab283",
success: "#12c905",
warning: "#fcd53a",
error: "#fc533a",
info: "#edb2f1",
interactive: "#034cff",
},
},
true,
)
const base = hexToOklch(tokens["text-base"] as HexColor).l
const strong = hexToOklch(tokens["text-strong"] as HexColor).l
expect(strong - base).toBeGreaterThan(0.18)
})
test("keeps dark icons weaker than body text", () => {
const tokens = resolveThemeVariant(
{
seeds: {
neutral: "#1f1f1f",
primary: "#fab283",
success: "#12c905",
warning: "#fcd53a",
error: "#fc533a",
info: "#edb2f1",
interactive: "#034cff",
},
},
true,
)
const icon = hexToOklch(tokens["icon-base"] as HexColor).l
const text = hexToOklch(tokens["text-base"] as HexColor).l
expect(text - icon).toBeGreaterThan(0.08)
})
test("keeps base icons distinct from disabled icons", () => {
const light = resolveThemeVariant(
{
seeds: {
neutral: "#f7f7f7",
primary: "#dcde8d",
success: "#12c905",
warning: "#ffdc17",
error: "#fc533a",
info: "#a753ae",
interactive: "#034cff",
},
},
false,
)
const dark = resolveThemeVariant(
{
seeds: {
neutral: "#1f1f1f",
primary: "#fab283",
success: "#12c905",
warning: "#fcd53a",
error: "#fc533a",
info: "#edb2f1",
interactive: "#034cff",
},
},
true,
)
const lightBase = hexToOklch(light["icon-base"] as HexColor).l
const lightDisabled = hexToOklch(light["icon-disabled"] as HexColor).l
const darkBase = hexToOklch(dark["icon-base"] as HexColor).l
const darkDisabled = hexToOklch(dark["icon-disabled"] as HexColor).l
expect(lightDisabled - lightBase).toBeGreaterThan(0.12)
expect(darkBase - darkDisabled).toBeGreaterThan(0.12)
})
test("uses tuned interactive and success token steps", () => {
const light: ThemeVariant = {
seeds: {
neutral: "#f7f7f7",
primary: "#dcde8d",
success: "#12c905",
warning: "#ffdc17",
error: "#fc533a",
info: "#a753ae",
interactive: "#034cff",
diffDelete: "#fc533a",
},
}
const dark: ThemeVariant = {
seeds: {
neutral: "#1f1f1f",
primary: "#fab283",
success: "#12c905",
warning: "#fcd53a",
error: "#fc533a",
info: "#edb2f1",
interactive: "#034cff",
diffDelete: "#fc533a",
},
}
const lightTokens = resolveThemeVariant(light, false)
const darkTokens = resolveThemeVariant(dark, true)
const lightNeutral = generateNeutralScale(light.seeds.neutral, false)
const darkNeutral = generateNeutralScale(dark.seeds.neutral, true)
const lightSuccess = generateScale(light.seeds.success, false)
const darkSuccess = generateScale(dark.seeds.success, true)
const darkInteractive = generateScale(dark.seeds.interactive!, true)
const darkDelete = generateScale(dark.seeds.error, true)
expect(lightTokens["icon-success-base"]).toBe(lightSuccess[6])
expect(darkTokens["icon-success-base"]).toBe(darkSuccess[8])
expect(darkTokens["surface-interactive-weak"]).toBe(darkInteractive[3])
expect(lightTokens["icon-base"]).toBe(lightNeutral[8])
expect(lightTokens["icon-disabled"]).toBe(lightNeutral[6])
expect(darkTokens["icon-base"]).toBe(darkNeutral[7])
expect(darkTokens["icon-disabled"]).toBe(darkNeutral[5])
expect(darkTokens["icon-diff-delete-base"]).toBe(darkDelete[9])
expect(darkTokens["icon-diff-delete-hover"]).toBe(darkDelete[10])
})
test("keeps accent scales centered on step 9", () => { test("keeps accent scales centered on step 9", () => {
const seed = "#3b7dd8" as HexColor const seed = "#3b7dd8" as HexColor
const light = generateScale(seed, false) const light = generateScale(seed, false)

View file

@ -27,14 +27,13 @@ export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): Res
const overlay = Boolean(bgValue) && !bgHex const overlay = Boolean(bgValue) && !bgHex
const bg = bgHex ?? neutral[0] const bg = bgHex ?? neutral[0]
const alpha = generateNeutralAlphaScale(neutral, isDark) const alpha = generateNeutralAlphaScale(neutral, isDark)
const soft = isDark ? 6 : 3 const soft = isDark ? 5 : 3
const base = isDark ? 7 : 4 const tone = isDark ? 6 : 5
const fill = isDark ? 8 : 5 const rise = isDark ? 7 : 5
const rise = isDark ? 8 : 6 const prose = isDark ? 8 : 9
const prose = isDark ? 10 : 9
const fade = (color: HexColor, value: number) => const fade = (color: HexColor, value: number) =>
overlay ? (withAlpha(color, value) as ColorValue) : blend(color, bg, value) overlay ? (withAlpha(color, value) as ColorValue) : blend(color, bg, value)
const text = (scale: HexColor[]) => shift(scale[prose], { l: isDark ? 0.014 : -0.024, c: isDark ? 1.16 : 1.14 }) const text = (scale: HexColor[]) => shift(scale[prose], { l: isDark ? 0.006 : -0.024, c: isDark ? 1.08 : 1.14 })
const wash = ( const wash = (
seed: HexColor, seed: HexColor,
value: { base: number; weak: number; weaker: number; strong: number; stronger: number }, value: { base: number; weak: number; weaker: number; strong: number; stronger: number },
@ -68,9 +67,10 @@ export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): Res
) )
const brand = primary[8] const brand = primary[8]
const brandHover = primary[9] const brandHover = primary[9]
const inter = interactive[base] const interText = isDark ? shift(interactive[8], { l: 0.012, c: 1.08 }) : text(interactive)
const interHover = interactive[isDark ? 7 : 5] const inter = interactive[tone]
const interWeak = interactive[soft] const interHover = interactive[isDark ? 7 : 6]
const interWeak = interactive[isDark ? 3 : soft]
const tones = { const tones = {
success, success,
warning, warning,
@ -139,11 +139,11 @@ export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): Res
"surface-diff-delete-stronger": diffDelete[isDark ? 10 : 8], "surface-diff-delete-stronger": diffDelete[isDark ? 10 : 8],
"input-base": isDark ? neutral[1] : neutral[0], "input-base": isDark ? neutral[1] : neutral[0],
"input-hover": isDark ? neutral[2] : neutral[1], "input-hover": isDark ? neutral[2] : neutral[1],
"input-active": isDark ? interactive[base] : interactive[0], "input-active": isDark ? interactive[tone] : interactive[0],
"input-selected": isDark ? interactive[fill] : interactive[3], "input-selected": isDark ? interactive[7] : interactive[3],
"input-focus": isDark ? interactive[base] : interactive[0], "input-focus": isDark ? interactive[tone] : interactive[0],
"input-disabled": neutral[3], "input-disabled": neutral[3],
"text-base": neutral[10], "text-base": isDark ? neutral[8] : neutral[10],
"text-weak": neutral[7], "text-weak": neutral[7],
"text-weaker": neutral[6], "text-weaker": neutral[6],
"text-strong": neutral[11], "text-strong": neutral[11],
@ -151,7 +151,7 @@ export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): Res
"text-invert-weak": isDark ? neutral[8] : neutral[2], "text-invert-weak": isDark ? neutral[8] : neutral[2],
"text-invert-weaker": isDark ? neutral[7] : neutral[3], "text-invert-weaker": isDark ? neutral[7] : neutral[3],
"text-invert-strong": isDark ? neutral[11] : neutral[0], "text-invert-strong": isDark ? neutral[11] : neutral[0],
"text-interactive-base": text(interactive), "text-interactive-base": interText,
"text-on-brand-base": on(brand), "text-on-brand-base": on(brand),
"text-on-brand-weak": on(brand), "text-on-brand-weak": on(brand),
"text-on-brand-weaker": on(brand), "text-on-brand-weaker": on(brand),
@ -193,40 +193,40 @@ export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): Res
"border-interactive-disabled": neutral[7], "border-interactive-disabled": neutral[7],
"border-interactive-focus": interactive[8], "border-interactive-focus": interactive[8],
"border-color": neutral[0], "border-color": neutral[0],
"icon-base": neutral[isDark ? 9 : 8], "icon-base": neutral[isDark ? 7 : 8],
"icon-hover": neutral[10], "icon-hover": neutral[isDark ? 8 : 10],
"icon-active": neutral[11], "icon-active": neutral[isDark ? 9 : 11],
"icon-selected": neutral[11], "icon-selected": neutral[isDark ? 10 : 11],
"icon-disabled": neutral[isDark ? 6 : 7], "icon-disabled": neutral[isDark ? 5 : 6],
"icon-focus": neutral[11], "icon-focus": neutral[isDark ? 10 : 11],
"icon-invert-base": isDark ? neutral[0] : "#ffffff", "icon-invert-base": isDark ? neutral[0] : "#ffffff",
"icon-weak-base": neutral[isDark ? 5 : 6], "icon-weak-base": neutral[isDark ? 5 : 6],
"icon-weak-hover": neutral[isDark ? 11 : 7], "icon-weak-hover": neutral[isDark ? 10 : 7],
"icon-weak-active": neutral[8], "icon-weak-active": neutral[8],
"icon-weak-selected": neutral[isDark ? 8 : 9], "icon-weak-selected": neutral[isDark ? 8 : 9],
"icon-weak-disabled": neutral[isDark ? 3 : 5], "icon-weak-disabled": neutral[isDark ? 3 : 5],
"icon-weak-focus": neutral[8], "icon-weak-focus": neutral[8],
"icon-strong-base": neutral[11], "icon-strong-base": neutral[isDark ? 10 : 11],
"icon-strong-hover": neutral[11], "icon-strong-hover": neutral[isDark ? 10 : 11],
"icon-strong-active": neutral[11], "icon-strong-active": neutral[isDark ? 10 : 11],
"icon-strong-selected": neutral[11], "icon-strong-selected": neutral[isDark ? 10 : 11],
"icon-strong-disabled": neutral[7], "icon-strong-disabled": neutral[7],
"icon-strong-focus": neutral[11], "icon-strong-focus": neutral[isDark ? 10 : 11],
"icon-brand-base": on(brand), "icon-brand-base": on(brand),
"icon-interactive-base": interactive[rise], "icon-interactive-base": interactive[rise],
"icon-on-brand-base": on(brand), "icon-on-brand-base": on(brand),
"icon-on-brand-hover": on(brandHover), "icon-on-brand-hover": on(brandHover),
"icon-on-brand-selected": on(brandHover), "icon-on-brand-selected": on(brandHover),
"icon-on-interactive-base": on(inter), "icon-on-interactive-base": on(inter),
"icon-agent-plan-base": info[8], "icon-agent-plan-base": info[rise],
"icon-agent-docs-base": warning[8], "icon-agent-docs-base": warning[rise],
"icon-agent-ask-base": interactive[8], "icon-agent-ask-base": interactive[rise],
"icon-agent-build-base": interactive[10], "icon-agent-build-base": interactive[isDark ? 8 : 6],
"icon-diff-add-base": diffAdd[10], "icon-diff-add-base": diffAdd[10],
"icon-diff-add-hover": diffAdd[11], "icon-diff-add-hover": diffAdd[11],
"icon-diff-add-active": diffAdd[11], "icon-diff-add-active": diffAdd[11],
"icon-diff-delete-base": diffDelete[10], "icon-diff-delete-base": diffDelete[isDark ? 9 : 10],
"icon-diff-delete-hover": diffDelete[11], "icon-diff-delete-hover": diffDelete[isDark ? 10 : 11],
"icon-diff-modified-base": warning[10], "icon-diff-modified-base": warning[10],
"syntax-comment": "var(--text-weak)", "syntax-comment": "var(--text-weak)",
"syntax-regexp": text(primary), "syntax-regexp": text(primary),
@ -264,10 +264,10 @@ export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): Res
} }
for (const [name, scale] of Object.entries(tones)) { for (const [name, scale] of Object.entries(tones)) {
const fillColor = scale[fill] const fillColor = scale[tone]
const weakColor = scale[soft] const weakColor = scale[soft]
const strongColor = scale[10] const strongColor = scale[10]
const iconColor = scale[rise] const iconColor = name === "success" ? scale[isDark ? 8 : 6] : scale[rise]
tokens[`surface-${name}-base`] = fillColor tokens[`surface-${name}-base`] = fillColor
tokens[`surface-${name}-weak`] = weakColor tokens[`surface-${name}-weak`] = weakColor