@silvery/theme 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/package.json +36 -0
  2. package/src/ThemeContext.tsx +62 -0
  3. package/src/alias.ts +94 -0
  4. package/src/auto-generate.ts +126 -0
  5. package/src/builder.ts +218 -0
  6. package/src/cli.ts +275 -0
  7. package/src/color.ts +142 -0
  8. package/src/contrast.ts +75 -0
  9. package/src/css.ts +51 -0
  10. package/src/derive.ts +167 -0
  11. package/src/detect.ts +263 -0
  12. package/src/export/base16.ts +64 -0
  13. package/src/generate.ts +79 -0
  14. package/src/generators.ts +255 -0
  15. package/src/import/base16.ts +150 -0
  16. package/src/import/types.ts +47 -0
  17. package/src/index.ts +2 -0
  18. package/src/palettes/ayu.ts +92 -0
  19. package/src/palettes/catppuccin.ts +118 -0
  20. package/src/palettes/dracula.ts +34 -0
  21. package/src/palettes/edge.ts +63 -0
  22. package/src/palettes/everforest.ts +63 -0
  23. package/src/palettes/gruvbox.ts +62 -0
  24. package/src/palettes/horizon.ts +35 -0
  25. package/src/palettes/index.ts +293 -0
  26. package/src/palettes/kanagawa.ts +91 -0
  27. package/src/palettes/material.ts +64 -0
  28. package/src/palettes/modus.ts +63 -0
  29. package/src/palettes/monokai.ts +64 -0
  30. package/src/palettes/moonfly.ts +35 -0
  31. package/src/palettes/nightfly.ts +35 -0
  32. package/src/palettes/nightfox.ts +63 -0
  33. package/src/palettes/nord.ts +34 -0
  34. package/src/palettes/one-dark.ts +34 -0
  35. package/src/palettes/oxocarbon.ts +63 -0
  36. package/src/palettes/palenight.ts +36 -0
  37. package/src/palettes/rose-pine.ts +90 -0
  38. package/src/palettes/snazzy.ts +35 -0
  39. package/src/palettes/solarized.ts +62 -0
  40. package/src/palettes/sonokai.ts +35 -0
  41. package/src/palettes/tokyo-night.ts +90 -0
  42. package/src/resolve.ts +44 -0
  43. package/src/state.ts +58 -0
  44. package/src/theme.ts +148 -0
  45. package/src/types.ts +223 -0
  46. package/src/validate-theme.ts +100 -0
  47. package/src/validate.ts +56 -0
package/src/cli.ts ADDED
@@ -0,0 +1,275 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * @silvery/theme CLI — browse and preview built-in themes.
4
+ *
5
+ * Usage:
6
+ * bun theme # Interactive theme browser
7
+ * bun theme list # List all themes
8
+ * bun theme show <name> # Show a specific theme
9
+ * bun theme json <name> # Output theme as JSON
10
+ */
11
+
12
+ import { builtinPalettes, getThemeByName } from "./palettes"
13
+ import { deriveTheme } from "./derive"
14
+ import type { Theme, ColorPalette } from "./types"
15
+
16
+ // ── ANSI helpers ─────────────────────────────────────────────────────
17
+
18
+ function hexToRgb(hex: string): [number, number, number] {
19
+ const h = hex.replace("#", "")
20
+ return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)]
21
+ }
22
+
23
+ function fg(hex: string, text: string): string {
24
+ const [r, g, b] = hexToRgb(hex)
25
+ return `\x1b[38;2;${r};${g};${b}m${text}\x1b[0m`
26
+ }
27
+
28
+ function bg(hex: string, text: string): string {
29
+ const [r, g, b] = hexToRgb(hex)
30
+ return `\x1b[48;2;${r};${g};${b}m${text}\x1b[0m`
31
+ }
32
+
33
+ function swatch(hex: string): string {
34
+ return bg(hex, " ")
35
+ }
36
+
37
+ // ── Commands ─────────────────────────────────────────────────────────
38
+
39
+ function listThemes(): void {
40
+ const names = Object.keys(builtinPalettes).sort()
41
+ console.log(`\n ${names.length} built-in palettes:\n`)
42
+
43
+ for (const name of names) {
44
+ const palette = builtinPalettes[name]!
45
+ const theme = deriveTheme(palette)
46
+ const swatches = [
47
+ swatch(palette.background),
48
+ swatch(palette.red),
49
+ swatch(palette.green),
50
+ swatch(palette.yellow),
51
+ swatch(palette.blue),
52
+ swatch(palette.magenta),
53
+ swatch(palette.cyan),
54
+ swatch(palette.foreground),
55
+ ].join("")
56
+ const mode = palette.dark !== false ? "dark" : "light"
57
+ console.log(` ${swatches} ${name.padEnd(24)} ${fg(palette.foreground, mode)}`)
58
+ }
59
+ console.log()
60
+ }
61
+
62
+ function showTheme(name: string): void {
63
+ const palette = builtinPalettes[name]
64
+ if (!palette) {
65
+ console.error(`Unknown theme: ${name}`)
66
+ console.error(`Available: ${Object.keys(builtinPalettes).sort().join(", ")}`)
67
+ process.exit(1)
68
+ }
69
+
70
+ const theme = deriveTheme(palette)
71
+ const line = "─".repeat(50)
72
+
73
+ console.log(`\n ${fg(palette.foreground, name)}`)
74
+ console.log(` ${fg(palette.brightBlack ?? palette.white, line)}\n`)
75
+
76
+ // 16-color ANSI palette
77
+ console.log(" ANSI palette:")
78
+ const ansiColors = [
79
+ palette.black,
80
+ palette.red,
81
+ palette.green,
82
+ palette.yellow,
83
+ palette.blue,
84
+ palette.magenta,
85
+ palette.cyan,
86
+ palette.white,
87
+ ]
88
+ const brightColors = [
89
+ palette.brightBlack,
90
+ palette.brightRed,
91
+ palette.brightGreen,
92
+ palette.brightYellow,
93
+ palette.brightBlue,
94
+ palette.brightMagenta,
95
+ palette.brightCyan,
96
+ palette.brightWhite,
97
+ ]
98
+ console.log(" " + ansiColors.map((c) => bg(c, " ")).join(""))
99
+ console.log(" " + brightColors.map((c) => bg(c, " ")).join(""))
100
+
101
+ // Special colors
102
+ console.log(`\n Special:`)
103
+ console.log(` ${swatch(palette.background)} background ${swatch(palette.foreground)} foreground`)
104
+ console.log(` ${swatch(palette.cursorColor)} cursor ${swatch(palette.selectionBackground)} selection`)
105
+
106
+ // Semantic tokens
107
+ console.log(`\n Semantic tokens:`)
108
+ const tokens: Array<[string, string]> = [
109
+ ["primary", theme.primary],
110
+ ["secondary", theme.secondary],
111
+ ["accent", theme.accent],
112
+ ["error", theme.error],
113
+ ["warning", theme.warning],
114
+ ["success", theme.success],
115
+ ["info", theme.info],
116
+ ["border", theme.border],
117
+ ["link", theme.link],
118
+ ["surface", theme.surface],
119
+ ]
120
+ for (const [label, color] of tokens) {
121
+ if (color.startsWith("#")) {
122
+ console.log(` ${swatch(color)} ${label}`)
123
+ }
124
+ }
125
+
126
+ // Preview
127
+ console.log(`\n Preview:`)
128
+ const previewBg = palette.background
129
+ const previewFg = palette.foreground
130
+ console.log(` ${bg(previewBg, fg(previewFg, ` Hello from ${name}! `))}`)
131
+ console.log(
132
+ ` ${bg(previewBg, fg(theme.primary.startsWith("#") ? theme.primary : palette.blue, ` Primary text `))}`,
133
+ )
134
+ console.log(
135
+ ` ${bg(previewBg, fg(theme.success.startsWith("#") ? theme.success : palette.green, ` Success message `))}`,
136
+ )
137
+ console.log(
138
+ ` ${bg(previewBg, fg(theme.error.startsWith("#") ? theme.error : palette.red, ` Error message `))}`,
139
+ )
140
+ console.log()
141
+ }
142
+
143
+ function showJson(name: string): void {
144
+ const palette = builtinPalettes[name]
145
+ if (!palette) {
146
+ console.error(`Unknown theme: ${name}`)
147
+ process.exit(1)
148
+ }
149
+ const theme = deriveTheme(palette)
150
+ console.log(JSON.stringify(theme, null, 2))
151
+ }
152
+
153
+ async function interactiveBrowser(): Promise<void> {
154
+ const names = Object.keys(builtinPalettes).sort()
155
+ let cursor = 0
156
+
157
+ // Save screen, hide cursor
158
+ process.stdout.write("\x1b[?1049h\x1b[?25l")
159
+
160
+ function render(): void {
161
+ const { rows = 24, columns = 80 } = process.stdout
162
+ const visibleRows = rows - 4
163
+ const startIdx = Math.max(0, cursor - Math.floor(visibleRows / 2))
164
+ const endIdx = Math.min(names.length, startIdx + visibleRows)
165
+
166
+ // Clear and move to top
167
+ process.stdout.write("\x1b[2J\x1b[H")
168
+
169
+ // Header
170
+ process.stdout.write(
171
+ ` \x1b[1m@silvery/theme\x1b[0m — ${names.length} palettes (j/k navigate, Enter show, q quit)\n\n`,
172
+ )
173
+
174
+ // List
175
+ for (let i = startIdx; i < endIdx; i++) {
176
+ const name = names[i]!
177
+ const palette = builtinPalettes[name]!
178
+ const swatches = [
179
+ swatch(palette.background),
180
+ swatch(palette.red),
181
+ swatch(palette.green),
182
+ swatch(palette.yellow),
183
+ swatch(palette.blue),
184
+ swatch(palette.magenta),
185
+ swatch(palette.cyan),
186
+ swatch(palette.foreground),
187
+ ].join("")
188
+
189
+ if (i === cursor) {
190
+ process.stdout.write(` \x1b[7m ${swatches} ${name.padEnd(28)}\x1b[0m\n`)
191
+ } else {
192
+ process.stdout.write(` ${swatches} ${name}\n`)
193
+ }
194
+ }
195
+
196
+ // Preview of selected theme
197
+ const selectedName = names[cursor]!
198
+ const palette = builtinPalettes[selectedName]!
199
+ const previewBg = palette.background
200
+ const previewFg = palette.foreground
201
+ process.stdout.write(`\n ${bg(previewBg, fg(previewFg, ` ${selectedName} `))}`)
202
+ }
203
+
204
+ render()
205
+
206
+ // Raw mode for key input
207
+ if (process.stdin.isTTY) {
208
+ process.stdin.setRawMode(true)
209
+ }
210
+ process.stdin.resume()
211
+
212
+ return new Promise<void>((resolve) => {
213
+ process.stdin.on("data", (data: Buffer) => {
214
+ const key = data.toString()
215
+
216
+ if (key === "q" || key === "\x1b" || key === "\x03") {
217
+ // Restore screen, show cursor
218
+ process.stdout.write("\x1b[?1049l\x1b[?25h")
219
+ if (process.stdin.isTTY) process.stdin.setRawMode(false)
220
+ process.stdin.pause()
221
+ resolve()
222
+ return
223
+ }
224
+
225
+ if (key === "j" || key === "\x1b[B") {
226
+ cursor = Math.min(cursor + 1, names.length - 1)
227
+ } else if (key === "k" || key === "\x1b[A") {
228
+ cursor = Math.max(cursor - 1, 0)
229
+ } else if (key === "g") {
230
+ cursor = 0
231
+ } else if (key === "G") {
232
+ cursor = names.length - 1
233
+ } else if (key === "\r" || key === "\n") {
234
+ // Show detailed view
235
+ process.stdout.write("\x1b[?1049l\x1b[?25h")
236
+ if (process.stdin.isTTY) process.stdin.setRawMode(false)
237
+ process.stdin.pause()
238
+ showTheme(names[cursor]!)
239
+ resolve()
240
+ return
241
+ }
242
+
243
+ render()
244
+ })
245
+ })
246
+ }
247
+
248
+ // ── Main ─────────────────────────────────────────────────────────────
249
+
250
+ const [cmd, ...args] = process.argv.slice(2)
251
+
252
+ switch (cmd) {
253
+ case "list":
254
+ listThemes()
255
+ break
256
+ case "show":
257
+ showTheme(args[0] ?? "")
258
+ break
259
+ case "json":
260
+ showJson(args[0] ?? "")
261
+ break
262
+ case undefined:
263
+ case "view":
264
+ await interactiveBrowser()
265
+ break
266
+ default:
267
+ // Treat as theme name: bun theme catppuccin-mocha
268
+ if (builtinPalettes[cmd]) {
269
+ showTheme(cmd)
270
+ } else {
271
+ console.error(`Unknown command: ${cmd}`)
272
+ console.error("Usage: bun theme [list|show <name>|json <name>|view]")
273
+ process.exit(1)
274
+ }
275
+ }
package/src/color.ts ADDED
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Color manipulation utilities.
3
+ *
4
+ * Operates in OKLCH for perceptual uniformity — hue rotations look right,
5
+ * lightness changes feel linear, chroma is preserved.
6
+ *
7
+ * Currently uses simple RGB blending as a foundation. Full OKLCH conversion
8
+ * will be added when needed for advanced derivation (shade generation,
9
+ * palette generation from minimal input).
10
+ */
11
+
12
+ // ============================================================================
13
+ // Hex ↔ RGB Parsing
14
+ // ============================================================================
15
+
16
+ /** Parse a hex color string to [r, g, b] (0-255). Returns null for non-hex. */
17
+ export function hexToRgb(hex: string): [number, number, number] | null {
18
+ const match = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex)
19
+ if (!match) return null
20
+ return [parseInt(match[1]!, 16), parseInt(match[2]!, 16), parseInt(match[3]!, 16)]
21
+ }
22
+
23
+ /** Convert [r, g, b] (0-255) to hex string. */
24
+ export function rgbToHex(r: number, g: number, b: number): string {
25
+ const clamp = (n: number) => Math.max(0, Math.min(255, Math.round(n)))
26
+ return `#${clamp(r).toString(16).padStart(2, "0")}${clamp(g).toString(16).padStart(2, "0")}${clamp(b).toString(16).padStart(2, "0")}`.toUpperCase()
27
+ }
28
+
29
+ // ============================================================================
30
+ // Color Manipulation
31
+ // ============================================================================
32
+
33
+ /**
34
+ * Blend two hex colors. t=0 returns a, t=1 returns b.
35
+ * For non-hex inputs (ANSI names), returns `a` unchanged.
36
+ */
37
+ export function blend(a: string, b: string, t: number): string {
38
+ const rgbA = hexToRgb(a)
39
+ const rgbB = hexToRgb(b)
40
+ if (!rgbA || !rgbB) return a
41
+
42
+ return rgbToHex(
43
+ rgbA[0] + (rgbB[0] - rgbA[0]) * t,
44
+ rgbA[1] + (rgbB[1] - rgbA[1]) * t,
45
+ rgbA[2] + (rgbB[2] - rgbA[2]) * t,
46
+ )
47
+ }
48
+
49
+ /**
50
+ * Brighten a hex color. amount=0.1 adds 10% lightness toward white.
51
+ * For non-hex inputs (ANSI names), returns the color unchanged.
52
+ */
53
+ export function brighten(color: string, amount: number): string {
54
+ return blend(color, "#FFFFFF", amount)
55
+ }
56
+
57
+ /**
58
+ * Darken a hex color. amount=0.1 adds 10% darkness toward black.
59
+ * For non-hex inputs (ANSI names), returns the color unchanged.
60
+ */
61
+ export function darken(color: string, amount: number): string {
62
+ return blend(color, "#000000", amount)
63
+ }
64
+
65
+ /**
66
+ * Pick black or white text for readability on the given background.
67
+ * Uses relative luminance (WCAG formula).
68
+ */
69
+ export function contrastFg(bg: string): "#000000" | "#FFFFFF" {
70
+ const rgb = hexToRgb(bg)
71
+ if (!rgb) return "#FFFFFF" // default to white for non-hex
72
+
73
+ // Relative luminance per WCAG 2.0
74
+ const [r, g, b] = rgb.map((c) => {
75
+ const s = c / 255
76
+ return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4)
77
+ })
78
+ const luminance = 0.2126 * r! + 0.7152 * g! + 0.0722 * b!
79
+ return luminance > 0.179 ? "#000000" : "#FFFFFF"
80
+ }
81
+
82
+ // ============================================================================
83
+ // HSL Utilities
84
+ // ============================================================================
85
+
86
+ export type HSL = [number, number, number] // [h: 0-360, s: 0-1, l: 0-1]
87
+
88
+ export function rgbToHsl(r: number, g: number, b: number): HSL {
89
+ r /= 255
90
+ g /= 255
91
+ b /= 255
92
+ const max = Math.max(r, g, b),
93
+ min = Math.min(r, g, b)
94
+ const l = (max + min) / 2
95
+ if (max === min) return [0, 0, l]
96
+ const d = max - min
97
+ const s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
98
+ let h = 0
99
+ if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6
100
+ else if (max === g) h = ((b - r) / d + 2) / 6
101
+ else h = ((r - g) / d + 4) / 6
102
+ return [h * 360, s, l]
103
+ }
104
+
105
+ export function hslToHex(h: number, s: number, l: number): string {
106
+ h = ((h % 360) + 360) % 360
107
+ const a = s * Math.min(l, 1 - l)
108
+ const f = (n: number) => {
109
+ const k = (n + h / 30) % 12
110
+ return l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1)
111
+ }
112
+ return rgbToHex(f(0) * 255, f(8) * 255, f(4) * 255)
113
+ }
114
+
115
+ export function hexToHsl(hex: string): HSL | null {
116
+ const rgb = hexToRgb(hex)
117
+ if (!rgb) return null
118
+ return rgbToHsl(rgb[0], rgb[1], rgb[2])
119
+ }
120
+
121
+ /**
122
+ * Desaturate a hex color by reducing saturation.
123
+ * amount=0.4 reduces saturation by 40%.
124
+ * For non-hex inputs, returns the color unchanged.
125
+ */
126
+ export function desaturate(color: string, amount: number): string {
127
+ const hsl = hexToHsl(color)
128
+ if (!hsl) return color
129
+ const [h, s, l] = hsl
130
+ return hslToHex(h, s * (1 - amount), l)
131
+ }
132
+
133
+ /**
134
+ * Get the complementary color (180° hue rotation).
135
+ * For non-hex inputs, returns the color unchanged.
136
+ */
137
+ export function complement(color: string): string {
138
+ const hsl = hexToHsl(color)
139
+ if (!hsl) return color
140
+ const [h, s, l] = hsl
141
+ return hslToHex(h + 180, s, l)
142
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * WCAG 2.1 contrast checking — compute contrast ratios between colors.
3
+ *
4
+ * Uses the relative luminance formula from WCAG 2.1 to calculate
5
+ * contrast ratios and check AA/AAA compliance levels.
6
+ */
7
+
8
+ import { hexToRgb } from "./color"
9
+
10
+ /** Result of a contrast check between two colors. */
11
+ export interface ContrastResult {
12
+ /** The contrast ratio (1:1 to 21:1), expressed as a single number (e.g. 4.5). */
13
+ ratio: number
14
+ /** Whether the ratio meets WCAG AA for normal text (>= 4.5:1). */
15
+ aa: boolean
16
+ /** Whether the ratio meets WCAG AAA for normal text (>= 7:1). */
17
+ aaa: boolean
18
+ }
19
+
20
+ /**
21
+ * Compute relative luminance of an sRGB color channel value (0-255).
22
+ * Per WCAG 2.1: linearize, then weight by standard coefficients.
23
+ */
24
+ function channelLuminance(c: number): number {
25
+ const s = c / 255
26
+ return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4)
27
+ }
28
+
29
+ /**
30
+ * Compute relative luminance of a hex color.
31
+ * Returns a value between 0 (darkest) and 1 (lightest).
32
+ */
33
+ function relativeLuminance(hex: string): number | null {
34
+ const rgb = hexToRgb(hex)
35
+ if (!rgb) return null
36
+ return 0.2126 * channelLuminance(rgb[0]) + 0.7152 * channelLuminance(rgb[1]) + 0.0722 * channelLuminance(rgb[2])
37
+ }
38
+
39
+ /**
40
+ * Check contrast ratio between foreground and background colors.
41
+ *
42
+ * Uses the WCAG 2.1 relative luminance formula to compute the contrast
43
+ * ratio and check AA (>= 4.5:1) and AAA (>= 7:1) compliance for normal text.
44
+ *
45
+ * @param fg - Foreground hex color (e.g. "#FFFFFF")
46
+ * @param bg - Background hex color (e.g. "#000000")
47
+ * @returns Contrast ratio and AA/AAA pass/fail, or null if colors are not valid hex
48
+ *
49
+ * @example
50
+ * ```typescript
51
+ * const result = checkContrast("#FFFFFF", "#000000")
52
+ * // { ratio: 21, aa: true, aaa: true }
53
+ *
54
+ * const poor = checkContrast("#777777", "#888888")
55
+ * // { ratio: ~1.3, aa: false, aaa: false }
56
+ * ```
57
+ */
58
+ export function checkContrast(fg: string, bg: string): ContrastResult | null {
59
+ const fgLum = relativeLuminance(fg)
60
+ const bgLum = relativeLuminance(bg)
61
+ if (fgLum === null || bgLum === null) return null
62
+
63
+ const lighter = Math.max(fgLum, bgLum)
64
+ const darker = Math.min(fgLum, bgLum)
65
+ const ratio = (lighter + 0.05) / (darker + 0.05)
66
+
67
+ // Round to 2 decimal places for practical use
68
+ const roundedRatio = Math.round(ratio * 100) / 100
69
+
70
+ return {
71
+ ratio: roundedRatio,
72
+ aa: roundedRatio >= 4.5,
73
+ aaa: roundedRatio >= 7,
74
+ }
75
+ }
package/src/css.ts ADDED
@@ -0,0 +1,51 @@
1
+ /**
2
+ * CSS variables export — convert theme tokens to CSS custom properties.
3
+ *
4
+ * Generates a flat map of CSS custom property names to color values,
5
+ * suitable for applying as inline styles or injecting into a stylesheet.
6
+ */
7
+
8
+ import type { Theme } from "./types"
9
+ import { THEME_TOKEN_KEYS } from "./validate-theme"
10
+
11
+ /**
12
+ * Convert a Theme to CSS custom properties.
13
+ *
14
+ * Token names are kebab-cased with a `--` prefix:
15
+ * - `bg` → `--bg`
16
+ * - `surfacebg` → `--surfacebg`
17
+ * - `disabledfg` → `--disabledfg`
18
+ * - Palette entries: `--color0` through `--color15`
19
+ *
20
+ * @param theme - The theme to convert
21
+ * @returns A record mapping CSS custom property names to color values
22
+ *
23
+ * @example
24
+ * ```typescript
25
+ * const vars = themeToCSSVars(myTheme)
26
+ * // { "--bg": "#1E1E2E", "--fg": "#CDD6F4", "--primary": "#F9E2AF", ... }
27
+ *
28
+ * // Apply to an element:
29
+ * Object.assign(element.style, vars)
30
+ * ```
31
+ */
32
+ export function themeToCSSVars(theme: Theme): Record<string, string> {
33
+ const vars: Record<string, string> = {}
34
+
35
+ // Semantic tokens
36
+ for (const key of THEME_TOKEN_KEYS) {
37
+ const value = theme[key as keyof Theme]
38
+ if (typeof value === "string") {
39
+ vars[`--${key}`] = value
40
+ }
41
+ }
42
+
43
+ // Palette colors
44
+ if (theme.palette) {
45
+ for (let i = 0; i < theme.palette.length; i++) {
46
+ vars[`--color${i}`] = theme.palette[i]!
47
+ }
48
+ }
49
+
50
+ return vars
51
+ }
package/src/derive.ts ADDED
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Theme derivation — transforms a ColorPalette into a Theme.
3
+ *
4
+ * All inputs ultimately flow through deriveTheme():
5
+ * ColorPalette (22) → deriveTheme() → Theme (33)
6
+ *
7
+ * Supports two modes:
8
+ * - truecolor (default): rich derivation with blends, contrast pairing, OKLCH
9
+ * - ansi16: direct aliases into the 22 palette colors (no blending)
10
+ */
11
+
12
+ import { blend, contrastFg, desaturate, complement } from "./color"
13
+ import type { ColorPalette, Theme } from "./types"
14
+
15
+ /**
16
+ * Derive a complete Theme from a ColorPalette.
17
+ *
18
+ * The palette provides 22 terminal colors. This function maps them to
19
+ * 33 semantic tokens + a 16-color content palette.
20
+ *
21
+ * @param palette - The 22-color terminal palette
22
+ * @param mode - "truecolor" (default) for rich derivation, "ansi16" for direct aliases
23
+ */
24
+ export function deriveTheme(palette: ColorPalette, mode: "ansi16" | "truecolor" = "truecolor"): Theme {
25
+ if (mode === "ansi16") return deriveAnsi16Theme(palette)
26
+ return deriveTruecolorTheme(palette)
27
+ }
28
+
29
+ function deriveTruecolorTheme(p: ColorPalette): Theme {
30
+ const dark = p.dark ?? true
31
+ const primaryColor = dark ? p.yellow : p.blue
32
+
33
+ return {
34
+ name: p.name ?? (dark ? "derived-dark" : "derived-light"),
35
+
36
+ // ── Root pair ─────────────────────────────────────────────────
37
+ bg: p.background,
38
+ fg: p.foreground,
39
+
40
+ // ── Surface pairs (base = text, *bg = background) ──────────
41
+ muted: blend(p.foreground, p.background, 0.4),
42
+ mutedbg: blend(p.background, p.foreground, 0.04),
43
+ surface: p.foreground,
44
+ surfacebg: blend(p.background, p.foreground, 0.05),
45
+ popover: p.foreground,
46
+ popoverbg: blend(p.background, p.foreground, 0.08),
47
+ inverse: contrastFg(blend(p.foreground, p.background, 0.1)),
48
+ inversebg: blend(p.foreground, p.background, 0.1),
49
+ cursor: p.cursorText,
50
+ cursorbg: p.cursorColor,
51
+ selection: p.selectionForeground,
52
+ selectionbg: p.selectionBackground,
53
+
54
+ // ── Accent pairs (base = area bg, *fg = text on area) ──────
55
+ primary: primaryColor,
56
+ primaryfg: contrastFg(primaryColor),
57
+ secondary: desaturate(primaryColor, 0.4),
58
+ secondaryfg: contrastFg(desaturate(primaryColor, 0.4)),
59
+ accent: complement(primaryColor),
60
+ accentfg: contrastFg(complement(primaryColor)),
61
+ error: p.red,
62
+ errorfg: contrastFg(p.red),
63
+ warning: p.yellow,
64
+ warningfg: contrastFg(p.yellow),
65
+ success: p.green,
66
+ successfg: contrastFg(p.green),
67
+ info: p.cyan,
68
+ infofg: contrastFg(p.cyan),
69
+
70
+ // ── Standalone ───────────────────────────────────────────────
71
+ border: blend(p.background, p.foreground, 0.15),
72
+ inputborder: blend(p.background, p.foreground, 0.25),
73
+ focusborder: dark ? p.brightBlue : p.blue,
74
+ link: p.blue,
75
+ disabledfg: blend(p.foreground, p.background, 0.5),
76
+
77
+ // ── 16 palette passthrough ───────────────────────────────────
78
+ palette: [
79
+ p.black,
80
+ p.red,
81
+ p.green,
82
+ p.yellow,
83
+ p.blue,
84
+ p.magenta,
85
+ p.cyan,
86
+ p.white,
87
+ p.brightBlack,
88
+ p.brightRed,
89
+ p.brightGreen,
90
+ p.brightYellow,
91
+ p.brightBlue,
92
+ p.brightMagenta,
93
+ p.brightCyan,
94
+ p.brightWhite,
95
+ ],
96
+ }
97
+ }
98
+
99
+ function deriveAnsi16Theme(p: ColorPalette): Theme {
100
+ const dark = p.dark ?? true
101
+ const primaryColor = dark ? p.yellow : p.blue
102
+
103
+ return {
104
+ name: p.name ?? (dark ? "derived-ansi16-dark" : "derived-ansi16-light"),
105
+
106
+ // ── Root pair ─────────────────────────────────────────────────
107
+ bg: p.background,
108
+ fg: p.foreground,
109
+
110
+ // ── Surface pairs (base = text, *bg = background) ──────────
111
+ muted: p.white,
112
+ mutedbg: p.black,
113
+ surface: p.foreground,
114
+ surfacebg: p.black,
115
+ popover: p.foreground,
116
+ popoverbg: p.black,
117
+ inverse: p.black,
118
+ inversebg: p.brightWhite,
119
+ cursor: p.cursorText,
120
+ cursorbg: p.cursorColor,
121
+ selection: p.selectionForeground,
122
+ selectionbg: p.selectionBackground,
123
+
124
+ // ── Accent pairs (base = area bg, *fg = text on area) ──────
125
+ primary: primaryColor,
126
+ primaryfg: p.black,
127
+ secondary: p.magenta,
128
+ secondaryfg: p.black,
129
+ accent: p.cyan,
130
+ accentfg: p.black,
131
+ error: dark ? p.brightRed : p.red,
132
+ errorfg: p.black,
133
+ warning: p.yellow,
134
+ warningfg: p.black,
135
+ success: dark ? p.brightGreen : p.green,
136
+ successfg: p.black,
137
+ info: p.cyan,
138
+ infofg: p.black,
139
+
140
+ // ── Standalone ───────────────────────────────────────────────
141
+ border: p.brightBlack,
142
+ inputborder: p.brightBlack,
143
+ focusborder: dark ? p.brightBlue : p.blue,
144
+ link: dark ? p.brightBlue : p.blue,
145
+ disabledfg: p.brightBlack,
146
+
147
+ // ── 16 palette passthrough ───────────────────────────────────
148
+ palette: [
149
+ p.black,
150
+ p.red,
151
+ p.green,
152
+ p.yellow,
153
+ p.blue,
154
+ p.magenta,
155
+ p.cyan,
156
+ p.white,
157
+ p.brightBlack,
158
+ p.brightRed,
159
+ p.brightGreen,
160
+ p.brightYellow,
161
+ p.brightBlue,
162
+ p.brightMagenta,
163
+ p.brightCyan,
164
+ p.brightWhite,
165
+ ],
166
+ }
167
+ }