@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/detect.ts
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal palette auto-detection via OSC queries.
|
|
3
|
+
*
|
|
4
|
+
* Detects the terminal's actual colors by querying:
|
|
5
|
+
* - OSC 10: foreground (text) color
|
|
6
|
+
* - OSC 11: background color
|
|
7
|
+
* - OSC 4;0–15: ANSI 16 palette colors
|
|
8
|
+
*
|
|
9
|
+
* Then maps the detected colors to a partial ColorPalette for
|
|
10
|
+
* theme generation via createTheme() or deriveTheme().
|
|
11
|
+
*
|
|
12
|
+
* Supported by: Ghostty, Kitty, WezTerm, iTerm2, foot, Alacritty, xterm
|
|
13
|
+
* NOT supported by: tmux (blocks OSC), basic xterm, CI environments
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { ColorPalette, Theme } from "./types"
|
|
17
|
+
import { deriveTheme } from "./derive"
|
|
18
|
+
import { nord } from "./palettes/nord"
|
|
19
|
+
import { catppuccinLatte } from "./palettes/catppuccin"
|
|
20
|
+
|
|
21
|
+
// silvery is an optional peer dependency — lazy-import to avoid breaking
|
|
22
|
+
// standalone consumers that don't have silvery installed.
|
|
23
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
24
|
+
let _silvery: any = null
|
|
25
|
+
async function getSilvery() {
|
|
26
|
+
if (!_silvery) {
|
|
27
|
+
try {
|
|
28
|
+
const mod = "@silvery/react"
|
|
29
|
+
_silvery = await import(mod)
|
|
30
|
+
} catch {
|
|
31
|
+
throw new Error("Terminal palette detection requires '@silvery/term' to be installed")
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return _silvery as {
|
|
35
|
+
queryBackgroundColor: (
|
|
36
|
+
write: (s: string) => void,
|
|
37
|
+
read: (ms: number) => Promise<string | null>,
|
|
38
|
+
timeout: number,
|
|
39
|
+
) => Promise<string | null>
|
|
40
|
+
queryForegroundColor: (
|
|
41
|
+
write: (s: string) => void,
|
|
42
|
+
read: (ms: number) => Promise<string | null>,
|
|
43
|
+
timeout: number,
|
|
44
|
+
) => Promise<string | null>
|
|
45
|
+
queryMultiplePaletteColors: (indices: number[], write: (s: string) => void) => void
|
|
46
|
+
parsePaletteResponse: (chunk: string) => { index: number; color: string } | null
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Result of terminal palette detection. */
|
|
51
|
+
export interface DetectedPalette {
|
|
52
|
+
/** Terminal foreground color (#RRGGBB), or null if undetected */
|
|
53
|
+
fg: string | null
|
|
54
|
+
/** Terminal background color (#RRGGBB), or null if undetected */
|
|
55
|
+
bg: string | null
|
|
56
|
+
/** ANSI 16 palette colors (index 0-15), null entries = undetected */
|
|
57
|
+
ansi: (string | null)[]
|
|
58
|
+
/** Whether the terminal appears to be dark mode (from bg luminance) */
|
|
59
|
+
dark: boolean
|
|
60
|
+
/** Partial ColorPalette derived from detected colors */
|
|
61
|
+
palette: Partial<ColorPalette>
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Detect the terminal's color palette via OSC queries.
|
|
66
|
+
*
|
|
67
|
+
* Must be called BEFORE entering alternate screen / fullscreen mode,
|
|
68
|
+
* as some terminals don't respond to OSC in alternate screen.
|
|
69
|
+
*
|
|
70
|
+
* @param timeoutMs How long to wait for each response (default 150ms)
|
|
71
|
+
* @returns Detected palette, or null if terminal doesn't support OSC queries
|
|
72
|
+
*/
|
|
73
|
+
export async function detectTerminalPalette(timeoutMs = 150): Promise<DetectedPalette | null> {
|
|
74
|
+
const stdin = process.stdin
|
|
75
|
+
const stdout = process.stdout
|
|
76
|
+
|
|
77
|
+
if (!stdin.isTTY || !stdout.isTTY) return null
|
|
78
|
+
|
|
79
|
+
const wasRaw = stdin.isRaw
|
|
80
|
+
if (!wasRaw) stdin.setRawMode(true)
|
|
81
|
+
|
|
82
|
+
// Buffer for collecting responses
|
|
83
|
+
let buffer = ""
|
|
84
|
+
const onData = (chunk: Buffer) => {
|
|
85
|
+
buffer += chunk.toString()
|
|
86
|
+
}
|
|
87
|
+
stdin.on("data", onData)
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
const write = (s: string) => {
|
|
91
|
+
stdout.write(s)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const read = (ms: number): Promise<string | null> =>
|
|
95
|
+
new Promise((resolve) => {
|
|
96
|
+
if (buffer.length > 0) {
|
|
97
|
+
const result = buffer
|
|
98
|
+
buffer = ""
|
|
99
|
+
resolve(result)
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const timer = setTimeout(() => {
|
|
104
|
+
resolve(buffer.length > 0 ? buffer : null)
|
|
105
|
+
buffer = ""
|
|
106
|
+
}, ms)
|
|
107
|
+
|
|
108
|
+
const check = (_chunk: Buffer) => {
|
|
109
|
+
clearTimeout(timer)
|
|
110
|
+
stdin.removeListener("data", check)
|
|
111
|
+
const result = buffer
|
|
112
|
+
buffer = ""
|
|
113
|
+
resolve(result)
|
|
114
|
+
}
|
|
115
|
+
stdin.on("data", check)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
const silvery = await getSilvery()
|
|
119
|
+
|
|
120
|
+
// Query bg and fg first
|
|
121
|
+
const bg = await silvery.queryBackgroundColor(write, read, timeoutMs)
|
|
122
|
+
const fg = await silvery.queryForegroundColor(write, read, timeoutMs)
|
|
123
|
+
|
|
124
|
+
// Query ANSI 16 palette
|
|
125
|
+
const ansi: (string | null)[] = new Array(16).fill(null)
|
|
126
|
+
silvery.queryMultiplePaletteColors(
|
|
127
|
+
Array.from({ length: 16 }, (_, i) => i),
|
|
128
|
+
write,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
// Wait for responses
|
|
132
|
+
await new Promise((resolve) => setTimeout(resolve, timeoutMs))
|
|
133
|
+
|
|
134
|
+
// Parse any buffered palette responses
|
|
135
|
+
const remaining = buffer
|
|
136
|
+
buffer = ""
|
|
137
|
+
if (remaining) {
|
|
138
|
+
const oscPrefix = "\x1b]4;"
|
|
139
|
+
let pos = 0
|
|
140
|
+
while (pos < remaining.length) {
|
|
141
|
+
const nextOsc = remaining.indexOf(oscPrefix, pos)
|
|
142
|
+
if (nextOsc === -1) break
|
|
143
|
+
|
|
144
|
+
let end = remaining.indexOf("\x07", nextOsc)
|
|
145
|
+
if (end === -1) end = remaining.indexOf("\x1b\\", nextOsc)
|
|
146
|
+
if (end === -1) break
|
|
147
|
+
|
|
148
|
+
const chunk = remaining.slice(nextOsc, end + 1)
|
|
149
|
+
const parsed = silvery.parsePaletteResponse(chunk)
|
|
150
|
+
if (parsed && parsed.index >= 0 && parsed.index < 16) {
|
|
151
|
+
ansi[parsed.index] = parsed.color
|
|
152
|
+
}
|
|
153
|
+
pos = end + 1
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Determine dark/light from bg
|
|
158
|
+
const dark = bg ? isDarkColor(bg) : true
|
|
159
|
+
|
|
160
|
+
// Build partial ColorPalette from detected colors
|
|
161
|
+
const palette: Partial<ColorPalette> = { dark }
|
|
162
|
+
|
|
163
|
+
if (bg) palette.background = bg
|
|
164
|
+
if (fg) palette.foreground = fg
|
|
165
|
+
|
|
166
|
+
// Map ANSI 16 indices to ColorPalette fields
|
|
167
|
+
const ansiFields: (keyof ColorPalette)[] = [
|
|
168
|
+
"black",
|
|
169
|
+
"red",
|
|
170
|
+
"green",
|
|
171
|
+
"yellow",
|
|
172
|
+
"blue",
|
|
173
|
+
"magenta",
|
|
174
|
+
"cyan",
|
|
175
|
+
"white",
|
|
176
|
+
"brightBlack",
|
|
177
|
+
"brightRed",
|
|
178
|
+
"brightGreen",
|
|
179
|
+
"brightYellow",
|
|
180
|
+
"brightBlue",
|
|
181
|
+
"brightMagenta",
|
|
182
|
+
"brightCyan",
|
|
183
|
+
"brightWhite",
|
|
184
|
+
]
|
|
185
|
+
for (let i = 0; i < 16; i++) {
|
|
186
|
+
if (ansi[i]) {
|
|
187
|
+
;(palette as Record<string, string>)[ansiFields[i]!] = ansi[i]!
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Derive special colors from detected values
|
|
192
|
+
if (fg) {
|
|
193
|
+
palette.cursorColor = fg
|
|
194
|
+
palette.selectionForeground = fg
|
|
195
|
+
}
|
|
196
|
+
if (bg) {
|
|
197
|
+
palette.cursorText = bg
|
|
198
|
+
}
|
|
199
|
+
if (ansi[4]) {
|
|
200
|
+
// Selection background from blue at 30% on bg
|
|
201
|
+
palette.selectionBackground = ansi[4]
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return { fg, bg, ansi, dark, palette }
|
|
205
|
+
} finally {
|
|
206
|
+
stdin.removeListener("data", onData)
|
|
207
|
+
if (!wasRaw) stdin.setRawMode(false)
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ============================================================================
|
|
212
|
+
// detectTheme — high-level: detect terminal palette, fill gaps, derive theme
|
|
213
|
+
// ============================================================================
|
|
214
|
+
|
|
215
|
+
export interface DetectThemeOptions {
|
|
216
|
+
/** Fallback ColorPalette when detection fails or returns partial data.
|
|
217
|
+
* Detected colors override matching fallback fields. */
|
|
218
|
+
fallback?: ColorPalette
|
|
219
|
+
/** Timeout per OSC query in ms (default 150). */
|
|
220
|
+
timeoutMs?: number
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Detect the terminal's color palette and derive a Theme.
|
|
225
|
+
*
|
|
226
|
+
* Queries the terminal via OSC, fills gaps from `fallback` palette,
|
|
227
|
+
* and runs `deriveTheme()` to produce a complete 33-token Theme.
|
|
228
|
+
*
|
|
229
|
+
* Falls back entirely to the fallback palette (or Nord dark) if
|
|
230
|
+
* detection fails (e.g., not a TTY, tmux, CI).
|
|
231
|
+
*/
|
|
232
|
+
export async function detectTheme(opts: DetectThemeOptions = {}): Promise<Theme> {
|
|
233
|
+
const detected = await detectTerminalPalette(opts.timeoutMs)
|
|
234
|
+
const isDark = detected?.dark ?? true
|
|
235
|
+
const fallback = opts.fallback ?? (isDark ? nord : catppuccinLatte)
|
|
236
|
+
|
|
237
|
+
if (!detected) {
|
|
238
|
+
// Detection failed entirely — use fallback
|
|
239
|
+
return deriveTheme(fallback)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Merge: detected colors override fallback
|
|
243
|
+
const merged: ColorPalette = { ...fallback, ...stripNulls(detected.palette) }
|
|
244
|
+
return deriveTheme(merged)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Strip null/undefined values from a partial palette so they don't override fallback. */
|
|
248
|
+
function stripNulls(partial: Partial<ColorPalette>): Partial<ColorPalette> {
|
|
249
|
+
const result: Record<string, unknown> = {}
|
|
250
|
+
for (const [k, v] of Object.entries(partial)) {
|
|
251
|
+
if (v != null) result[k] = v
|
|
252
|
+
}
|
|
253
|
+
return result as Partial<ColorPalette>
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Check if a #RRGGBB color is dark (luminance <= 0.5). */
|
|
257
|
+
function isDarkColor(hex: string): boolean {
|
|
258
|
+
const r = parseInt(hex.slice(1, 3), 16) / 255
|
|
259
|
+
const g = parseInt(hex.slice(3, 5), 16) / 255
|
|
260
|
+
const b = parseInt(hex.slice(5, 7), 16) / 255
|
|
261
|
+
const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b
|
|
262
|
+
return luminance <= 0.5
|
|
263
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Export ColorPalette to Base16 YAML format.
|
|
3
|
+
*
|
|
4
|
+
* Maps ColorPalette fields to base00–base0F. For fields that
|
|
5
|
+
* ColorPalette doesn't store directly (base04/base06/base07),
|
|
6
|
+
* we interpolate between neighboring values.
|
|
7
|
+
*
|
|
8
|
+
* @see https://github.com/chriskempson/base16
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { blend } from "../color"
|
|
12
|
+
import type { ColorPalette } from "../types"
|
|
13
|
+
|
|
14
|
+
/** Strip `#` prefix from a hex color string and uppercase. */
|
|
15
|
+
function stripHash(hex: string): string {
|
|
16
|
+
const bare = hex.startsWith("#") ? hex.slice(1) : hex
|
|
17
|
+
return bare.toUpperCase()
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Export a ColorPalette to Base16 YAML format.
|
|
22
|
+
*
|
|
23
|
+
* Mapping:
|
|
24
|
+
* background → base00, brightBlack → base01, selectionBackground → base02,
|
|
25
|
+
* white → base03, (interpolated) → base04, foreground → base05,
|
|
26
|
+
* (interpolated) → base06, (interpolated) → base07,
|
|
27
|
+
* red → base08, brightRed → base09, yellow → base0A, green → base0B,
|
|
28
|
+
* cyan → base0C, blue → base0D, magenta → base0E, brightMagenta → base0F.
|
|
29
|
+
*/
|
|
30
|
+
export function exportBase16(palette: ColorPalette): string {
|
|
31
|
+
const dark = palette.dark ?? true
|
|
32
|
+
|
|
33
|
+
// base04: between white (base03/muted fg) and foreground (base05)
|
|
34
|
+
const base04 = blend(palette.white, palette.foreground, 0.33)
|
|
35
|
+
|
|
36
|
+
// base06: light foreground — between fg and the inverse extreme
|
|
37
|
+
const base06 = dark ? blend(palette.foreground, "#FFFFFF", 0.15) : blend(palette.foreground, "#000000", 0.15)
|
|
38
|
+
|
|
39
|
+
// base07: lightest background — use black (the deepest bg extreme)
|
|
40
|
+
const base07 = palette.black
|
|
41
|
+
|
|
42
|
+
const lines = [
|
|
43
|
+
`scheme: "${palette.name ?? "exported"}"`,
|
|
44
|
+
`author: ""`,
|
|
45
|
+
`base00: "${stripHash(palette.background)}"`,
|
|
46
|
+
`base01: "${stripHash(palette.brightBlack)}"`,
|
|
47
|
+
`base02: "${stripHash(palette.selectionBackground)}"`,
|
|
48
|
+
`base03: "${stripHash(palette.white)}"`,
|
|
49
|
+
`base04: "${stripHash(base04)}"`,
|
|
50
|
+
`base05: "${stripHash(palette.foreground)}"`,
|
|
51
|
+
`base06: "${stripHash(base06)}"`,
|
|
52
|
+
`base07: "${stripHash(base07)}"`,
|
|
53
|
+
`base08: "${stripHash(palette.red)}"`,
|
|
54
|
+
`base09: "${stripHash(palette.brightRed)}"`,
|
|
55
|
+
`base0A: "${stripHash(palette.yellow)}"`,
|
|
56
|
+
`base0B: "${stripHash(palette.green)}"`,
|
|
57
|
+
`base0C: "${stripHash(palette.cyan)}"`,
|
|
58
|
+
`base0D: "${stripHash(palette.blue)}"`,
|
|
59
|
+
`base0E: "${stripHash(palette.magenta)}"`,
|
|
60
|
+
`base0F: "${stripHash(palette.brightMagenta)}"`,
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
return lines.join("\n") + "\n"
|
|
64
|
+
}
|
package/src/generate.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ANSI 16 theme generation — derives a complete Theme from a primary color + dark/light.
|
|
3
|
+
*
|
|
4
|
+
* Uses ANSI color names (not hex) so it works on any terminal without truecolor support.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { AnsiPrimary, Theme } from "./types"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Generate a complete ANSI 16 theme from a primary color + dark/light preference.
|
|
11
|
+
*
|
|
12
|
+
* All token values are ANSI color names (e.g. "yellow", "blueBright").
|
|
13
|
+
*/
|
|
14
|
+
export function generateTheme(primary: AnsiPrimary, dark: boolean): Theme {
|
|
15
|
+
return {
|
|
16
|
+
name: `${dark ? "dark" : "light"}-${primary}`,
|
|
17
|
+
|
|
18
|
+
// ── Root pair ─────────────────────────────────────────────────
|
|
19
|
+
bg: "",
|
|
20
|
+
fg: dark ? "whiteBright" : "black",
|
|
21
|
+
|
|
22
|
+
// ── Surface pairs (base = text, *bg = background) ──────────
|
|
23
|
+
muted: dark ? "white" : "blackBright",
|
|
24
|
+
mutedbg: dark ? "black" : "white",
|
|
25
|
+
surface: dark ? "whiteBright" : "black",
|
|
26
|
+
surfacebg: dark ? "black" : "white",
|
|
27
|
+
popover: dark ? "whiteBright" : "black",
|
|
28
|
+
popoverbg: dark ? "black" : "white",
|
|
29
|
+
inverse: dark ? "black" : "whiteBright",
|
|
30
|
+
inversebg: dark ? "whiteBright" : "black",
|
|
31
|
+
cursor: "black",
|
|
32
|
+
cursorbg: primary,
|
|
33
|
+
selection: "black",
|
|
34
|
+
selectionbg: primary,
|
|
35
|
+
|
|
36
|
+
// ── Accent pairs (base = area bg, *fg = text on area) ──────
|
|
37
|
+
primary,
|
|
38
|
+
primaryfg: "black",
|
|
39
|
+
secondary: primary,
|
|
40
|
+
secondaryfg: "black",
|
|
41
|
+
accent: primary,
|
|
42
|
+
accentfg: "black",
|
|
43
|
+
error: dark ? "redBright" : "red",
|
|
44
|
+
errorfg: "black",
|
|
45
|
+
warning: primary,
|
|
46
|
+
warningfg: "black",
|
|
47
|
+
success: dark ? "greenBright" : "green",
|
|
48
|
+
successfg: "black",
|
|
49
|
+
info: dark ? "cyanBright" : "cyan",
|
|
50
|
+
infofg: "black",
|
|
51
|
+
|
|
52
|
+
// ── Standalone ───────────────────────────────────────────────
|
|
53
|
+
border: "gray",
|
|
54
|
+
inputborder: "gray",
|
|
55
|
+
focusborder: dark ? "blueBright" : "blue",
|
|
56
|
+
link: "blueBright",
|
|
57
|
+
disabledfg: "gray",
|
|
58
|
+
|
|
59
|
+
// ── Palette ──────────────────────────────────────────────────
|
|
60
|
+
palette: [
|
|
61
|
+
"black",
|
|
62
|
+
"red",
|
|
63
|
+
"green",
|
|
64
|
+
"yellow",
|
|
65
|
+
"blue",
|
|
66
|
+
"magenta",
|
|
67
|
+
"cyan",
|
|
68
|
+
"white",
|
|
69
|
+
"blackBright",
|
|
70
|
+
"redBright",
|
|
71
|
+
"greenBright",
|
|
72
|
+
"yellowBright",
|
|
73
|
+
"blueBright",
|
|
74
|
+
"magentaBright",
|
|
75
|
+
"cyanBright",
|
|
76
|
+
"whiteBright",
|
|
77
|
+
],
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Palette generators — produce a ColorPalette from various inputs.
|
|
3
|
+
*
|
|
4
|
+
* All generators return a complete ColorPalette (22 fields).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { hexToRgb, hexToHsl, hslToHex, brighten, darken, blend } from "./color"
|
|
8
|
+
import { importBase16 as importBase16Internal } from "./import/base16"
|
|
9
|
+
import { getPaletteByName } from "./palettes/index"
|
|
10
|
+
import type { ColorPalette, HueName } from "./types"
|
|
11
|
+
|
|
12
|
+
// ============================================================================
|
|
13
|
+
// Luminance
|
|
14
|
+
// ============================================================================
|
|
15
|
+
|
|
16
|
+
function luminance(hex: string): number {
|
|
17
|
+
const rgb = hexToRgb(hex)
|
|
18
|
+
if (!rgb) return 0.5
|
|
19
|
+
return (rgb[0] + rgb[1] + rgb[2]) / (255 * 3)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function isDarkColor(hex: string): boolean {
|
|
23
|
+
return luminance(hex) < 0.5
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ============================================================================
|
|
27
|
+
// Accent Generation
|
|
28
|
+
// ============================================================================
|
|
29
|
+
|
|
30
|
+
/** Target hues for each accent slot. */
|
|
31
|
+
const targetHues: Record<HueName, number> = {
|
|
32
|
+
red: 0,
|
|
33
|
+
orange: 30,
|
|
34
|
+
yellow: 60,
|
|
35
|
+
green: 120,
|
|
36
|
+
teal: 180,
|
|
37
|
+
blue: 220,
|
|
38
|
+
purple: 280,
|
|
39
|
+
pink: 330,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Find which hue slot the primary color best matches by hue angle proximity. */
|
|
43
|
+
export function assignPrimaryToSlot(primary: string): HueName {
|
|
44
|
+
const hsl = hexToHsl(primary)
|
|
45
|
+
if (!hsl) return "blue"
|
|
46
|
+
const h = hsl[0]
|
|
47
|
+
const slots: [number, number, HueName][] = [
|
|
48
|
+
[0, 15, "red"],
|
|
49
|
+
[15, 45, "orange"],
|
|
50
|
+
[45, 75, "yellow"],
|
|
51
|
+
[75, 165, "green"],
|
|
52
|
+
[165, 200, "teal"],
|
|
53
|
+
[200, 260, "blue"],
|
|
54
|
+
[260, 310, "purple"],
|
|
55
|
+
[310, 345, "pink"],
|
|
56
|
+
[345, 360, "red"],
|
|
57
|
+
]
|
|
58
|
+
for (const [lo, hi, name] of slots) {
|
|
59
|
+
if (h >= lo && h < hi) return name
|
|
60
|
+
}
|
|
61
|
+
return "blue"
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Generate 8 accent hues from a primary, placing it in its natural slot. */
|
|
65
|
+
function generateAccentsFromPrimary(primary: string): Record<HueName, string> {
|
|
66
|
+
const hsl = hexToHsl(primary)
|
|
67
|
+
if (!hsl) {
|
|
68
|
+
return {
|
|
69
|
+
red: "#BF616A",
|
|
70
|
+
orange: "#D08770",
|
|
71
|
+
yellow: "#EBCB8B",
|
|
72
|
+
green: "#A3BE8C",
|
|
73
|
+
teal: "#88C0D0",
|
|
74
|
+
blue: "#5E81AC",
|
|
75
|
+
purple: "#B48EAD",
|
|
76
|
+
pink: "#D4879C",
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const [, s, l] = hsl
|
|
80
|
+
const slot = assignPrimaryToSlot(primary)
|
|
81
|
+
const result = {} as Record<HueName, string>
|
|
82
|
+
for (const [name, targetH] of Object.entries(targetHues) as [HueName, number][]) {
|
|
83
|
+
result[name] = name === slot ? primary : hslToHex(targetH, s, l)
|
|
84
|
+
}
|
|
85
|
+
return result
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ============================================================================
|
|
89
|
+
// fromBase16 — Base16 YAML → ColorPalette
|
|
90
|
+
// ============================================================================
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Generate a ColorPalette from a Base16 YAML scheme.
|
|
94
|
+
*
|
|
95
|
+
* Maps base00–base0F to ANSI palette colors, derives special colors.
|
|
96
|
+
*/
|
|
97
|
+
export function fromBase16(yamlOrJson: string): ColorPalette {
|
|
98
|
+
return importBase16Internal(yamlOrJson)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ============================================================================
|
|
102
|
+
// fromColors — Generate full palette from 1-3 hex colors
|
|
103
|
+
// ============================================================================
|
|
104
|
+
|
|
105
|
+
interface FromColorsOptions {
|
|
106
|
+
/** Background color (infers dark/light). */
|
|
107
|
+
background?: string
|
|
108
|
+
/** Foreground/text color. Generated if omitted. */
|
|
109
|
+
foreground?: string
|
|
110
|
+
/** Primary accent color. Generated if omitted. */
|
|
111
|
+
primary?: string
|
|
112
|
+
/** Force dark mode. */
|
|
113
|
+
dark?: boolean
|
|
114
|
+
/** Theme name. */
|
|
115
|
+
name?: string
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Generate a full ColorPalette from 1-3 hex colors.
|
|
120
|
+
*
|
|
121
|
+
* At minimum, provide `background` or `primary`. Missing colors are
|
|
122
|
+
* generated via surface ramp (from bg) and hue rotation (from primary).
|
|
123
|
+
*/
|
|
124
|
+
export function fromColors(opts: FromColorsOptions): ColorPalette {
|
|
125
|
+
const dark = opts.dark ?? (opts.background ? isDarkColor(opts.background) : true)
|
|
126
|
+
const step = dark ? brighten : darken
|
|
127
|
+
|
|
128
|
+
// Generate background if not provided
|
|
129
|
+
const bg = opts.background ?? (dark ? "#2E3440" : "#FFFFFF")
|
|
130
|
+
const fg = opts.foreground ?? step(bg, 0.85)
|
|
131
|
+
|
|
132
|
+
// Generate accents from primary or defaults
|
|
133
|
+
const accents = opts.primary
|
|
134
|
+
? generateAccentsFromPrimary(opts.primary)
|
|
135
|
+
: {
|
|
136
|
+
red: "#BF616A",
|
|
137
|
+
orange: "#D08770",
|
|
138
|
+
yellow: "#EBCB8B",
|
|
139
|
+
green: "#A3BE8C",
|
|
140
|
+
teal: "#88C0D0",
|
|
141
|
+
blue: "#5E81AC",
|
|
142
|
+
purple: "#B48EAD",
|
|
143
|
+
pink: "#D4879C",
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Surface ramp for grayscale ANSI colors
|
|
147
|
+
const black = dark ? darken(bg, 0.05) : darken(bg, 0.1)
|
|
148
|
+
const white = dark ? blend(fg, bg, 0.3) : blend(bg, fg, 0.3)
|
|
149
|
+
const brightBlack = step(bg, 0.15)
|
|
150
|
+
const brightWhite = dark ? fg : brighten(fg, 0.1)
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
name: opts.name ?? (dark ? "generated-dark" : "generated-light"),
|
|
154
|
+
dark,
|
|
155
|
+
black,
|
|
156
|
+
red: accents.red,
|
|
157
|
+
green: accents.green,
|
|
158
|
+
yellow: accents.yellow,
|
|
159
|
+
blue: accents.blue,
|
|
160
|
+
magenta: accents.purple,
|
|
161
|
+
cyan: accents.teal,
|
|
162
|
+
white,
|
|
163
|
+
brightBlack,
|
|
164
|
+
brightRed: accents.orange,
|
|
165
|
+
brightGreen: brighten(accents.green, 0.15),
|
|
166
|
+
brightYellow: brighten(accents.yellow, 0.15),
|
|
167
|
+
brightBlue: brighten(accents.blue, 0.15),
|
|
168
|
+
brightMagenta: accents.pink,
|
|
169
|
+
brightCyan: brighten(accents.teal, 0.15),
|
|
170
|
+
brightWhite,
|
|
171
|
+
foreground: fg,
|
|
172
|
+
background: bg,
|
|
173
|
+
cursorColor: fg,
|
|
174
|
+
cursorText: bg,
|
|
175
|
+
selectionBackground: blend(bg, accents.blue, 0.3),
|
|
176
|
+
selectionForeground: fg,
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ============================================================================
|
|
181
|
+
// fromPreset — Look up a built-in ColorPalette by name
|
|
182
|
+
// ============================================================================
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Look up a built-in palette by name.
|
|
186
|
+
*
|
|
187
|
+
* @returns The ColorPalette, or undefined if not found.
|
|
188
|
+
*/
|
|
189
|
+
export function fromPreset(name: string): ColorPalette | undefined {
|
|
190
|
+
return getPaletteByName(name)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ============================================================================
|
|
194
|
+
// ThemePalette → ColorPalette conversion (migration helper)
|
|
195
|
+
// ============================================================================
|
|
196
|
+
|
|
197
|
+
/** Old ThemePalette shape for migration. */
|
|
198
|
+
interface OldThemePalette {
|
|
199
|
+
name: string
|
|
200
|
+
dark: boolean
|
|
201
|
+
crust: string
|
|
202
|
+
base: string
|
|
203
|
+
surface: string
|
|
204
|
+
overlay: string
|
|
205
|
+
subtext: string
|
|
206
|
+
text: string
|
|
207
|
+
red: string
|
|
208
|
+
orange: string
|
|
209
|
+
yellow: string
|
|
210
|
+
green: string
|
|
211
|
+
teal: string
|
|
212
|
+
blue: string
|
|
213
|
+
purple: string
|
|
214
|
+
pink: string
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Convert an old ThemePalette to a ColorPalette.
|
|
219
|
+
*
|
|
220
|
+
* Mapping:
|
|
221
|
+
* black = crust, red/green/yellow/blue = direct, magenta = purple,
|
|
222
|
+
* cyan = teal, white = subtext, brightBlack = surface,
|
|
223
|
+
* brightRed = orange, bright{green,yellow,blue,cyan} = brighten(normal),
|
|
224
|
+
* brightMagenta = pink, brightWhite = text,
|
|
225
|
+
* foreground = text, background = base,
|
|
226
|
+
* cursor = text/base, selection = overlay/text.
|
|
227
|
+
*/
|
|
228
|
+
export function themePaletteToColorPalette(p: OldThemePalette): ColorPalette {
|
|
229
|
+
return {
|
|
230
|
+
name: p.name,
|
|
231
|
+
dark: p.dark,
|
|
232
|
+
black: p.crust,
|
|
233
|
+
red: p.red,
|
|
234
|
+
green: p.green,
|
|
235
|
+
yellow: p.yellow,
|
|
236
|
+
blue: p.blue,
|
|
237
|
+
magenta: p.purple,
|
|
238
|
+
cyan: p.teal,
|
|
239
|
+
white: p.subtext,
|
|
240
|
+
brightBlack: p.surface,
|
|
241
|
+
brightRed: p.orange,
|
|
242
|
+
brightGreen: brighten(p.green, 0.15),
|
|
243
|
+
brightYellow: brighten(p.yellow, 0.15),
|
|
244
|
+
brightBlue: brighten(p.blue, 0.15),
|
|
245
|
+
brightMagenta: p.pink,
|
|
246
|
+
brightCyan: brighten(p.teal, 0.15),
|
|
247
|
+
brightWhite: p.text,
|
|
248
|
+
foreground: p.text,
|
|
249
|
+
background: p.base,
|
|
250
|
+
cursorColor: p.text,
|
|
251
|
+
cursorText: p.base,
|
|
252
|
+
selectionBackground: p.overlay,
|
|
253
|
+
selectionForeground: p.text,
|
|
254
|
+
}
|
|
255
|
+
}
|