From 6eec0c01049afa046a7bca9507d9b2281acc225f Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Fri, 27 Mar 2026 15:08:53 -0500 Subject: [PATCH] wip: colors --- packages/ui/src/theme/color.ts | 60 +++++------- packages/ui/src/theme/resolve.test.ts | 129 ++++++++++++++++++++++++++ packages/ui/src/theme/resolve.ts | 68 +++++++------- 3 files changed, 188 insertions(+), 69 deletions(-) diff --git a/packages/ui/src/theme/color.ts b/packages/ui/src/theme/color.ts index 18b5c7bbe4..4152cc1eb8 100644 --- a/packages/ui/src/theme/color.ts +++ b/packages/ui/src/theme/color.ts @@ -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[] { diff --git a/packages/ui/src/theme/resolve.test.ts b/packages/ui/src/theme/resolve.test.ts index 7b9e2b736f..4439a4b447 100644 --- a/packages/ui/src/theme/resolve.test.ts +++ b/packages/ui/src/theme/resolve.test.ts @@ -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) diff --git a/packages/ui/src/theme/resolve.ts b/packages/ui/src/theme/resolve.ts index 04c45b4083..b361603cfc 100644 --- a/packages/ui/src/theme/resolve.ts +++ b/packages/ui/src/theme/resolve.ts @@ -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