diff --git a/packages/ui/src/theme/resolve.test.ts b/packages/ui/src/theme/resolve.test.ts new file mode 100644 index 00000000..c037b537 --- /dev/null +++ b/packages/ui/src/theme/resolve.test.ts @@ -0,0 +1,765 @@ +import { describe, expect, test } from "bun:test" +import { + addCompactTokenProperties, + applyOverridePostProcessing, + buildAvatarTokens, + buildIconTokens, + buildScales, + buildTextTokens, + buildTokenContext, + computeCompactBody, + computeToneContext, + createToken, + getColors, + surface, + type ThemeColors, + type ToneContext, +} from "./resolve" +import type { ColorValue, ResolvedTheme, ThemeVariant } from "./types" + +describe("getColors", () => { + test("resolves a palette variant into compact ThemeColors with fallbacks", () => { + const variant: ThemeVariant = { + palette: { + neutral: "#808080", + ink: "#202020", + primary: "#3b82f6", + success: "#22c55e", + warning: "#f59e0b", + error: "#ef4444", + info: "#0ea5e9", + }, + } + + const colors = getColors(variant) + + expect(colors.compact).toBe(true) + expect(colors.neutral).toBe("#808080") + expect(colors.ink).toBe("#202020") + expect(colors.accent).toBe("#0ea5e9") // falls back to palette.info + expect(colors.interactive).toBe("#3b82f6") // falls back to palette.primary + }) + + test("resolves a seeds variant into non-compact ThemeColors", () => { + const variant: ThemeVariant = { + seeds: { + neutral: "#808080", + primary: "#3b82f6", + success: "#22c55e", + warning: "#f59e0b", + error: "#ef4444", + info: "#0ea5e9", + interactive: "#6366f1", + diffAdd: "#16a34a", + diffDelete: "#dc2626", + }, + } + + const colors = getColors(variant) + + expect(colors.compact).toBe(false) + expect(colors.ink).toBeUndefined() + expect(colors.accent).toBe("#0ea5e9") // seeds always map accent from info + expect(colors.interactive).toBe("#6366f1") + expect(colors.diffAdd).toBe("#16a34a") + }) +}) + +const ink = "#202020" as const +const sampleColors: ThemeColors = { + compact: true, + neutral: "#808080", + ink, + primary: "#3b82f6", + accent: "#8b5cf6", + success: "#22c55e", + warning: "#f59e0b", + error: "#ef4444", + info: "#0ea5e9", + interactive: "#6366f1", + diffAdd: "#16a34a", + diffDelete: "#dc2626", +} + +describe("buildScales", () => { + test("generates dark-mode color scales", () => { + const scales = buildScales(sampleColors, true) + + expect(scales.neutral).toEqual([ + "#525252", "#515151", "#505050", "#4f4f4f", "#4d4d4d", "#4a4a4a", + "#474747", "#424242", "#3a3a3a", "#313131", "#272727", "#212121", + ]) + expect(scales.primary).toEqual([ + "#000417", "#00071f", "#010d29", "#021437", "#021d4c", "#062b65", + "#013b90", "#0752be", "#0061e4", "#2e7cfa", "#bcd5fe", "#fafafa", + ]) + expect(scales.success).toEqual([ + "#000802", "#000d03", "#011405", "#001d08", "#04280f", "#023a16", + "#065022", "#126c32", "#169847", "#22a851", "#36fb7c", "#effff1", + ]) + expect(scales.amber).toEqual([ + "#0d0300", "#130500", "#1c0900", "#270f01", "#361701", "#4a2303", + "#653208", "#874712", "#ba6113", "#cc6d1e", "#fdc7a4", "#fafafa", + ]) + expect(scales.diffAdd).toEqual([ + "#000802", "#010d03", "#001404", "#011d08", "#01290d", "#043a16", + "#0a5022", "#166c32", "#15813b", "#179d47", "#41fa7c", "#effff1", + ]) + }) + + test("generates light-mode color scales", () => { + const scales = buildScales(sampleColors, false) + + expect(scales.neutral).toEqual([ + "#ececec", "#e7e7e7", "#e2e2e2", "#dcdcdc", "#d5d5d5", "#cbcbcb", + "#bdbdbd", "#a9a9a9", "#8c8c8c", "#686868", "#414141", "#262626", + ]) + expect(scales.primary).toEqual([ + "#fbfdff", "#f6faff", "#ecf3ff", "#dfebfe", "#cfe1fe", "#bbd4fe", + "#a0c3fa", "#7aabfc", "#3c82f5", "#1e73f8", "#1459c4", "#00225a", + ]) + expect(scales.success).toEqual([ + "#f8fff9", "#effef1", "#d8fede", "#b7fec4", "#8bfea5", "#67f48d", + "#39e573", "#19cc5f", "#27c55f", "#2ab75a", "#077433", "#023011", + ]) + expect(scales.amber).toEqual([ + "#fffcfb", "#fff8f5", "#ffefe7", "#fee4d7", "#fdd7c4", "#fcc5aa", + "#fcab82", "#fb8745", "#eb7328", "#e16606", "#9a4301", "#421901", + ]) + expect(scales.diffAdd).toEqual([ + "#f8fff9", "#eefff0", "#d9fede", "#b9fec4", "#98faac", "#7af195", + "#58e17c", "#26cb60", "#1ba34b", "#1c9646", "#0f7433", "#033011", + ]) + }) +}) + +describe("computeCompactBody", () => { + test("shifts ink lighter for dark mode", () => { + const body = computeCompactBody(sampleColors, ink, true) + expect(body).toBe("#636363") + }) + + test("shifts ink darker for light mode", () => { + const body = computeCompactBody(sampleColors, ink, false) + expect(body).toBe("#1c1c1c") + }) +}) + +describe("computeToneContext", () => { + test("builds tone context for dark mode", () => { + const neutral = buildScales(sampleColors, true).neutral + const tone = computeToneContext(sampleColors, {}, neutral, ink, true) + + expect(tone).toEqual({ + ink: "#202020", + overlay: false, + background: "#525252", + isDark: true, + compact: true, + }) + }) + + test("builds tone context for light mode", () => { + const neutral = buildScales(sampleColors, false).neutral + const tone = computeToneContext(sampleColors, {}, neutral, ink, false) + + expect(tone).toEqual({ + ink: "#202020", + overlay: false, + background: "#ececec", + isDark: false, + compact: true, + }) + }) +}) + +describe("surface", () => { + test("computes alpha-blended surface tones for a dark tone context", () => { + const tone: ToneContext = { ink: "#202020", overlay: false, background: "#141414", isDark: true, compact: true } + const tones = surface(tone, "#6366f1", { base: 0.14, weak: 0.08, weaker: 0.18, strong: 0.26, stronger: 0.42 }) + + expect(tones).toEqual({ + base: "#1f1f33", + weak: "#1a1b26", + weaker: "#22233c", + strong: "#29294d", + stronger: "#353671", + }) + }) + + test("computes alpha-blended surface tones for a light tone context", () => { + const tone: ToneContext = { ink: "#202020", overlay: false, background: "#fefefe", isDark: false, compact: true } + const tones = surface(tone, "#6366f1", { base: 0.12, weak: 0.08, weaker: 0.16, strong: 0.24, stronger: 0.36 }) + + expect(tones).toEqual({ + base: "#ebecfc", + weak: "#f2f2fd", + weaker: "#e5e6fc", + strong: "#d9dafb", + stronger: "#c6c7f9", + }) + }) +}) + +describe("buildTextTokens", () => { + test("populates text tokens for dark mode", () => { + const ctx = buildTokenContext(sampleColors, {}, true) + const tokens: ResolvedTheme = {} + buildTextTokens(ctx, tokens) + + expect(tokens).toEqual({ + "text-base": "#636363", + "text-weak": "#454545", + "text-weaker": "#2e2e2e", + "text-strong": "#efefef", + "text-invert-base": "#272727", + "text-invert-weak": "#3a3a3a", + "text-invert-weaker": "#424242", + "text-invert-strong": "#212121", + "text-interactive-base": "#c9d1fc", + "text-on-brand-base": "#ffffff", + "text-on-interactive-base": "#ffffff", + "text-on-interactive-weak": "#ffffff", + "text-on-success-base": "#ffffff", + "text-on-critical-base": "#ffffff", + "text-on-critical-weak": "#ffffff", + "text-on-critical-strong": "#000000", + "text-on-warning-base": "#ffffff", + "text-on-info-base": "#ffffff", + "text-on-info-weak": "#ffffff", + "text-on-info-strong": "#000000", + "text-on-warning-weak": "#ffffff", + "text-on-warning-strong": "#000000", + "text-on-success-weak": "#ffffff", + "text-on-success-strong": "#000000", + "text-on-brand-weak": "#ffffff", + "text-on-brand-weaker": "#ffffff", + "text-on-brand-strong": "#000000", + "text-diff-add-base": "#41fa7c", + "text-diff-add-strong": "#166c32", + "text-diff-delete-base": "#ee2527", + "text-diff-delete-strong": "#fafafa", + }) + }) + + test("populates text tokens for light mode", () => { + const ctx = buildTokenContext(sampleColors, {}, false) + const tokens: ResolvedTheme = {} + buildTextTokens(ctx, tokens) + + expect(tokens).toEqual({ + "text-base": "#1c1c1c", + "text-weak": "#373737", + "text-weaker": "#515151", + "text-strong": "#0c0c0c", + "text-invert-base": "#e7e7e7", + "text-invert-weak": "#e2e2e2", + "text-invert-weaker": "#dcdcdc", + "text-invert-strong": "#ececec", + "text-interactive-base": "#5953f3", + "text-on-brand-base": "#000000", + "text-on-interactive-base": "#000000", + "text-on-interactive-weak": "#000000", + "text-on-success-base": "#000000", + "text-on-critical-base": "#000000", + "text-on-critical-weak": "#000000", + "text-on-critical-strong": "#ffffff", + "text-on-warning-base": "#000000", + "text-on-info-base": "#000000", + "text-on-info-weak": "#000000", + "text-on-info-strong": "#ffffff", + "text-on-warning-weak": "#000000", + "text-on-warning-strong": "#ffffff", + "text-on-success-weak": "#000000", + "text-on-success-strong": "#ffffff", + "text-on-brand-weak": "#000000", + "text-on-brand-weaker": "#000000", + "text-on-brand-strong": "#000000", + "text-diff-add-base": "#0f7433", + "text-diff-add-strong": "#033011", + "text-diff-delete-base": "#d10315", + "text-diff-delete-strong": "#4d0606", + }) + }) +}) + +describe("buildIconTokens", () => { + // icon-base/hover/active/selected/focus fall back to text-* tokens when compact && !isDark, + // so buildTextTokens is run first to seed those, matching how createToken orders builders. + test("populates icon tokens for dark mode", () => { + const ctx = buildTokenContext(sampleColors, {}, true) + const tokens: ResolvedTheme = {} + buildTextTokens(ctx, tokens) + buildIconTokens(ctx, tokens) + + expect(tokens).toMatchObject({ + "icon-base": "#313131", + "icon-hover": "#272727", + "icon-active": "#212121", + "icon-selected": "#212121", + "icon-disabled": "#474747", + "icon-focus": "#212121", + "icon-invert-base": "#525252", + "icon-weak-base": "#4a4a4a", + "icon-weak-hover": "#212121", + "icon-weak-active": "#3a3a3a", + "icon-weak-selected": "#3a3a3a", + "icon-weak-disabled": "#4f4f4f", + "icon-weak-focus": "#3a3a3a", + "icon-strong-base": "#212121", + "icon-strong-hover": "#f6f3f3", + "icon-strong-active": "#fcfcfc", + "icon-strong-selected": "#fdfcfc", + "icon-strong-disabled": "#424242", + "icon-strong-focus": "#fdfcfc", + "icon-brand-base": "#ffffff", + "icon-interactive-base": "#5443fb", + "icon-success-base": "#169847", + "icon-success-hover": "#22a851", + "icon-success-active": "#36fb7c", + "icon-warning-base": "#ba6113", + "icon-warning-hover": "#cc6d1e", + "icon-warning-active": "#fdc7a4", + "icon-critical-base": "#ca0620", + "icon-critical-hover": "#eb2c34", + "icon-critical-active": "#fec3bd", + "icon-info-base": "#067fb4", + "icon-info-hover": "#158dc6", + "icon-info-active": "#aadbfb", + "icon-agent-plan-base": "#067fb4", + "icon-agent-docs-base": "#ba6113", + "icon-agent-ask-base": "#3d70f8", + "icon-agent-build-base": "#c9d1fc", + "icon-on-brand-base": "#ffffff", + "icon-on-brand-hover": "#000000", + "icon-on-brand-selected": "#000000", + "icon-on-interactive-base": "#ffffff", + "icon-on-success-base": "#ffffff", + "icon-on-success-hover": "#000000", + "icon-on-success-selected": "#000000", + "icon-on-warning-base": "#ffffff", + "icon-on-warning-hover": "#000000", + "icon-on-warning-selected": "#000000", + "icon-on-critical-base": "#ffffff", + "icon-on-critical-hover": "#000000", + "icon-on-critical-selected": "#000000", + "icon-on-info-base": "#ffffff", + "icon-on-info-hover": "#000000", + "icon-on-info-selected": "#000000", + "icon-diff-add-base": "#41fa7c", + "icon-diff-add-hover": "#179d47", + "icon-diff-add-active": "#41fa7c", + "icon-diff-delete-base": "#ee2527", + "icon-diff-delete-hover": "#fbc4bd", + "icon-diff-modified-base": "#ffba92", + }) + }) + + test("populates icon tokens for light mode", () => { + const ctx = buildTokenContext(sampleColors, {}, false) + const tokens: ResolvedTheme = {} + buildTextTokens(ctx, tokens) + buildIconTokens(ctx, tokens) + + expect(tokens).toMatchObject({ + "icon-base": "#373737", + "icon-hover": "#1c1c1c", + "icon-active": "#0c0c0c", + "icon-selected": "#0c0c0c", + "icon-disabled": "#a9a9a9", + "icon-focus": "#0c0c0c", + "icon-invert-base": "#ffffff", + "icon-weak-base": "#bdbdbd", + "icon-weak-hover": "#a9a9a9", + "icon-weak-active": "#8c8c8c", + "icon-weak-selected": "#686868", + "icon-weak-disabled": "#cbcbcb", + "icon-weak-focus": "#8c8c8c", + "icon-strong-base": "#262626", + "icon-strong-hover": "#151313", + "icon-strong-active": "#020202", + "icon-strong-selected": "#020202", + "icon-strong-disabled": "#a9a9a9", + "icon-strong-focus": "#020202", + "icon-brand-base": "#262626", + "icon-interactive-base": "#6261fc", + "icon-success-base": "#39e573", + "icon-success-hover": "#2ab75a", + "icon-success-active": "#077433", + "icon-warning-base": "#fcab82", + "icon-warning-hover": "#e16606", + "icon-warning-active": "#9a4301", + "icon-critical-base": "#ed172b", + "icon-critical-hover": "#ed172b", + "icon-critical-active": "#b7001a", + "icon-info-base": "#7ccbfd", + "icon-info-hover": "#40b6f7", + "icon-info-active": "#0e6893", + "icon-agent-plan-base": "#15a5e8", + "icon-agent-docs-base": "#eb7328", + "icon-agent-ask-base": "#759fff", + "icon-agent-build-base": "#6261fc", + "icon-on-brand-base": "#000000", + "icon-on-brand-hover": "#000000", + "icon-on-brand-selected": "#000000", + "icon-on-interactive-base": "#000000", + "icon-on-success-base": "#000000", + "icon-on-success-hover": "#ffffff", + "icon-on-success-selected": "#ffffff", + "icon-on-warning-base": "#000000", + "icon-on-warning-hover": "#ffffff", + "icon-on-warning-selected": "#ffffff", + "icon-on-critical-base": "#000000", + "icon-on-critical-hover": "#ffffff", + "icon-on-critical-selected": "#ffffff", + "icon-on-info-base": "#000000", + "icon-on-info-hover": "#ffffff", + "icon-on-info-selected": "#ffffff", + "icon-diff-add-base": "#0f7433", + "icon-diff-add-hover": "#033011", + "icon-diff-add-active": "#033011", + "icon-diff-delete-base": "#d10315", + "icon-diff-delete-hover": "#b21a1b", + "icon-diff-modified-base": "#FF8C00", + }) + }) +}) + +describe("createToken", () => { + // Spans background/surface/input/text/button/border/icon/avatar tokens (>25% of the ~214 keys produced). + test("assembles the full dark-mode token set", () => { + const ctx = buildTokenContext(sampleColors, {}, true) + const tokens = createToken(ctx) + + expect(Object.keys(tokens).length).toBe(214) + expect(tokens).toMatchObject({ + "background-base": "#525252", + "background-weak": "#505050", + "background-strong": "#525252", + "background-stronger": "#515151", + "surface-base": "#4f4f4f", + "surface-inset-strong": "rgba(82, 82, 82, 0.5)", + "surface-float-base": "#515151", + "surface-raised-strong": "#4b4b4b", + "surface-weak": "#4d4d4d", + "surface-strong": "#424242", + "surface-brand-base": "#0061e4", + "surface-interactive-base": "#340db4", + "surface-interactive-weak": "#240388", + "surface-success-base": "#065022", + "surface-success-strong": "#36fb7c", + "surface-warning-base": "#5c3906", + "surface-critical-base": "#7f0411", + "surface-info-base": "#094766", + "surface-diff-unchanged-base": "#525252", + "surface-diff-hidden-base": "#565761", + "surface-diff-add-base": "#001404", + "surface-diff-add-stronger": "#41fa7c", + "surface-diff-delete-base": "#240101", + "surface-diff-delete-stronger": "#fbc4bd", + "input-base": "#515151", + "input-active": "#340db4", + "input-selected": "#471cec", + "input-disabled": "#4f4f4f", + "text-base": "#636363", + "text-weak": "#454545", + "text-strong": "#efefef", + "text-invert-base": "#272727", + "text-invert-strong": "#212121", + "text-interactive-base": "#c9d1fc", + "text-on-brand-base": "#ffffff", + "text-on-success-base": "#ffffff", + "text-on-critical-strong": "#000000", + "text-on-warning-base": "#ffffff", + "text-diff-add-base": "#41fa7c", + "text-diff-add-strong": "#166c32", + "text-diff-delete-base": "#ee2527", + "text-diff-delete-strong": "#fafafa", + "button-primary-base": "#212121", + "button-secondary-base": "#505050", + "button-secondary-hover": "#4f4f4f", + "button-ghost-hover": "#4f4f4f", + "button-ghost-hover2": "#4d4d4d", + "border-base": "#454545", + "border-active": "#414141", + "border-selected": "rgba(84, 67, 251, 0.9)", + "border-weak-base": "#494949", + "border-strong-base": "#414141", + "border-success-base": "#065022", + "border-warning-selected": "#be7903", + "border-critical-hover": "#a9101d", + "border-info-base": "#094766", + "icon-base": "#313131", + "icon-hover": "#272727", + "icon-disabled": "#474747", + "icon-weak-base": "#4a4a4a", + "icon-strong-hover": "#f6f3f3", + "icon-success-base": "#169847", + "icon-warning-active": "#fdc7a4", + "icon-critical-hover": "#eb2c34", + "icon-info-base": "#067fb4", + "icon-agent-plan-base": "#067fb4", + "icon-on-brand-base": "#ffffff", + "icon-on-success-hover": "#000000", + "icon-on-critical-selected": "#000000", + "icon-diff-add-base": "#41fa7c", + "icon-diff-delete-hover": "#fbc4bd", + "icon-diff-modified-base": "#ffba92", + "avatar-background-pink": "#501b3f", + "avatar-background-mint": "#033a34", + "avatar-background-orange": "#5f2a06", + "avatar-text-pink": "#e34ba9", + "avatar-text-cyan": "#369eff", + "avatar-text-lime": "#c4f042", + }) + }) + + test("assembles the full light-mode token set", () => { + const ctx = buildTokenContext(sampleColors, {}, false) + const tokens = createToken(ctx) + + expect(Object.keys(tokens).length).toBe(214) + expect(tokens).toMatchObject({ + "background-base": "#ececec", + "background-weak": "#e2e2e2", + "background-strong": "#ececec", + "background-stronger": "#fcfcfc", + "surface-base": "#e0e0e0", + "surface-inset-strong": "rgba(220, 220, 220, 0.09)", + "surface-float-base": "#262626", + "surface-raised-strong": "#ececec", + "surface-weak": "#d8d8d8", + "surface-strong": "#ffffff", + "surface-brand-base": "#3c82f5", + "surface-interactive-base": "#d8defe", + "surface-interactive-weak": "#e5e9fe", + "surface-success-base": "#8bfea5", + "surface-success-strong": "#077433", + "surface-warning-base": "#fdd9b1", + "surface-critical-base": "#fcd5d1", + "surface-info-base": "#c3e6fd", + "surface-diff-unchanged-base": "#ffffff00", + "surface-diff-hidden-base": "#e0e1e8", + "surface-diff-add-base": "#d9fede", + "surface-diff-add-stronger": "#1ba34b", + "surface-diff-delete-base": "#ffeeec", + "surface-diff-delete-stronger": "#db2928", + "input-base": "#ececec", + "input-active": "#fcfdff", + "input-selected": "#e5e9fe", + "input-disabled": "#dcdcdc", + "text-base": "#1c1c1c", + "text-weak": "#373737", + "text-strong": "#0c0c0c", + "text-invert-base": "#e7e7e7", + "text-invert-strong": "#ececec", + "text-interactive-base": "#5953f3", + "text-on-brand-base": "#000000", + "text-on-success-base": "#000000", + "text-on-critical-strong": "#ffffff", + "text-on-warning-base": "#000000", + "text-diff-add-base": "#0f7433", + "text-diff-add-strong": "#033011", + "text-diff-delete-base": "#d10315", + "text-diff-delete-strong": "#4d0606", + "button-primary-base": "#262626", + "button-secondary-base": "#ececec", + "button-secondary-hover": "#e7e7e7", + "button-ghost-hover": "#e0e0e0", + "button-ghost-hover2": "#d8d8d8", + "border-base": "#bababa", + "border-active": "#a2a2a2", + "border-selected": "rgba(98, 97, 252, 0.99)", + "border-weak-base": "#d3d3d3", + "border-strong-base": "#a2a2a2", + "border-success-base": "#39e573", + "border-warning-selected": "#f49e14", + "border-critical-hover": "#ff7e76", + "border-info-base": "#7ccbfd", + "icon-base": "#373737", + "icon-hover": "#1c1c1c", + "icon-disabled": "#a9a9a9", + "icon-weak-base": "#bdbdbd", + "icon-strong-hover": "#151313", + "icon-success-base": "#39e573", + "icon-warning-active": "#9a4301", + "icon-critical-hover": "#ed172b", + "icon-info-base": "#7ccbfd", + "icon-agent-plan-base": "#15a5e8", + "icon-on-brand-base": "#000000", + "icon-on-success-hover": "#ffffff", + "icon-on-critical-selected": "#ffffff", + "icon-diff-add-base": "#0f7433", + "icon-diff-delete-hover": "#b21a1b", + "icon-diff-modified-base": "#FF8C00", + "avatar-background-pink": "#feeef8", + "avatar-background-mint": "#e1fbf4", + "avatar-background-orange": "#fff1e7", + "avatar-text-pink": "#cd1d8d", + "avatar-text-cyan": "#0894b3", + "avatar-text-lime": "#5d770d", + }) + }) +}) + +describe("buildAvatarTokens", () => { + test("populates avatar tokens for dark mode", () => { + const ctx = buildTokenContext(sampleColors, {}, true) + const tokens: ResolvedTheme = {} + buildAvatarTokens(ctx, tokens) + + expect(tokens).toEqual({ + "avatar-background-pink": "#501b3f", + "avatar-background-mint": "#033a34", + "avatar-background-orange": "#5f2a06", + "avatar-background-purple": "#432155", + "avatar-background-cyan": "#0f3058", + "avatar-background-lime": "#2b3711", + "avatar-text-pink": "#e34ba9", + "avatar-text-mint": "#95f3d9", + "avatar-text-orange": "#ff802b", + "avatar-text-purple": "#9d5bd2", + "avatar-text-cyan": "#369eff", + "avatar-text-lime": "#c4f042", + }) + }) + + test("populates avatar tokens for light mode", () => { + const ctx = buildTokenContext(sampleColors, {}, false) + const tokens: ResolvedTheme = {} + buildAvatarTokens(ctx, tokens) + + expect(tokens).toEqual({ + "avatar-background-pink": "#feeef8", + "avatar-background-mint": "#e1fbf4", + "avatar-background-orange": "#fff1e7", + "avatar-background-purple": "#f9f1fe", + "avatar-background-cyan": "#e7f9fb", + "avatar-background-lime": "#eefadc", + "avatar-text-pink": "#cd1d8d", + "avatar-text-mint": "#147d6f", + "avatar-text-orange": "#ed5f00", + "avatar-text-purple": "#8445bc", + "avatar-text-cyan": "#0894b3", + "avatar-text-lime": "#5d770d", + }) + }) +}) + +describe("addCompactTokenProperties", () => { + test("populates syntax and markdown tokens for dark mode", () => { + const ctx = buildTokenContext(sampleColors, {}, true) + const tokens: ResolvedTheme = { "text-base": "#111111", "border-base": "#222222" } + addCompactTokenProperties(ctx, tokens) + + expect(tokens).toEqual({ + "text-base": "#111111", + "border-base": "#222222", + "syntax-comment": "var(--text-weak)", + "syntax-regexp": "var(--text-base)", + "syntax-string": "#80ff9e", + "syntax-keyword": "#dfd9fe", + "syntax-primitive": "#cee0fc", + "syntax-operator": "var(--text-weak)", + "syntax-variable": "var(--text-strong)", + "syntax-property": "#bfe5fd", + "syntax-type": "#fed7ac", + "syntax-constant": "#dfd9fe", + "syntax-punctuation": "var(--text-weak)", + "syntax-object": "var(--text-strong)", + "syntax-success": "#36fb7c", + "syntax-warning": "#fdc7a4", + "syntax-critical": "#fec3bd", + "syntax-info": "#bfe5fd", + "syntax-diff-add": "#41fa7c", + "syntax-diff-delete": "#fbc4bd", + "syntax-diff-unknown": "#ff0000", + "markdown-heading": "#cee0fc", + "markdown-text": "#111111", + "markdown-link": "#d6ddfe", + "markdown-link-text": "#bfe5fd", + "markdown-code": "#80ff9e", + "markdown-block-quote": "#fed7ac", + "markdown-emph": "#fed7ac", + "markdown-strong": "#dfd9fe", + "markdown-horizontal-rule": "#222222", + "markdown-list-item": "#d6ddfe", + "markdown-list-enumeration": "#bfe5fd", + "markdown-image": "#d6ddfe", + "markdown-image-text": "#bfe5fd", + "markdown-code-block": "#111111", + }) + }) + + test("populates syntax and markdown tokens for light mode", () => { + const ctx = buildTokenContext(sampleColors, {}, false) + const tokens: ResolvedTheme = { "text-base": "#111111", "border-base": "#222222" } + addCompactTokenProperties(ctx, tokens) + + expect(tokens).toEqual({ + "text-base": "#111111", + "border-base": "#222222", + "syntax-comment": "var(--text-weak)", + "syntax-regexp": "var(--text-base)", + "syntax-string": "#046c2f", + "syntax-keyword": "#6815d3", + "syntax-primitive": "#1153b8", + "syntax-operator": "var(--text-base)", + "syntax-variable": "var(--text-strong)", + "syntax-property": "#0b6189", + "syntax-type": "#7c4e05", + "syntax-constant": "#6815d3", + "syntax-punctuation": "var(--text-base)", + "syntax-object": "var(--text-strong)", + "syntax-success": "#077433", + "syntax-warning": "#9a4301", + "syntax-critical": "#b7001a", + "syntax-info": "#0b6189", + "syntax-diff-add": "#0f7433", + "syntax-diff-delete": "#b21a1b", + "syntax-diff-unknown": "#ff0000", + "markdown-heading": "#1153b8", + "markdown-text": "#111111", + "markdown-link": "#4809f1", + "markdown-link-text": "#0b6189", + "markdown-code": "#046c2f", + "markdown-block-quote": "#7c4e05", + "markdown-emph": "#7c4e05", + "markdown-strong": "#6815d3", + "markdown-horizontal-rule": "#222222", + "markdown-list-item": "#4809f1", + "markdown-list-enumeration": "#0b6189", + "markdown-image": "#4809f1", + "markdown-image-text": "#0b6189", + "markdown-code-block": "#111111", + }) + }) +}) + +describe("applyOverridePostProcessing", () => { + test("applies overrides, derives text-weaker/markdown-*/text-stronger fallbacks in compact mode", () => { + const overrides: Record = { + "background-base": "#101010", + "text-weak": "#334455", + } + const tokens: ResolvedTheme = { + "background-base": "#000000", + "text-base": "#ffffff", + "text-strong": "#eeeeee", + "text-weak": "#aaaaaa", + } + + applyOverridePostProcessing(tokens, overrides, sampleColors, true) + + expect(tokens).toEqual({ + "background-base": "#101010", // raw override applied + "text-base": "#ffffff", + "text-strong": "#eeeeee", + "text-weak": "#334455", // raw override applied + "text-weaker": "#192531", // derived from the overridden text-weak since text-weaker wasn't overridden + "markdown-text": "#ffffff", // defaulted to text-base since not overridden + "markdown-code-block": "#ffffff", // defaulted to text-base since not overridden + "text-stronger": "#eeeeee", // defaulted to text-strong since not overridden + }) + }) +}) diff --git a/packages/ui/src/theme/resolve.ts b/packages/ui/src/theme/resolve.ts index 2cf6711e..1292c6eb 100644 --- a/packages/ui/src/theme/resolve.ts +++ b/packages/ui/src/theme/resolve.ts @@ -1,122 +1,303 @@ import type { ColorValue, DesktopTheme, HexColor, ResolvedTheme, ThemeVariant } from "./types" import { blend, generateNeutralScale, generateScale, hexToOklch, hexToRgb, shift, withAlpha } from "./color" +export interface ThemeColors { + compact: boolean + neutral: HexColor + ink?: HexColor + primary: HexColor + accent: HexColor + success: HexColor + warning: HexColor + error: HexColor + info: HexColor + interactive: HexColor + diffAdd?: HexColor + diffDelete?: HexColor +} + +export interface ToneContext { + ink: HexColor + overlay: boolean + background: HexColor + isDark: boolean + compact: boolean +} + +export interface SurfaceTones { + base: ColorValue + weak: ColorValue + weaker: ColorValue + strong: ColorValue + stronger: ColorValue +} + +export interface TokenContext { + isDark: boolean + colors: ThemeColors + tone: ToneContext + neutral: HexColor[] + neutralAlpha: HexColor[] + primary: HexColor[] + accent: HexColor[] + success: HexColor[] + warning: HexColor[] + error: HexColor[] + info: HexColor[] + interactive: HexColor[] + amber: HexColor[] + blue: HexColor[] + diffAdd: HexColor[] + diffDelete: HexColor[] + diffHiddenSurface: SurfaceTones + body?: HexColor + brandb: HexColor + brandh: HexColor + interb: HexColor + interh: HexColor + interw: HexColor + succb: HexColor + succw: HexColor + succs: HexColor + warnb: HexColor + warnw: HexColor + warns: HexColor + critb: HexColor + critw: HexColor + crits: HexColor + infob: HexColor + infow: HexColor + infos: HexColor +} + +export interface ColorScales { + neutral: HexColor[] + primary: HexColor[] + accent: HexColor[] + success: HexColor[] + warning: HexColor[] + error: HexColor[] + info: HexColor[] + interactive: HexColor[] + amber: HexColor[] + blue: HexColor[] + diffAdd: HexColor[] + diffDelete: HexColor[] +} + export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): ResolvedTheme { const colors = getColors(variant) const { overrides = {} } = variant + const ctx = buildTokenContext(colors, overrides, isDark) - const neutral = generateNeutralScale(colors.neutral, isDark, colors.ink) - const primary = generateScale(colors.primary, isDark) - const accent = generateScale(colors.accent, isDark) - const success = generateScale(colors.success, isDark) - const warning = generateScale(colors.warning, isDark) - const error = generateScale(colors.error, isDark) - const info = generateScale(colors.info, isDark) - const interactive = generateScale(colors.interactive, isDark) - const amber = generateScale( - shift(colors.warning, isDark ? { h: -16, l: -0.058, c: 1.14 } : { h: -22, l: -0.082, c: 0.94 }), - isDark, - ) - const blue = generateScale(shift(colors.interactive, { h: -12, l: 0.128, c: 1.12 }), isDark) - const diffAdd = generateScale( - colors.diffAdd ?? shift(colors.success, { c: isDark ? 0.7 : 0.55, l: isDark ? -0.18 : 0.14 }), - isDark, - ) - const diffDelete = generateScale( - colors.diffDelete ?? shift(colors.error, { c: isDark ? 0.82 : 0.7, l: isDark ? -0.08 : 0.08 }), - isDark, - ) - const ink = colors.ink ?? colors.neutral - const tint = colors.compact ? hexToOklch(ink) : undefined - const body = tint - ? shift(ink, { - l: isDark ? Math.max(0, 0.88 - tint.l) * 0.4 : -Math.max(0, tint.l - 0.18) * 0.24, - c: isDark ? 1.04 : 1.02, - }) - : undefined - const backgroundOverride = overrides["background-base"] - const backgroundHex = getHex(backgroundOverride) - const overlay = Boolean(backgroundOverride) && !backgroundHex - const content = (seed: HexColor, scale: HexColor[]) => { - const base = hexToOklch(seed) - const value = isDark ? (base.l > 0.84 ? shift(seed, { c: 1.18 }) : scale[10]) : scale[10] - return shift(value, { l: isDark ? 0.034 : -0.024, c: isDark ? 1.3 : 1.18 }) - } - const modified = () => { - if (!colors.compact) return isDark ? "#ffba92" : "#FF8C00" - const warningHue = hexToOklch(colors.warning).h - const deleteHue = hexToOklch(colors.diffDelete ?? colors.error).h - const delta = Math.abs(((((deleteHue - warningHue) % 360) + 540) % 360) - 180) - if (delta < 48) return isDark ? "#ffba92" : "#FF8C00" - return content(colors.warning, warning) - } - const surface = ( - seed: HexColor, - alpha: { base: number; weak: number; weaker: number; strong: number; stronger: number }, - ) => { - const base = alphaTone(seed, alpha.base) - return { - base, - weak: alphaTone(seed, alpha.weak), - weaker: alphaTone(seed, alpha.weaker), - strong: alphaTone(seed, alpha.strong), - stronger: alphaTone(seed, alpha.stronger), - } + const tokens: ResolvedTheme = createToken(ctx) + + if (colors.compact) { + addCompactTokenProperties(ctx, tokens) + } else { + addNonCompactTokenProperties(ctx, tokens) } - const background = backgroundHex ?? neutral[0] - const alphaTone = (color: HexColor, alpha: number) => - overlay ? (withAlpha(color, alpha) as ColorValue) : blend(color, background, alpha) - const borderTone = (light: number, dark: number) => - alphaTone(ink, isDark ? Math.min(1, dark + 0.024 + (colors.compact ? 0.08 : 0)) : Math.min(1, light + 0.024)) - const diffHiddenSurface = surface( - isDark ? shift(colors.interactive, { c: 0.55, l: 0 }) : shift(colors.interactive, { c: 0.45, l: 0.08 }), - isDark - ? { base: 0.14, weak: 0.08, weaker: 0.18, strong: 0.26, stronger: 0.42 } - : { base: 0.12, weak: 0.08, weaker: 0.16, strong: 0.24, stronger: 0.36 }, - ) + applyOverridePostProcessing(tokens, overrides, colors, isDark) + + return tokens +} + +export function buildTokenContext(colors: ThemeColors, overrides: Record, isDark: boolean): TokenContext { + const { neutral, primary, accent, success, warning, error, info, interactive, amber, blue, diffAdd, diffDelete } = + buildScales(colors, isDark) + + const ink = colors.ink ?? colors.neutral + const body = computeCompactBody(colors, ink, isDark) + const tone = computeToneContext(colors, overrides, neutral, ink, isDark) + const diffHiddenSurface = computeDiffHiddenSurface(tone, colors, isDark) const neutralAlpha = generateNeutralAlphaScale(neutral, isDark) + const brandb = primary[8] const brandh = primary[9] const interb = interactive[isDark ? 6 : 4] const interh = interactive[isDark ? 7 : 5] const interw = interactive[isDark ? 5 : 3] - const succb = success[isDark ? 6 : 4] - const succw = success[isDark ? 5 : 3] - const succs = success[10] - const warnb = warning[isDark ? 6 : 4] - const warnw = warning[isDark ? 5 : 3] - const warns = warning[10] - const critb = error[isDark ? 6 : 4] - const critw = error[isDark ? 5 : 3] - const crits = error[10] - const infob = info[isDark ? 6 : 4] - const infow = info[isDark ? 5 : 3] - const infos = info[10] - const lum = (hex: HexColor) => { - const rgb = hexToRgb(hex) - const lift = (v: number) => (v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4)) - return 0.2126 * lift(rgb.r) + 0.7152 * lift(rgb.g) + 0.0722 * lift(rgb.b) + const successShades = semanticShades(success, isDark) + const warningShades = semanticShades(warning, isDark) + const errorShades = semanticShades(error, isDark) + const infoShades = semanticShades(info, isDark) + + return { + isDark, colors, tone, neutral, primary, accent, + success, warning, error, info, interactive, amber, blue, diffAdd, diffDelete, + neutralAlpha, diffHiddenSurface, body, brandb, brandh, interb, interh, interw, + succb: successShades.base, succw: successShades.weak, succs: successShades.strong, + warnb: warningShades.base, warnw: warningShades.weak, warns: warningShades.strong, + critb: errorShades.base, critw: errorShades.weak, crits: errorShades.strong, + infob: infoShades.base, infow: infoShades.weak, infos: infoShades.strong, } - const hit = (a: HexColor, b: HexColor) => { - const x = lum(a) - const y = lum(b) - const light = Math.max(x, y) - const dark = Math.min(x, y) - return (light + 0.05) / (dark + 0.05) +} + +export function getColors(variant: ThemeVariant): ThemeColors { + const input = variant as { palette?: unknown; seeds?: unknown } + if (input.palette && input.seeds) { + throw new Error("Theme variant cannot define both `palette` and `seeds`") } - const on = (fill: HexColor) => { - const light = "#ffffff" as HexColor - const dark = "#000000" as HexColor - return hit(light, fill) > hit(dark, fill) ? light : dark + + if (variant.palette) { + return { + compact: true, + neutral: variant.palette.neutral, + ink: variant.palette.ink, + primary: variant.palette.primary, + accent: variant.palette.accent ?? variant.palette.info, + success: variant.palette.success, + warning: variant.palette.warning, + error: variant.palette.error, + info: variant.palette.info, + interactive: variant.palette.interactive ?? variant.palette.primary, + diffAdd: variant.palette.diffAdd, + diffDelete: variant.palette.diffDelete, + } + } + + if (variant.seeds) { + return { + compact: false, + neutral: variant.seeds.neutral, + ink: undefined, + primary: variant.seeds.primary, + accent: variant.seeds.info, + success: variant.seeds.success, + warning: variant.seeds.warning, + error: variant.seeds.error, + info: variant.seeds.info, + interactive: variant.seeds.interactive, + diffAdd: variant.seeds.diffAdd, + diffDelete: variant.seeds.diffDelete, + } + } + + throw new Error("Theme variant requires `palette` or `seeds`") +} + +export function buildScales(colors: ThemeColors, isDark: boolean): ColorScales { + const amberSeed = shift(colors.warning, isDark ? { h: -16, l: -0.058, c: 1.14 } : { h: -22, l: -0.082, c: 0.94 }) + const diffAddSeed = colors.diffAdd ?? shift(colors.success, { c: isDark ? 0.7 : 0.55, l: isDark ? -0.18 : 0.14 }) + const diffDeleteSeed = colors.diffDelete ?? shift(colors.error, { c: isDark ? 0.82 : 0.7, l: isDark ? -0.08 : 0.08 }) + + return { + neutral: generateNeutralScale(colors.neutral, isDark, colors.ink), + primary: generateScale(colors.primary, isDark), + accent: generateScale(colors.accent, isDark), + success: generateScale(colors.success, isDark), + warning: generateScale(colors.warning, isDark), + error: generateScale(colors.error, isDark), + info: generateScale(colors.info, isDark), + interactive: generateScale(colors.interactive, isDark), + amber: generateScale(amberSeed, isDark), + blue: generateScale(shift(colors.interactive, { h: -12, l: 0.128, c: 1.12 }), isDark), + diffAdd: generateScale(diffAddSeed, isDark), + diffDelete: generateScale(diffDeleteSeed, isDark), + } +} + +export function computeCompactBody(colors: ThemeColors, ink: HexColor, isDark: boolean): HexColor | undefined { + if (!colors.compact) return undefined + const tint = hexToOklch(ink) + return shift(ink, { + l: isDark ? Math.max(0, 0.88 - tint.l) * 0.4 : -Math.max(0, tint.l - 0.18) * 0.24, + c: isDark ? 1.04 : 1.02, + }) +} + +export function computeToneContext( + colors: ThemeColors, + overrides: Record, + neutral: HexColor[], + ink: HexColor, + isDark: boolean, +): ToneContext { + const backgroundOverride = overrides["background-base"] + const backgroundHex = getHex(backgroundOverride) + const overlay = Boolean(backgroundOverride) && !backgroundHex + const background = backgroundHex ?? neutral[0] + return { ink, overlay, background, isDark, compact: colors.compact } +} + +function getHex(value: ColorValue | undefined): HexColor | undefined { + if (!value?.startsWith("#")) return + return value as HexColor +} + +function computeDiffHiddenSurface(tone: ToneContext, colors: ThemeColors, isDark: boolean): SurfaceTones { + const seed = isDark ? shift(colors.interactive, { c: 0.55, l: 0 }) : shift(colors.interactive, { c: 0.45, l: 0.08 }) + const alpha = isDark + ? { base: 0.14, weak: 0.08, weaker: 0.18, strong: 0.26, stronger: 0.42 } + : { base: 0.12, weak: 0.08, weaker: 0.16, strong: 0.24, stronger: 0.36 } + return surface(tone, seed, alpha) +} + +export function surface( + tone: ToneContext, + seed: HexColor, + alpha: { base: number; weak: number; weaker: number; strong: number; stronger: number }, +): SurfaceTones { + const base = alphaTone(tone, seed, alpha.base) + return { + base, + weak: alphaTone(tone, seed, alpha.weak), + weaker: alphaTone(tone, seed, alpha.weaker), + strong: alphaTone(tone, seed, alpha.strong), + stronger: alphaTone(tone, seed, alpha.stronger), } +} + +function alphaTone(tone: ToneContext, color: HexColor, alpha: number): ColorValue { + return tone.overlay ? (withAlpha(color, alpha) as ColorValue) : blend(color, tone.background, alpha) +} +function generateNeutralAlphaScale(neutralScale: HexColor[], isDark: boolean): HexColor[] { + const alphas = isDark + ? [0.038, 0.066, 0.1, 0.142, 0.19, 0.252, 0.334, 0.446, 0.58, 0.718, 0.854, 0.985] + : [0.03, 0.06, 0.1, 0.145, 0.2, 0.265, 0.35, 0.47, 0.61, 0.74, 0.86, 0.97] + + return alphas.map((alpha) => blend(neutralScale[11], neutralScale[0], alpha)) +} + +function semanticShades(scale: HexColor[], isDark: boolean): { base: HexColor; weak: HexColor; strong: HexColor } { + return { + base: scale[isDark ? 6 : 4], + weak: scale[isDark ? 5 : 3], + strong: scale[10], + } +} + +export function createToken(ctx: TokenContext): ResolvedTheme { const tokens: ResolvedTheme = {} + buildBackgroundTokens(ctx, tokens) + buildSurfaceTokens(ctx, tokens) + buildInputTokens(ctx, tokens) + buildTextTokens(ctx, tokens) + buildButtonTokens(ctx, tokens) + buildBorderTokens(ctx, tokens) + buildIconTokens(ctx, tokens) + buildAvatarTokens(ctx, tokens) + return tokens +} +function buildBackgroundTokens(ctx: TokenContext, tokens: ResolvedTheme): void { + const { neutral, isDark } = ctx tokens["background-base"] = neutral[0] tokens["background-weak"] = neutral[2] tokens["background-strong"] = neutral[0] tokens["background-stronger"] = isDark ? neutral[1] : "#fcfcfc" +} + +function buildSurfaceTokens(ctx: TokenContext, tokens: ResolvedTheme): void { + buildSurfaceBaseTokens(ctx, tokens) + buildSurfaceStatusTokens(ctx, tokens) + buildSurfaceDiffTokens(ctx, tokens) +} + +function buildSurfaceBaseTokens(ctx: TokenContext, tokens: ResolvedTheme): void { + const { neutral, neutralAlpha, interactive, isDark } = ctx tokens["surface-base"] = neutralAlpha[1] tokens["base"] = neutralAlpha[1] @@ -144,6 +325,10 @@ export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): Res tokens["surface-weaker"] = neutralAlpha[3] tokens["surface-strong"] = isDark ? neutralAlpha[6] : "#ffffff" tokens["surface-raised-stronger-non-alpha"] = isDark ? neutral[2] : "#ffffff" +} + +function buildSurfaceStatusTokens(ctx: TokenContext, tokens: ResolvedTheme): void { + const { brandb, brandh, interb, interh, interw, succb, succw, succs, warnb, warnw, warns, critb, critw, crits, infob, infow, infos } = ctx tokens["surface-brand-base"] = brandb tokens["surface-brand-hover"] = brandh @@ -165,6 +350,10 @@ export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): Res tokens["surface-info-base"] = infob tokens["surface-info-weak"] = infow tokens["surface-info-strong"] = infos +} + +function buildSurfaceDiffTokens(ctx: TokenContext, tokens: ResolvedTheme): void { + const { neutral, neutralAlpha, isDark, diffHiddenSurface, diffAdd, diffDelete } = ctx tokens["surface-diff-unchanged-base"] = isDark ? neutral[0] : "#ffffff00" tokens["surface-diff-skip-base"] = isDark ? neutralAlpha[0] : neutral[1] @@ -183,13 +372,26 @@ export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): Res tokens["surface-diff-delete-weaker"] = diffDelete[isDark ? 2 : 0] tokens["surface-diff-delete-strong"] = diffDelete[isDark ? 4 : 5] tokens["surface-diff-delete-stronger"] = diffDelete[isDark ? 10 : 8] +} +function buildInputTokens(ctx: TokenContext, tokens: ResolvedTheme): void { + const { neutral, interactive, isDark } = ctx tokens["input-base"] = isDark ? neutral[1] : neutral[0] tokens["input-hover"] = isDark ? neutral[2] : neutral[1] tokens["input-active"] = isDark ? interactive[6] : interactive[0] tokens["input-selected"] = isDark ? interactive[7] : interactive[3] tokens["input-focus"] = isDark ? interactive[6] : interactive[0] tokens["input-disabled"] = neutral[3] +} + +export function buildTextTokens(ctx: TokenContext, tokens: ResolvedTheme): void { + buildTextToneTokens(ctx, tokens) + buildTextOnTokens(ctx, tokens) + buildTextDiffTokens(ctx, tokens) +} + +function buildTextToneTokens(ctx: TokenContext, tokens: ResolvedTheme): void { + const { neutral, interactive, isDark, colors, body } = ctx tokens["text-base"] = colors.compact ? (body as HexColor) : neutral[10] tokens["text-weak"] = colors.compact ? shift(body as HexColor, { l: isDark ? -0.11 : 0.11, c: 0.9 }) : neutral[8] @@ -206,54 +408,93 @@ export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): Res tokens["text-invert-weaker"] = isDark ? neutral[7] : neutral[3] tokens["text-invert-strong"] = isDark ? neutral[11] : neutral[0] tokens["text-interactive-base"] = interactive[isDark ? 10 : 9] - tokens["text-on-brand-base"] = on(brandb) - tokens["text-on-interactive-base"] = on(interb) - tokens["text-on-interactive-weak"] = on(interb) - tokens["text-on-success-base"] = on(succb) - tokens["text-on-critical-base"] = on(critb) - tokens["text-on-critical-weak"] = on(critb) - tokens["text-on-critical-strong"] = on(crits) - tokens["text-on-warning-base"] = on(warnb) - tokens["text-on-info-base"] = on(infob) +} + +function buildTextOnTokens(ctx: TokenContext, tokens: ResolvedTheme): void { + const { brandb, brandh, interb, succb, succs, warnb, warns, critb, crits, infob, infos } = ctx + + tokens["text-on-brand-base"] = onColor(brandb) + tokens["text-on-interactive-base"] = onColor(interb) + tokens["text-on-interactive-weak"] = onColor(interb) + tokens["text-on-success-base"] = onColor(succb) + tokens["text-on-critical-base"] = onColor(critb) + tokens["text-on-critical-weak"] = onColor(critb) + tokens["text-on-critical-strong"] = onColor(crits) + tokens["text-on-warning-base"] = onColor(warnb) + tokens["text-on-info-base"] = onColor(infob) + tokens["text-on-info-weak"] = onColor(infob) + tokens["text-on-info-strong"] = onColor(infos) + tokens["text-on-warning-weak"] = onColor(warnb) + tokens["text-on-warning-strong"] = onColor(warns) + tokens["text-on-success-weak"] = onColor(succb) + tokens["text-on-success-strong"] = onColor(succs) + tokens["text-on-brand-weak"] = onColor(brandb) + tokens["text-on-brand-weaker"] = onColor(brandb) + tokens["text-on-brand-strong"] = onColor(brandh) +} + +function onColor(fill: HexColor): ColorValue { + const light = "#ffffff" as HexColor + const dark = "#000000" as HexColor + return contrastRatio(light, fill) > contrastRatio(dark, fill) ? light : dark +} + +function contrastRatio(a: HexColor, b: HexColor): number { + const x = relativeLuminance(a) + const y = relativeLuminance(b) + const light = Math.max(x, y) + const dark = Math.min(x, y) + return (light + 0.05) / (dark + 0.05) +} + +function relativeLuminance(hex: HexColor): number { + const rgb = hexToRgb(hex) + const lift = (v: number) => (v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4)) + return 0.2126 * lift(rgb.r) + 0.7152 * lift(rgb.g) + 0.0722 * lift(rgb.b) +} + +function buildTextDiffTokens(ctx: TokenContext, tokens: ResolvedTheme): void { + const { isDark, diffAdd, diffDelete } = ctx + tokens["text-diff-add-base"] = diffAdd[10] + tokens["text-diff-add-strong"] = diffAdd[isDark ? 7 : 11] tokens["text-diff-delete-base"] = diffDelete[9] tokens["text-diff-delete-strong"] = diffDelete[11] - tokens["text-diff-add-strong"] = diffAdd[isDark ? 7 : 11] - tokens["text-on-info-weak"] = on(infob) - tokens["text-on-info-strong"] = on(infos) - tokens["text-on-warning-weak"] = on(warnb) - tokens["text-on-warning-strong"] = on(warns) - tokens["text-on-success-weak"] = on(succb) - tokens["text-on-success-strong"] = on(succs) - tokens["text-on-brand-weak"] = on(brandb) - tokens["text-on-brand-weaker"] = on(brandb) - tokens["text-on-brand-strong"] = on(brandh) +} +function buildButtonTokens(ctx: TokenContext, tokens: ResolvedTheme): void { + const { neutral, neutralAlpha, isDark } = ctx tokens["button-primary-base"] = neutral[11] tokens["button-secondary-base"] = isDark ? neutral[2] : neutral[0] tokens["button-secondary-hover"] = isDark ? neutral[3] : neutral[1] tokens["button-ghost-hover"] = neutralAlpha[1] tokens["button-ghost-hover2"] = neutralAlpha[2] +} + +function buildBorderTokens(ctx: TokenContext, tokens: ResolvedTheme): void { + const { neutral, interactive, success, warning, error, info, isDark } = ctx + const border = (light: number, dark: number, alphaLight: number, alphaDark = alphaLight) => + resolveBorder(ctx, light, dark, alphaLight, alphaDark) - tokens["border-base"] = colors.compact ? borderTone(0.22, 0.16) : neutralAlpha[6] - tokens["border-hover"] = colors.compact ? borderTone(0.28, 0.2) : neutralAlpha[7] - tokens["border-active"] = colors.compact ? borderTone(0.34, 0.24) : neutralAlpha[8] + tokens["border-base"] = border(0.22, 0.16, 6) + tokens["border-hover"] = border(0.28, 0.2, 7) + tokens["border-active"] = border(0.34, 0.24, 8) tokens["border-selected"] = withAlpha(interactive[8], isDark ? 0.9 : 0.99) as ColorValue - tokens["border-disabled"] = colors.compact ? borderTone(0.18, 0.12) : neutralAlpha[7] - tokens["border-focus"] = colors.compact ? borderTone(0.34, 0.24) : neutralAlpha[8] - tokens["border-weak-base"] = colors.compact ? borderTone(0.1, 0.08) : neutralAlpha[isDark ? 5 : 4] - tokens["border-strong-base"] = colors.compact ? borderTone(0.34, 0.24) : neutralAlpha[isDark ? 7 : 6] - tokens["border-strong-hover"] = colors.compact ? borderTone(0.4, 0.28) : neutralAlpha[7] - tokens["border-strong-active"] = colors.compact ? borderTone(0.46, 0.32) : neutralAlpha[isDark ? 7 : 6] + tokens["border-disabled"] = border(0.18, 0.12, 7) + tokens["border-focus"] = border(0.34, 0.24, 8) + tokens["border-weak-base"] = border(0.1, 0.08, 4, 5) + tokens["border-strong-base"] = border(0.34, 0.24, 6, 7) + tokens["border-strong-hover"] = border(0.4, 0.28, 7) + tokens["border-strong-active"] = border(0.46, 0.32, 6, 7) tokens["border-strong-selected"] = withAlpha(interactive[5], 0.6) as ColorValue - tokens["border-strong-disabled"] = colors.compact ? borderTone(0.14, 0.1) : neutralAlpha[5] - tokens["border-strong-focus"] = colors.compact ? borderTone(0.46, 0.32) : neutralAlpha[isDark ? 7 : 6] - tokens["border-weak-hover"] = colors.compact ? borderTone(0.16, 0.12) : neutralAlpha[isDark ? 6 : 5] - tokens["border-weak-active"] = colors.compact ? borderTone(0.22, 0.16) : neutralAlpha[isDark ? 7 : 6] + tokens["border-strong-disabled"] = border(0.14, 0.1, 5) + tokens["border-strong-focus"] = border(0.46, 0.32, 6, 7) + tokens["border-weak-hover"] = border(0.16, 0.12, 5, 6) + tokens["border-weak-active"] = border(0.22, 0.16, 6, 7) tokens["border-weak-selected"] = withAlpha(interactive[4], isDark ? 0.6 : 0.5) as ColorValue - tokens["border-weak-disabled"] = colors.compact ? borderTone(0.08, 0.06) : neutralAlpha[5] - tokens["border-weak-focus"] = colors.compact ? borderTone(0.22, 0.16) : neutralAlpha[isDark ? 7 : 6] - tokens["border-weaker-base"] = colors.compact ? borderTone(0.06, 0.04) : neutralAlpha[2] + tokens["border-weak-disabled"] = border(0.08, 0.06, 5) + tokens["border-weak-focus"] = border(0.22, 0.16, 6, 7) + tokens["border-weaker-base"] = border(0.06, 0.04, 2) tokens["border-interactive-base"] = interactive[6] tokens["border-interactive-hover"] = interactive[7] @@ -262,26 +503,48 @@ export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): Res tokens["border-interactive-disabled"] = neutral[7] tokens["border-interactive-focus"] = interactive[8] - tokens["border-success-base"] = success[isDark ? 6 : 6] - tokens["border-success-hover"] = success[isDark ? 7 : 7] - tokens["border-success-selected"] = success[8] - tokens["border-warning-base"] = warning[isDark ? 6 : 6] - tokens["border-warning-hover"] = warning[isDark ? 7 : 7] - tokens["border-warning-selected"] = warning[8] - tokens["border-critical-base"] = error[isDark ? 6 : 6] - tokens["border-critical-hover"] = error[isDark ? 7 : 7] - tokens["border-critical-selected"] = error[8] - tokens["border-info-base"] = info[isDark ? 6 : 6] - tokens["border-info-hover"] = info[isDark ? 7 : 7] - tokens["border-info-selected"] = info[8] + borderStatusTokens(tokens, "success", success) + borderStatusTokens(tokens, "warning", warning) + borderStatusTokens(tokens, "critical", error) + borderStatusTokens(tokens, "info", info) tokens["border-color"] = "#ffffff" +} + +function resolveBorder(ctx: TokenContext, light: number, dark: number, alphaLight: number, alphaDark: number): ColorValue { + if (ctx.colors.compact) return borderTone(ctx.tone, light, dark) + return ctx.neutralAlpha[ctx.isDark ? alphaDark : alphaLight] +} + +function borderTone(tone: ToneContext, light: number, dark: number): ColorValue { + return alphaTone( + tone, + tone.ink, + tone.isDark ? Math.min(1, dark + 0.024 + (tone.compact ? 0.08 : 0)) : Math.min(1, light + 0.024), + ) +} + +function borderStatusTokens(tokens: ResolvedTheme, prefix: string, scale: HexColor[]): void { + tokens[`border-${prefix}-base`] = scale[6] + tokens[`border-${prefix}-hover`] = scale[7] + tokens[`border-${prefix}-selected`] = scale[8] +} + +export function buildIconTokens(ctx: TokenContext, tokens: ResolvedTheme): void { + buildIconBaseTokens(ctx, tokens) + buildIconStatusTokens(ctx, tokens) + buildIconOnTokens(ctx, tokens) + buildIconDiffTokens(ctx, tokens) +} - tokens["icon-base"] = colors.compact && !isDark ? tokens["text-weak"] : neutral[isDark ? 9 : 8] - tokens["icon-hover"] = colors.compact && !isDark ? tokens["text-base"] : neutral[10] - tokens["icon-active"] = colors.compact && !isDark ? tokens["text-strong"] : neutral[11] - tokens["icon-selected"] = colors.compact && !isDark ? tokens["text-strong"] : neutral[11] +function buildIconBaseTokens(ctx: TokenContext, tokens: ResolvedTheme): void { + const { neutral, interactive, isDark } = ctx + + tokens["icon-base"] = iconTone(ctx, tokens, "text-weak", 9, 8) + tokens["icon-hover"] = iconTone(ctx, tokens, "text-base", 10, 10) + tokens["icon-active"] = iconTone(ctx, tokens, "text-strong", 11, 11) + tokens["icon-selected"] = iconTone(ctx, tokens, "text-strong", 11, 11) tokens["icon-disabled"] = neutral[isDark ? 6 : 7] - tokens["icon-focus"] = colors.compact && !isDark ? tokens["text-strong"] : neutral[11] + tokens["icon-focus"] = iconTone(ctx, tokens, "text-strong", 11, 11) tokens["icon-invert-base"] = isDark ? neutral[0] : "#ffffff" tokens["icon-weak-base"] = neutral[isDark ? 5 : 6] tokens["icon-weak-hover"] = neutral[isDark ? 11 : 7] @@ -297,6 +560,16 @@ export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): Res tokens["icon-strong-focus"] = isDark ? "#fdfcfc" : "#020202" tokens["icon-brand-base"] = isDark ? "#ffffff" : neutral[11] tokens["icon-interactive-base"] = interactive[8] +} + +function iconTone(ctx: TokenContext, tokens: ResolvedTheme, fallbackKey: string, darkIdx: number, lightIdx: number): ColorValue { + if (ctx.colors.compact && !ctx.isDark) return tokens[fallbackKey] + return ctx.neutral[ctx.isDark ? darkIdx : lightIdx] +} + +function buildIconStatusTokens(ctx: TokenContext, tokens: ResolvedTheme): void { + const { isDark, success, error, info, amber, blue, interactive } = ctx + tokens["icon-success-base"] = success[isDark ? 8 : 6] tokens["icon-success-hover"] = success[9] tokens["icon-success-active"] = success[10] @@ -309,110 +582,62 @@ export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): Res tokens["icon-info-base"] = info[isDark ? 8 : 6] tokens["icon-info-hover"] = info[isDark ? 9 : 7] tokens["icon-info-active"] = info[10] - tokens["icon-on-brand-base"] = on(brandb) - tokens["icon-on-brand-hover"] = on(brandh) - tokens["icon-on-brand-selected"] = on(brandh) - tokens["icon-on-interactive-base"] = on(interb) tokens["icon-agent-plan-base"] = info[8] tokens["icon-agent-docs-base"] = amber[8] tokens["icon-agent-ask-base"] = blue[8] tokens["icon-agent-build-base"] = interactive[isDark ? 10 : 8] +} + +function buildIconOnTokens(ctx: TokenContext, tokens: ResolvedTheme): void { + const { brandb, brandh, interb, succb, succs, warnb, warns, critb, crits, infob, infos } = ctx + + tokens["icon-on-brand-base"] = onColor(brandb) + tokens["icon-on-brand-hover"] = onColor(brandh) + tokens["icon-on-brand-selected"] = onColor(brandh) + tokens["icon-on-interactive-base"] = onColor(interb) + tokens["icon-on-success-base"] = onColor(succb) + tokens["icon-on-success-hover"] = onColor(succs) + tokens["icon-on-success-selected"] = onColor(succs) + tokens["icon-on-warning-base"] = onColor(warnb) + tokens["icon-on-warning-hover"] = onColor(warns) + tokens["icon-on-warning-selected"] = onColor(warns) + tokens["icon-on-critical-base"] = onColor(critb) + tokens["icon-on-critical-hover"] = onColor(crits) + tokens["icon-on-critical-selected"] = onColor(crits) + tokens["icon-on-info-base"] = onColor(infob) + tokens["icon-on-info-hover"] = onColor(infos) + tokens["icon-on-info-selected"] = onColor(infos) +} - tokens["icon-on-success-base"] = on(succb) - tokens["icon-on-success-hover"] = on(succs) - tokens["icon-on-success-selected"] = on(succs) - tokens["icon-on-warning-base"] = on(warnb) - tokens["icon-on-warning-hover"] = on(warns) - tokens["icon-on-warning-selected"] = on(warns) - tokens["icon-on-critical-base"] = on(critb) - tokens["icon-on-critical-hover"] = on(crits) - tokens["icon-on-critical-selected"] = on(crits) - tokens["icon-on-info-base"] = on(infob) - tokens["icon-on-info-hover"] = on(infos) - tokens["icon-on-info-selected"] = on(infos) +function buildIconDiffTokens(ctx: TokenContext, tokens: ResolvedTheme): void { + const { isDark, colors, warning, diffAdd, diffDelete } = ctx tokens["icon-diff-add-base"] = diffAdd[10] tokens["icon-diff-add-hover"] = diffAdd[isDark ? 9 : 11] tokens["icon-diff-add-active"] = diffAdd[isDark ? 10 : 11] tokens["icon-diff-delete-base"] = diffDelete[9] - tokens["icon-diff-delete-hover"] = diffDelete[isDark ? 10 : 10] - tokens["icon-diff-modified-base"] = modified() + tokens["icon-diff-delete-hover"] = diffDelete[10] + tokens["icon-diff-modified-base"] = modified(colors, isDark, warning) +} - if (colors.compact) { - tokens["syntax-comment"] = "var(--text-weak)" - tokens["syntax-regexp"] = "var(--text-base)" - tokens["syntax-string"] = content(colors.success, success) - tokens["syntax-keyword"] = content(colors.accent, accent) - tokens["syntax-primitive"] = content(colors.primary, primary) - tokens["syntax-operator"] = isDark ? "var(--text-weak)" : "var(--text-base)" - tokens["syntax-variable"] = "var(--text-strong)" - tokens["syntax-property"] = content(colors.info, info) - tokens["syntax-type"] = content(colors.warning, warning) - tokens["syntax-constant"] = content(colors.accent, accent) - tokens["syntax-punctuation"] = isDark ? "var(--text-weak)" : "var(--text-base)" - tokens["syntax-object"] = "var(--text-strong)" - tokens["syntax-success"] = success[10] - tokens["syntax-warning"] = amber[10] - tokens["syntax-critical"] = error[10] - tokens["syntax-info"] = content(colors.info, info) - tokens["syntax-diff-add"] = diffAdd[10] - tokens["syntax-diff-delete"] = diffDelete[10] - tokens["syntax-diff-unknown"] = "#ff0000" - - tokens["markdown-heading"] = content(colors.primary, primary) - tokens["markdown-text"] = tokens["text-base"] - tokens["markdown-link"] = content(colors.interactive, interactive) - tokens["markdown-link-text"] = content(colors.info, info) - tokens["markdown-code"] = content(colors.success, success) - tokens["markdown-block-quote"] = content(colors.warning, warning) - tokens["markdown-emph"] = content(colors.warning, warning) - tokens["markdown-strong"] = content(colors.accent, accent) - tokens["markdown-horizontal-rule"] = tokens["border-base"] - tokens["markdown-list-item"] = content(colors.interactive, interactive) - tokens["markdown-list-enumeration"] = content(colors.info, info) - tokens["markdown-image"] = content(colors.interactive, interactive) - tokens["markdown-image-text"] = content(colors.info, info) - tokens["markdown-code-block"] = tokens["text-base"] - } +function modified(colors: ThemeColors, isDark: boolean, warning: HexColor[]): ColorValue { + if (!colors.compact) return isDark ? "#ffba92" : "#FF8C00" + const warningHue = hexToOklch(colors.warning).h + const deleteHue = hexToOklch(colors.diffDelete ?? colors.error).h + const delta = Math.abs(((((deleteHue - warningHue) % 360) + 540) % 360) - 180) + if (delta < 48) return isDark ? "#ffba92" : "#FF8C00" + return content(isDark, colors.warning, warning) +} - if (!colors.compact) { - tokens["syntax-comment"] = "var(--text-weak)" - tokens["syntax-regexp"] = "var(--text-base)" - tokens["syntax-string"] = isDark ? "#00ceb9" : "#006656" - tokens["syntax-keyword"] = "var(--text-weak)" - tokens["syntax-primitive"] = isDark ? "#ffba92" : "#fb4804" - tokens["syntax-operator"] = isDark ? "var(--text-weak)" : "var(--text-base)" - tokens["syntax-variable"] = "var(--text-strong)" - tokens["syntax-property"] = isDark ? "#ff9ae2" : "#ed6dc8" - tokens["syntax-type"] = isDark ? "#ecf58c" : "#596600" - tokens["syntax-constant"] = isDark ? "#93e9f6" : "#007b80" - tokens["syntax-punctuation"] = isDark ? "var(--text-weak)" : "var(--text-base)" - tokens["syntax-object"] = "var(--text-strong)" - tokens["syntax-success"] = success[10] - tokens["syntax-warning"] = amber[10] - tokens["syntax-critical"] = error[10] - tokens["syntax-info"] = isDark ? "#93e9f6" : "#0092a8" - tokens["syntax-diff-add"] = diffAdd[10] - tokens["syntax-diff-delete"] = diffDelete[10] - tokens["syntax-diff-unknown"] = "#ff0000" - - tokens["markdown-heading"] = isDark ? "#9d7cd8" : "#d68c27" - tokens["markdown-text"] = isDark ? "#eeeeee" : "#1a1a1a" - tokens["markdown-link"] = isDark ? "#fab283" : "#3b7dd8" - tokens["markdown-link-text"] = isDark ? "#56b6c2" : "#318795" - tokens["markdown-code"] = isDark ? "#7fd88f" : "#3d9a57" - tokens["markdown-block-quote"] = isDark ? "#e5c07b" : "#b0851f" - tokens["markdown-emph"] = isDark ? "#e5c07b" : "#b0851f" - tokens["markdown-strong"] = isDark ? "#f5a742" : "#d68c27" - tokens["markdown-horizontal-rule"] = isDark ? "#808080" : "#8a8a8a" - tokens["markdown-list-item"] = isDark ? "#fab283" : "#3b7dd8" - tokens["markdown-list-enumeration"] = isDark ? "#56b6c2" : "#318795" - tokens["markdown-image"] = isDark ? "#fab283" : "#3b7dd8" - tokens["markdown-image-text"] = isDark ? "#56b6c2" : "#318795" - tokens["markdown-code-block"] = isDark ? "#eeeeee" : "#1a1a1a" - } +function content(isDark: boolean, seed: HexColor, scale: HexColor[]): ColorValue { + const base = hexToOklch(seed) + const value = isDark ? (base.l > 0.84 ? shift(seed, { c: 1.18 }) : scale[10]) : scale[10] + return shift(value, { l: isDark ? 0.034 : -0.024, c: isDark ? 1.3 : 1.18 }) +} +export function buildAvatarTokens(ctx: TokenContext, tokens: ResolvedTheme): void { + const { isDark } = ctx tokens["avatar-background-pink"] = isDark ? "#501b3f" : "#feeef8" tokens["avatar-background-mint"] = isDark ? "#033a34" : "#e1fbf4" tokens["avatar-background-orange"] = isDark ? "#5f2a06" : "#fff1e7" @@ -425,18 +650,109 @@ export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): Res tokens["avatar-text-purple"] = isDark ? "#9d5bd2" : "#8445bc" tokens["avatar-text-cyan"] = isDark ? "#369eff" : "#0894b3" tokens["avatar-text-lime"] = isDark ? "#c4f042" : "#5d770d" +} +export function addCompactTokenProperties(ctx: TokenContext, tokens: ResolvedTheme): ResolvedTheme { + const {colors, success, accent, primary, isDark, info, warning, diffAdd, + diffDelete, interactive, amber, error + } = ctx + + tokens["syntax-comment"] = "var(--text-weak)" + tokens["syntax-regexp"] = "var(--text-base)" + tokens["syntax-string"] = content(isDark, colors.success, success) + tokens["syntax-keyword"] = content(isDark, colors.accent, accent) + tokens["syntax-primitive"] = content(isDark, colors.primary, primary) + tokens["syntax-operator"] = isDark ? "var(--text-weak)" : "var(--text-base)" + tokens["syntax-variable"] = "var(--text-strong)" + tokens["syntax-property"] = content(isDark, colors.info, info) + tokens["syntax-type"] = content(isDark, colors.warning, warning) + tokens["syntax-constant"] = content(isDark, colors.accent, accent) + tokens["syntax-punctuation"] = isDark ? "var(--text-weak)" : "var(--text-base)" + tokens["syntax-object"] = "var(--text-strong)" + tokens["syntax-success"] = success[10] + tokens["syntax-warning"] = amber[10] + tokens["syntax-critical"] = error[10] + tokens["syntax-info"] = content(isDark, colors.info, info) + tokens["syntax-diff-add"] = diffAdd[10] + tokens["syntax-diff-delete"] = diffDelete[10] + tokens["syntax-diff-unknown"] = "#ff0000" + + tokens["markdown-heading"] = content(isDark, colors.primary, primary) + tokens["markdown-text"] = tokens["text-base"] + tokens["markdown-link"] = content(isDark, colors.interactive, interactive) + tokens["markdown-link-text"] = content(isDark, colors.info, info) + tokens["markdown-code"] = content(isDark, colors.success, success) + tokens["markdown-block-quote"] = content(isDark, colors.warning, warning) + tokens["markdown-emph"] = content(isDark, colors.warning, warning) + tokens["markdown-strong"] = content(isDark, colors.accent, accent) + tokens["markdown-horizontal-rule"] = tokens["border-base"] + tokens["markdown-list-item"] = content(isDark, colors.interactive, interactive) + tokens["markdown-list-enumeration"] = content(isDark, colors.info, info) + tokens["markdown-image"] = content(isDark, colors.interactive, interactive) + tokens["markdown-image-text"] = content(isDark, colors.info, info) + tokens["markdown-code-block"] = tokens["text-base"] + return tokens +} + +const NON_COMPACT_HEX_TOKENS: Record = { + "syntax-string": ["#00ceb9", "#006656"], + "syntax-primitive": ["#ffba92", "#fb4804"], + "syntax-property": ["#ff9ae2", "#ed6dc8"], + "syntax-type": ["#ecf58c", "#596600"], + "syntax-constant": ["#93e9f6", "#007b80"], + "syntax-info": ["#93e9f6", "#0092a8"], + "markdown-heading": ["#9d7cd8", "#d68c27"], + "markdown-text": ["#eeeeee", "#1a1a1a"], + "markdown-link": ["#fab283", "#3b7dd8"], + "markdown-link-text": ["#56b6c2", "#318795"], + "markdown-code": ["#7fd88f", "#3d9a57"], + "markdown-block-quote": ["#e5c07b", "#b0851f"], + "markdown-emph": ["#e5c07b", "#b0851f"], + "markdown-strong": ["#f5a742", "#d68c27"], + "markdown-horizontal-rule": ["#808080", "#8a8a8a"], + "markdown-list-item": ["#fab283", "#3b7dd8"], + "markdown-list-enumeration": ["#56b6c2", "#318795"], + "markdown-image": ["#fab283", "#3b7dd8"], + "markdown-image-text": ["#56b6c2", "#318795"], + "markdown-code-block": ["#eeeeee", "#1a1a1a"], +} + +function addNonCompactTokenProperties(ctx: TokenContext, tokens: ResolvedTheme): ResolvedTheme { + const { isDark, success, amber, error, diffAdd, diffDelete } = ctx + + for (const [key, [dark, light]] of Object.entries(NON_COMPACT_HEX_TOKENS)) { + tokens[key] = isDark ? dark : light + } + + tokens["syntax-comment"] = "var(--text-weak)" + tokens["syntax-regexp"] = "var(--text-base)" + tokens["syntax-keyword"] = "var(--text-weak)" + tokens["syntax-operator"] = isDark ? "var(--text-weak)" : "var(--text-base)" + tokens["syntax-variable"] = "var(--text-strong)" + tokens["syntax-punctuation"] = isDark ? "var(--text-weak)" : "var(--text-base)" + tokens["syntax-object"] = "var(--text-strong)" + tokens["syntax-success"] = success[10] + tokens["syntax-warning"] = amber[10] + tokens["syntax-critical"] = error[10] + tokens["syntax-diff-add"] = diffAdd[10] + tokens["syntax-diff-delete"] = diffDelete[10] + tokens["syntax-diff-unknown"] = "#ff0000" + return tokens +} + +export function applyOverridePostProcessing( + tokens: ResolvedTheme, + overrides: Record, + colors: ThemeColors, + isDark: boolean, +): void { for (const [key, value] of Object.entries(overrides)) { tokens[key] = value } if (colors.compact && "text-weak" in overrides && !("text-weaker" in overrides)) { const weak = tokens["text-weak"] - if (weak.startsWith("#")) { - tokens["text-weaker"] = shift(weak as HexColor, { l: isDark ? -0.12 : 0.12, c: 0.75 }) - } else { - tokens["text-weaker"] = weak - } + tokens["text-weaker"] = weak.startsWith("#") ? shift(weak as HexColor, { l: isDark ? -0.12 : 0.12, c: 0.75 }) : weak } if (colors.compact) { @@ -451,79 +767,6 @@ export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): Res if (!("text-stronger" in overrides)) { tokens["text-stronger"] = tokens["text-strong"] } - - return tokens -} - -interface ThemeColors { - compact: boolean - neutral: HexColor - ink?: HexColor - primary: HexColor - accent: HexColor - success: HexColor - warning: HexColor - error: HexColor - info: HexColor - interactive: HexColor - diffAdd?: HexColor - diffDelete?: HexColor -} - -function getColors(variant: ThemeVariant): ThemeColors { - const input = variant as { palette?: unknown; seeds?: unknown } - if (input.palette && input.seeds) { - throw new Error("Theme variant cannot define both `palette` and `seeds`") - } - - if (variant.palette) { - return { - compact: true, - neutral: variant.palette.neutral, - ink: variant.palette.ink, - primary: variant.palette.primary, - accent: variant.palette.accent ?? variant.palette.info, - success: variant.palette.success, - warning: variant.palette.warning, - error: variant.palette.error, - info: variant.palette.info, - interactive: variant.palette.interactive ?? variant.palette.primary, - diffAdd: variant.palette.diffAdd, - diffDelete: variant.palette.diffDelete, - } - } - - if (variant.seeds) { - return { - compact: false, - neutral: variant.seeds.neutral, - ink: undefined, - primary: variant.seeds.primary, - accent: variant.seeds.info, - success: variant.seeds.success, - warning: variant.seeds.warning, - error: variant.seeds.error, - info: variant.seeds.info, - interactive: variant.seeds.interactive, - diffAdd: variant.seeds.diffAdd, - diffDelete: variant.seeds.diffDelete, - } - } - - throw new Error("Theme variant requires `palette` or `seeds`") -} - -function generateNeutralAlphaScale(neutralScale: HexColor[], isDark: boolean): HexColor[] { - const alphas = isDark - ? [0.038, 0.066, 0.1, 0.142, 0.19, 0.252, 0.334, 0.446, 0.58, 0.718, 0.854, 0.985] - : [0.03, 0.06, 0.1, 0.145, 0.2, 0.265, 0.35, 0.47, 0.61, 0.74, 0.86, 0.97] - - return alphas.map((alpha) => blend(neutralScale[11], neutralScale[0], alpha)) -} - -function getHex(value: ColorValue | undefined): HexColor | undefined { - if (!value?.startsWith("#")) return - return value as HexColor } export function resolveTheme(theme: DesktopTheme): { light: ResolvedTheme; dark: ResolvedTheme } {