wip: colors

pull/19330/head
Adam 2026-03-27 15:08:53 -05:00
parent 8427f890e6
commit 6eec0c0104
No known key found for this signature in database
GPG Key ID: 9CB48779AF150E75
3 changed files with 188 additions and 69 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 {
const base = {
l: clamp(oklch.l, 0, 1),
@ -149,35 +141,33 @@ export function oklchToHex(oklch: OklchColor): HexColor {
export function generateScale(seed: HexColor, isDark: boolean): HexColor[] {
const base = hexToOklch(seed)
const tint = 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]
const shade = isDark ? [0, 0.115, 0.524, 0.871] : [0, 0.124, 0.514, 0.83]
const stop = isDark
? [
0.118,
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
? [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.24, 0.32, 0.42, 0.56, 0.72, 0.88, 1.04, 1.14, 1, 0.94, 0.82, 0.64]
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,
})
? [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.12, 0.24, 0.46, 0.68, 0.84, 0.98, 1.08, 1.16, 1.22, 1.26, 1.18, 0.98]
return [
...tint.map((step, i) => oklchToHex(paint(base, mix(bg, mid, step), curve[i]!, isDark ? 0.32 : 0.28))),
...shade.map((step, i) =>
oklchToHex(paint(base, mix(mid, fg, step), curve[i + tint.length]!, isDark ? 0.32 : 0.28)),
),
]
return stop.map((l, i) =>
oklchToHex({
l,
c: base.c * curve[i]!,
h: base.h,
}),
)
}
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"])
})
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", () => {
const seed = "#3b7dd8" as HexColor
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 bg = bgHex ?? neutral[0]
const alpha = generateNeutralAlphaScale(neutral, isDark)
const soft = isDark ? 6 : 3
const base = isDark ? 7 : 4
const fill = isDark ? 8 : 5
const rise = isDark ? 8 : 6
const prose = isDark ? 10 : 9
const soft = isDark ? 5 : 3
const tone = isDark ? 6 : 5
const rise = isDark ? 7 : 5
const prose = isDark ? 8 : 9
const fade = (color: HexColor, value: number) =>
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 = (
seed: HexColor,
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 brandHover = primary[9]
const inter = interactive[base]
const interHover = interactive[isDark ? 7 : 5]
const interWeak = interactive[soft]
const interText = isDark ? shift(interactive[8], { l: 0.012, c: 1.08 }) : text(interactive)
const inter = interactive[tone]
const interHover = interactive[isDark ? 7 : 6]
const interWeak = interactive[isDark ? 3 : soft]
const tones = {
success,
warning,
@ -139,11 +139,11 @@ export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): Res
"surface-diff-delete-stronger": diffDelete[isDark ? 10 : 8],
"input-base": isDark ? neutral[1] : neutral[0],
"input-hover": isDark ? neutral[2] : neutral[1],
"input-active": isDark ? interactive[base] : interactive[0],
"input-selected": isDark ? interactive[fill] : interactive[3],
"input-focus": isDark ? interactive[base] : interactive[0],
"input-active": isDark ? interactive[tone] : interactive[0],
"input-selected": isDark ? interactive[7] : interactive[3],
"input-focus": isDark ? interactive[tone] : interactive[0],
"input-disabled": neutral[3],
"text-base": neutral[10],
"text-base": isDark ? neutral[8] : neutral[10],
"text-weak": neutral[7],
"text-weaker": neutral[6],
"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-weaker": isDark ? neutral[7] : neutral[3],
"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-weak": 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-focus": interactive[8],
"border-color": neutral[0],
"icon-base": neutral[isDark ? 9 : 8],
"icon-hover": neutral[10],
"icon-active": neutral[11],
"icon-selected": neutral[11],
"icon-disabled": neutral[isDark ? 6 : 7],
"icon-focus": neutral[11],
"icon-base": neutral[isDark ? 7 : 8],
"icon-hover": neutral[isDark ? 8 : 10],
"icon-active": neutral[isDark ? 9 : 11],
"icon-selected": neutral[isDark ? 10 : 11],
"icon-disabled": neutral[isDark ? 5 : 6],
"icon-focus": neutral[isDark ? 10 : 11],
"icon-invert-base": isDark ? neutral[0] : "#ffffff",
"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-selected": neutral[isDark ? 8 : 9],
"icon-weak-disabled": neutral[isDark ? 3 : 5],
"icon-weak-focus": neutral[8],
"icon-strong-base": neutral[11],
"icon-strong-hover": neutral[11],
"icon-strong-active": neutral[11],
"icon-strong-selected": neutral[11],
"icon-strong-base": neutral[isDark ? 10 : 11],
"icon-strong-hover": neutral[isDark ? 10 : 11],
"icon-strong-active": neutral[isDark ? 10 : 11],
"icon-strong-selected": neutral[isDark ? 10 : 11],
"icon-strong-disabled": neutral[7],
"icon-strong-focus": neutral[11],
"icon-strong-focus": neutral[isDark ? 10 : 11],
"icon-brand-base": on(brand),
"icon-interactive-base": interactive[rise],
"icon-on-brand-base": on(brand),
"icon-on-brand-hover": on(brandHover),
"icon-on-brand-selected": on(brandHover),
"icon-on-interactive-base": on(inter),
"icon-agent-plan-base": info[8],
"icon-agent-docs-base": warning[8],
"icon-agent-ask-base": interactive[8],
"icon-agent-build-base": interactive[10],
"icon-agent-plan-base": info[rise],
"icon-agent-docs-base": warning[rise],
"icon-agent-ask-base": interactive[rise],
"icon-agent-build-base": interactive[isDark ? 8 : 6],
"icon-diff-add-base": diffAdd[10],
"icon-diff-add-hover": diffAdd[11],
"icon-diff-add-active": diffAdd[11],
"icon-diff-delete-base": diffDelete[10],
"icon-diff-delete-hover": diffDelete[11],
"icon-diff-delete-base": diffDelete[isDark ? 9 : 10],
"icon-diff-delete-hover": diffDelete[isDark ? 10 : 11],
"icon-diff-modified-base": warning[10],
"syntax-comment": "var(--text-weak)",
"syntax-regexp": text(primary),
@ -264,10 +264,10 @@ export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): Res
}
for (const [name, scale] of Object.entries(tones)) {
const fillColor = scale[fill]
const fillColor = scale[tone]
const weakColor = scale[soft]
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}-weak`] = weakColor