@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.
- package/package.json +36 -0
- package/src/ThemeContext.tsx +62 -0
- package/src/alias.ts +94 -0
- package/src/auto-generate.ts +126 -0
- package/src/builder.ts +218 -0
- package/src/cli.ts +275 -0
- package/src/color.ts +142 -0
- package/src/contrast.ts +75 -0
- package/src/css.ts +51 -0
- package/src/derive.ts +167 -0
- package/src/detect.ts +263 -0
- package/src/export/base16.ts +64 -0
- package/src/generate.ts +79 -0
- package/src/generators.ts +255 -0
- package/src/import/base16.ts +150 -0
- package/src/import/types.ts +47 -0
- package/src/index.ts +2 -0
- package/src/palettes/ayu.ts +92 -0
- package/src/palettes/catppuccin.ts +118 -0
- package/src/palettes/dracula.ts +34 -0
- package/src/palettes/edge.ts +63 -0
- package/src/palettes/everforest.ts +63 -0
- package/src/palettes/gruvbox.ts +62 -0
- package/src/palettes/horizon.ts +35 -0
- package/src/palettes/index.ts +293 -0
- package/src/palettes/kanagawa.ts +91 -0
- package/src/palettes/material.ts +64 -0
- package/src/palettes/modus.ts +63 -0
- package/src/palettes/monokai.ts +64 -0
- package/src/palettes/moonfly.ts +35 -0
- package/src/palettes/nightfly.ts +35 -0
- package/src/palettes/nightfox.ts +63 -0
- package/src/palettes/nord.ts +34 -0
- package/src/palettes/one-dark.ts +34 -0
- package/src/palettes/oxocarbon.ts +63 -0
- package/src/palettes/palenight.ts +36 -0
- package/src/palettes/rose-pine.ts +90 -0
- package/src/palettes/snazzy.ts +35 -0
- package/src/palettes/solarized.ts +62 -0
- package/src/palettes/sonokai.ts +35 -0
- package/src/palettes/tokyo-night.ts +90 -0
- package/src/resolve.ts +44 -0
- package/src/state.ts +58 -0
- package/src/theme.ts +148 -0
- package/src/types.ts +223 -0
- package/src/validate-theme.ts +100 -0
- 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
|
+
}
|
package/src/contrast.ts
ADDED
|
@@ -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
|
+
}
|