wip: colors
parent
8427f890e6
commit
6eec0c0104
|
|
@ -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[] {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue