Adam 3 settimane fa
parent
commit
6eec0c0104

+ 26 - 36
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,
-  })
-
-  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)),
-    ),
-  ]
+    ? [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 stop.map((l, i) =>
+    oklchToHex({
+      l,
+      c: base.c * curve[i]!,
+      h: base.h,
+    }),
+  )
 }
 
 export function generateNeutralScale(seed: HexColor, isDark: boolean): HexColor[] {

+ 129 - 0
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)

+ 34 - 34
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