@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/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@silvery/theme",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Theming system with semantic color tokens for silvery",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Bjørn Stabell <bjorn@stabell.org>",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/beorn/silvery.git",
|
|
10
|
+
"directory": "packages/theme"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"src"
|
|
14
|
+
],
|
|
15
|
+
"type": "module",
|
|
16
|
+
"main": "src/index.ts",
|
|
17
|
+
"types": "src/index.ts",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"types": "./src/index.ts",
|
|
21
|
+
"import": "./src/index.ts"
|
|
22
|
+
},
|
|
23
|
+
"./ThemeContext": {
|
|
24
|
+
"types": "./src/ThemeContext.tsx",
|
|
25
|
+
"import": "./src/ThemeContext.tsx"
|
|
26
|
+
},
|
|
27
|
+
"./*": {
|
|
28
|
+
"types": "./src/*.ts",
|
|
29
|
+
"import": "./src/*.ts"
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {}
|
|
36
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ThemeContext — delivers a Theme to the component tree.
|
|
3
|
+
*
|
|
4
|
+
* Wrap your app (or a subtree) in `<ThemeProvider theme={…}>` to make
|
|
5
|
+
* `$token` color props resolve against that theme. Components call
|
|
6
|
+
* `useTheme()` to read the current theme.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```tsx
|
|
10
|
+
* import { ThemeProvider, defaultDarkTheme } from '@silvery/react'
|
|
11
|
+
*
|
|
12
|
+
* <ThemeProvider theme={defaultDarkTheme}>
|
|
13
|
+
* <App />
|
|
14
|
+
* </ThemeProvider>
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import React, { createContext, useContext } from "react"
|
|
19
|
+
import type { Theme } from "./types"
|
|
20
|
+
import { setActiveTheme } from "./state"
|
|
21
|
+
import { defaultDarkTheme } from "./palettes/index"
|
|
22
|
+
|
|
23
|
+
// ============================================================================
|
|
24
|
+
// Context
|
|
25
|
+
// ============================================================================
|
|
26
|
+
|
|
27
|
+
const ThemeContext = createContext<Theme>(defaultDarkTheme)
|
|
28
|
+
|
|
29
|
+
// ============================================================================
|
|
30
|
+
// Provider
|
|
31
|
+
// ============================================================================
|
|
32
|
+
|
|
33
|
+
export interface ThemeProviderProps {
|
|
34
|
+
theme: Theme
|
|
35
|
+
children: React.ReactNode
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Provide a theme to the subtree.
|
|
40
|
+
*
|
|
41
|
+
* Components beneath this provider can use `useTheme()` or `$token`
|
|
42
|
+
* color props (e.g. `color="$primary"`).
|
|
43
|
+
*/
|
|
44
|
+
export function ThemeProvider({ theme, children }: ThemeProviderProps): React.ReactElement {
|
|
45
|
+
// Set module-level active theme so parseColor() can resolve $token strings
|
|
46
|
+
// during the content phase without needing React context access.
|
|
47
|
+
setActiveTheme(theme)
|
|
48
|
+
return React.createElement(ThemeContext.Provider, { value: theme }, children)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ============================================================================
|
|
52
|
+
// Hook
|
|
53
|
+
// ============================================================================
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Read the current theme from context.
|
|
57
|
+
*
|
|
58
|
+
* Returns `defaultDarkTheme` when no `ThemeProvider` is present.
|
|
59
|
+
*/
|
|
60
|
+
export function useTheme(): Theme {
|
|
61
|
+
return useContext(ThemeContext)
|
|
62
|
+
}
|
package/src/alias.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token aliasing — resolve token values that reference other tokens.
|
|
3
|
+
*
|
|
4
|
+
* Supports alias chains (e.g. $button -> $primary -> #EBCB8B) with a
|
|
5
|
+
* depth limit to prevent infinite loops from circular references.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Theme } from "./types"
|
|
9
|
+
|
|
10
|
+
/** Maximum depth for alias chain resolution before treating as circular. */
|
|
11
|
+
const MAX_ALIAS_DEPTH = 10
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Resolve all token aliases in a theme.
|
|
15
|
+
*
|
|
16
|
+
* Token values that start with `$` are treated as references to other tokens.
|
|
17
|
+
* Alias chains are followed until a concrete (non-$) value is reached.
|
|
18
|
+
* Circular references are detected via a depth limit and left unresolved.
|
|
19
|
+
*
|
|
20
|
+
* @param theme - A theme-like object where values may reference other tokens via `$name`
|
|
21
|
+
* @returns A new object with all aliases resolved to concrete values
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* ```typescript
|
|
25
|
+
* const themed = resolveAliases({
|
|
26
|
+
* ...baseTheme,
|
|
27
|
+
* button: "$primary", // resolves to the value of 'primary'
|
|
28
|
+
* buttonHover: "$button", // chain: buttonHover -> button -> primary -> hex
|
|
29
|
+
* })
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
export function resolveAliases(theme: Record<string, string>): Record<string, string> {
|
|
33
|
+
const result: Record<string, string> = {}
|
|
34
|
+
|
|
35
|
+
for (const key of Object.keys(theme)) {
|
|
36
|
+
result[key] = resolveAlias(key, theme, 0)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return result
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Resolve a single token's alias chain.
|
|
44
|
+
*
|
|
45
|
+
* @param key - The token key to resolve
|
|
46
|
+
* @param tokens - The full token map
|
|
47
|
+
* @param depth - Current recursion depth (for loop detection)
|
|
48
|
+
* @returns The resolved concrete value, or the raw alias string if unresolvable
|
|
49
|
+
*/
|
|
50
|
+
function resolveAlias(key: string, tokens: Record<string, string>, depth: number): string {
|
|
51
|
+
if (depth >= MAX_ALIAS_DEPTH) return tokens[key] ?? ""
|
|
52
|
+
|
|
53
|
+
const value = tokens[key]
|
|
54
|
+
if (value === undefined) return ""
|
|
55
|
+
|
|
56
|
+
// Not an alias — return the concrete value
|
|
57
|
+
if (!value.startsWith("$")) return value
|
|
58
|
+
|
|
59
|
+
// Strip the `$` prefix and look up the referenced token
|
|
60
|
+
const refKey = value.slice(1)
|
|
61
|
+
if (!(refKey in tokens)) return value // Unknown reference, return as-is
|
|
62
|
+
|
|
63
|
+
return resolveAlias(refKey, tokens, depth + 1)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Resolve a single alias value against a Theme.
|
|
68
|
+
*
|
|
69
|
+
* Useful for resolving individual values without processing the entire theme.
|
|
70
|
+
*
|
|
71
|
+
* @param value - The value to resolve (may be "$tokenName" or a concrete value)
|
|
72
|
+
* @param theme - The theme to resolve against
|
|
73
|
+
* @returns The resolved concrete value
|
|
74
|
+
*/
|
|
75
|
+
export function resolveTokenAlias(value: string, theme: Theme): string {
|
|
76
|
+
if (!value.startsWith("$")) return value
|
|
77
|
+
|
|
78
|
+
const seen = new Set<string>()
|
|
79
|
+
let current = value
|
|
80
|
+
|
|
81
|
+
for (let i = 0; i < MAX_ALIAS_DEPTH; i++) {
|
|
82
|
+
if (!current.startsWith("$")) return current
|
|
83
|
+
|
|
84
|
+
const key = current.slice(1) as keyof Theme
|
|
85
|
+
if (seen.has(key)) return current // Circular reference
|
|
86
|
+
seen.add(key)
|
|
87
|
+
|
|
88
|
+
const resolved = theme[key]
|
|
89
|
+
if (typeof resolved !== "string") return current
|
|
90
|
+
current = resolved
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return current
|
|
94
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-generate themes — create a complete Theme from a single primary color.
|
|
3
|
+
*
|
|
4
|
+
* Uses HSL color manipulation to derive complementary and analogous colors
|
|
5
|
+
* for the full palette from one input color.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { hexToHsl, hslToHex, blend, contrastFg } from "./color"
|
|
9
|
+
import { fromColors } from "./generators"
|
|
10
|
+
import { deriveTheme } from "./derive"
|
|
11
|
+
import type { Theme } from "./types"
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Generate a complete Theme from a single primary color.
|
|
15
|
+
*
|
|
16
|
+
* Derives a full ColorPalette using HSL color manipulation:
|
|
17
|
+
* - Background/foreground from lightness inversion
|
|
18
|
+
* - Complementary and analogous accent colors from hue rotation
|
|
19
|
+
* - Surface ramp from background blending
|
|
20
|
+
* - Status colors (error, warning, success, info) from standard hue positions
|
|
21
|
+
*
|
|
22
|
+
* @param primaryColor - A hex color string (e.g. "#5E81AC")
|
|
23
|
+
* @param mode - "dark" or "light" theme mode
|
|
24
|
+
* @returns A complete Theme with all 33 semantic tokens
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* ```typescript
|
|
28
|
+
* const theme = autoGenerateTheme("#5E81AC", "dark")
|
|
29
|
+
* // Generates a full dark theme with blue as the primary accent
|
|
30
|
+
*
|
|
31
|
+
* const light = autoGenerateTheme("#E06C75", "light")
|
|
32
|
+
* // Generates a full light theme with red/rose as the primary accent
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
export function autoGenerateTheme(primaryColor: string, mode: "dark" | "light"): Theme {
|
|
36
|
+
const hsl = hexToHsl(primaryColor)
|
|
37
|
+
if (!hsl) {
|
|
38
|
+
// Fallback: use default colors if input is not valid hex
|
|
39
|
+
const palette = fromColors({ dark: mode === "dark" })
|
|
40
|
+
return deriveTheme(palette)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const [h, s] = hsl
|
|
44
|
+
const dark = mode === "dark"
|
|
45
|
+
|
|
46
|
+
// Generate background and foreground based on mode
|
|
47
|
+
const bgL = dark ? 0.12 : 0.97
|
|
48
|
+
const fgL = dark ? 0.87 : 0.13
|
|
49
|
+
// Use low saturation for bg/fg to keep them neutral
|
|
50
|
+
const bgS = Math.min(s, 0.15)
|
|
51
|
+
const bg = hslToHex(h, bgS, bgL)
|
|
52
|
+
const fg = hslToHex(h, bgS * 0.5, fgL)
|
|
53
|
+
|
|
54
|
+
// Generate accent colors from hue rotations
|
|
55
|
+
// Standard hue positions for semantic colors
|
|
56
|
+
const redHue = 0
|
|
57
|
+
const yellowHue = 45
|
|
58
|
+
const greenHue = 130
|
|
59
|
+
const cyanHue = 185
|
|
60
|
+
const blueHue = 220
|
|
61
|
+
const magentaHue = 300
|
|
62
|
+
|
|
63
|
+
// Use the primary's saturation and adjust lightness for the mode
|
|
64
|
+
const accentL = dark ? 0.65 : 0.45
|
|
65
|
+
const accentS = Math.max(s, 0.5) // Ensure accents are reasonably saturated
|
|
66
|
+
|
|
67
|
+
const red = hslToHex(redHue, accentS, accentL)
|
|
68
|
+
const green = hslToHex(greenHue, accentS, accentL)
|
|
69
|
+
const yellow = hslToHex(yellowHue, accentS, accentL)
|
|
70
|
+
const blue = hslToHex(blueHue, accentS, accentL)
|
|
71
|
+
const magenta = hslToHex(magentaHue, accentS, accentL)
|
|
72
|
+
const cyan = hslToHex(cyanHue, accentS, accentL)
|
|
73
|
+
|
|
74
|
+
// Bright variants: increase lightness slightly
|
|
75
|
+
const brightOffset = dark ? 0.1 : -0.1
|
|
76
|
+
const brightL = accentL + brightOffset
|
|
77
|
+
const brightRed = hslToHex(30, accentS, brightL) // orange-ish
|
|
78
|
+
const brightGreen = hslToHex(greenHue, accentS, brightL)
|
|
79
|
+
const brightYellow = hslToHex(yellowHue, accentS, brightL)
|
|
80
|
+
const brightBlue = hslToHex(blueHue, accentS, brightL)
|
|
81
|
+
const brightMagenta = hslToHex(330, accentS, brightL) // pink-ish
|
|
82
|
+
const brightCyan = hslToHex(cyanHue, accentS, brightL)
|
|
83
|
+
|
|
84
|
+
// Surface colors from background
|
|
85
|
+
const black = dark ? hslToHex(h, bgS, bgL * 0.7) : hslToHex(h, bgS, bgL * 0.92)
|
|
86
|
+
const white = dark ? hslToHex(h, bgS * 0.3, 0.6) : hslToHex(h, bgS * 0.3, 0.35)
|
|
87
|
+
const brightBlack = dark ? hslToHex(h, bgS, bgL + 0.08) : hslToHex(h, bgS, bgL - 0.08)
|
|
88
|
+
const brightWhite = dark ? fg : hslToHex(h, bgS * 0.5, fgL - 0.05)
|
|
89
|
+
|
|
90
|
+
const palette = {
|
|
91
|
+
name: `generated-${mode}`,
|
|
92
|
+
dark,
|
|
93
|
+
black,
|
|
94
|
+
red,
|
|
95
|
+
green,
|
|
96
|
+
yellow,
|
|
97
|
+
blue,
|
|
98
|
+
magenta,
|
|
99
|
+
cyan,
|
|
100
|
+
white,
|
|
101
|
+
brightBlack,
|
|
102
|
+
brightRed,
|
|
103
|
+
brightGreen,
|
|
104
|
+
brightYellow,
|
|
105
|
+
brightBlue,
|
|
106
|
+
brightMagenta,
|
|
107
|
+
brightCyan,
|
|
108
|
+
brightWhite,
|
|
109
|
+
foreground: fg,
|
|
110
|
+
background: bg,
|
|
111
|
+
cursorColor: fg,
|
|
112
|
+
cursorText: bg,
|
|
113
|
+
selectionBackground: blend(bg, primaryColor, 0.3),
|
|
114
|
+
selectionForeground: fg,
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Derive the full theme, then override primary with the input color
|
|
118
|
+
const theme = deriveTheme(palette)
|
|
119
|
+
|
|
120
|
+
// Override primary to be exactly the input color
|
|
121
|
+
return {
|
|
122
|
+
...theme,
|
|
123
|
+
primary: primaryColor,
|
|
124
|
+
primaryfg: contrastFg(primaryColor),
|
|
125
|
+
}
|
|
126
|
+
}
|
package/src/builder.ts
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chainable theme builder — create themes from minimal input.
|
|
3
|
+
*
|
|
4
|
+
* @example
|
|
5
|
+
* ```typescript
|
|
6
|
+
* // Just a background color
|
|
7
|
+
* const theme = createTheme().bg('#2E3440').build()
|
|
8
|
+
*
|
|
9
|
+
* // Primary + explicit dark mode
|
|
10
|
+
* const theme = createTheme().primary('#EBCB8B').dark().build()
|
|
11
|
+
*
|
|
12
|
+
* // Three-color input (dark/light inferred from bg luminance)
|
|
13
|
+
* const theme = createTheme()
|
|
14
|
+
* .bg('#2E3440').fg('#ECEFF4').primary('#EBCB8B').build()
|
|
15
|
+
*
|
|
16
|
+
* // Preset with override
|
|
17
|
+
* const theme = createTheme().preset('nord').primary('#A3BE8C').build()
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { hexToRgb } from "./color"
|
|
22
|
+
import { deriveTheme } from "./derive"
|
|
23
|
+
import { fromColors, assignPrimaryToSlot } from "./generators"
|
|
24
|
+
import { getPaletteByName } from "./palettes/index"
|
|
25
|
+
import type { Theme, ColorPalette, HueName } from "./types"
|
|
26
|
+
|
|
27
|
+
// ============================================================================
|
|
28
|
+
// Luminance
|
|
29
|
+
// ============================================================================
|
|
30
|
+
|
|
31
|
+
function isDarkColor(hex: string): boolean {
|
|
32
|
+
const rgb = hexToRgb(hex)
|
|
33
|
+
if (!rgb) return true
|
|
34
|
+
return (rgb[0] + rgb[1] + rgb[2]) / (255 * 3) < 0.5
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ============================================================================
|
|
38
|
+
// Builder
|
|
39
|
+
// ============================================================================
|
|
40
|
+
|
|
41
|
+
interface ThemeBuilderState {
|
|
42
|
+
dark?: boolean
|
|
43
|
+
bgColor?: string
|
|
44
|
+
fgColor?: string
|
|
45
|
+
primaryColor?: string
|
|
46
|
+
colors: Partial<ColorPalette>
|
|
47
|
+
presetPalette?: ColorPalette
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ThemeBuilder {
|
|
51
|
+
/** Set background color. */
|
|
52
|
+
bg(color: string): ThemeBuilder
|
|
53
|
+
/** Set foreground color. */
|
|
54
|
+
fg(color: string): ThemeBuilder
|
|
55
|
+
/** Set primary accent color. */
|
|
56
|
+
primary(color: string): ThemeBuilder
|
|
57
|
+
/** Alias for `.primary()`. */
|
|
58
|
+
accent(color: string): ThemeBuilder
|
|
59
|
+
/** Force dark mode. */
|
|
60
|
+
dark(): ThemeBuilder
|
|
61
|
+
/** Force light mode. */
|
|
62
|
+
light(): ThemeBuilder
|
|
63
|
+
/** Set any palette color by name. */
|
|
64
|
+
color(name: keyof Omit<ColorPalette, "name" | "dark">, value: string): ThemeBuilder
|
|
65
|
+
/** Set full palette at once. */
|
|
66
|
+
palette(p: ColorPalette): ThemeBuilder
|
|
67
|
+
/** Load a built-in palette by name. */
|
|
68
|
+
preset(name: string): ThemeBuilder
|
|
69
|
+
/** Derive the final Theme from accumulated state. */
|
|
70
|
+
build(): Theme
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Create a chainable theme builder. */
|
|
74
|
+
export function createTheme(): ThemeBuilder {
|
|
75
|
+
const state: ThemeBuilderState = { colors: {} }
|
|
76
|
+
|
|
77
|
+
const builder: ThemeBuilder = {
|
|
78
|
+
bg(color) {
|
|
79
|
+
state.bgColor = color
|
|
80
|
+
return builder
|
|
81
|
+
},
|
|
82
|
+
fg(color) {
|
|
83
|
+
state.fgColor = color
|
|
84
|
+
return builder
|
|
85
|
+
},
|
|
86
|
+
primary(color) {
|
|
87
|
+
state.primaryColor = color
|
|
88
|
+
return builder
|
|
89
|
+
},
|
|
90
|
+
accent(color) {
|
|
91
|
+
return builder.primary(color)
|
|
92
|
+
},
|
|
93
|
+
dark() {
|
|
94
|
+
state.dark = true
|
|
95
|
+
return builder
|
|
96
|
+
},
|
|
97
|
+
light() {
|
|
98
|
+
state.dark = false
|
|
99
|
+
return builder
|
|
100
|
+
},
|
|
101
|
+
color(name, value) {
|
|
102
|
+
;(state.colors as Record<string, string>)[name] = value
|
|
103
|
+
return builder
|
|
104
|
+
},
|
|
105
|
+
palette(p) {
|
|
106
|
+
state.presetPalette = p
|
|
107
|
+
return builder
|
|
108
|
+
},
|
|
109
|
+
preset(name) {
|
|
110
|
+
const p = getPaletteByName(name)
|
|
111
|
+
if (p) state.presetPalette = p
|
|
112
|
+
return builder
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
build(): Theme {
|
|
116
|
+
const isDark = state.dark ?? (state.bgColor ? isDarkColor(state.bgColor) : true)
|
|
117
|
+
|
|
118
|
+
let palette: ColorPalette
|
|
119
|
+
|
|
120
|
+
if (state.presetPalette) {
|
|
121
|
+
// Start from preset, apply overrides
|
|
122
|
+
palette = { ...state.presetPalette }
|
|
123
|
+
if (state.bgColor) palette.background = state.bgColor
|
|
124
|
+
if (state.fgColor) palette.foreground = state.fgColor
|
|
125
|
+
if (state.primaryColor) {
|
|
126
|
+
// Override the appropriate color slot
|
|
127
|
+
const slot = assignPrimaryToSlot(state.primaryColor)
|
|
128
|
+
const ansiName = hueToAnsiField(slot)
|
|
129
|
+
;(palette as unknown as Record<string, string>)[ansiName] = state.primaryColor
|
|
130
|
+
}
|
|
131
|
+
palette.dark = isDark
|
|
132
|
+
} else {
|
|
133
|
+
// Generate from minimal input
|
|
134
|
+
palette = fromColors({
|
|
135
|
+
background: state.bgColor,
|
|
136
|
+
foreground: state.fgColor,
|
|
137
|
+
primary: state.primaryColor,
|
|
138
|
+
dark: isDark,
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Apply explicit color overrides
|
|
143
|
+
for (const [key, val] of Object.entries(state.colors)) {
|
|
144
|
+
if (val !== undefined && typeof val === "string") (palette as unknown as Record<string, string>)[key] = val
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return deriveTheme(palette)
|
|
148
|
+
},
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return builder
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Map a HueName to the corresponding ColorPalette field. */
|
|
155
|
+
function hueToAnsiField(hue: HueName): keyof ColorPalette {
|
|
156
|
+
const map: Record<HueName, keyof ColorPalette> = {
|
|
157
|
+
red: "red",
|
|
158
|
+
orange: "brightRed",
|
|
159
|
+
yellow: "yellow",
|
|
160
|
+
green: "green",
|
|
161
|
+
teal: "cyan",
|
|
162
|
+
blue: "blue",
|
|
163
|
+
purple: "magenta",
|
|
164
|
+
pink: "brightMagenta",
|
|
165
|
+
}
|
|
166
|
+
return map[hue]
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ============================================================================
|
|
170
|
+
// Convenience Functions
|
|
171
|
+
// ============================================================================
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Quick theme from a primary color or color name.
|
|
175
|
+
*
|
|
176
|
+
* @example
|
|
177
|
+
* ```typescript
|
|
178
|
+
* quickTheme('#EBCB8B', 'dark') // yellow primary, dark mode
|
|
179
|
+
* quickTheme('blue') // blue primary, default dark
|
|
180
|
+
* ```
|
|
181
|
+
*/
|
|
182
|
+
export function quickTheme(primaryOrHex: string, mode?: "dark" | "light"): Theme {
|
|
183
|
+
const b = createTheme()
|
|
184
|
+
if (primaryOrHex.startsWith("#")) {
|
|
185
|
+
b.primary(primaryOrHex)
|
|
186
|
+
} else {
|
|
187
|
+
const namedColors: Record<string, string> = {
|
|
188
|
+
red: "#BF616A",
|
|
189
|
+
orange: "#D08770",
|
|
190
|
+
yellow: "#EBCB8B",
|
|
191
|
+
green: "#A3BE8C",
|
|
192
|
+
teal: "#88C0D0",
|
|
193
|
+
cyan: "#88C0D0",
|
|
194
|
+
blue: "#5E81AC",
|
|
195
|
+
purple: "#B48EAD",
|
|
196
|
+
pink: "#D4879C",
|
|
197
|
+
magenta: "#B48EAD",
|
|
198
|
+
white: "#ECEFF4",
|
|
199
|
+
}
|
|
200
|
+
b.primary(namedColors[primaryOrHex] ?? "#5E81AC")
|
|
201
|
+
}
|
|
202
|
+
if (mode === "dark") b.dark()
|
|
203
|
+
else if (mode === "light") b.light()
|
|
204
|
+
return b.build()
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Create a theme from a built-in preset name.
|
|
209
|
+
*
|
|
210
|
+
* @example
|
|
211
|
+
* ```typescript
|
|
212
|
+
* presetTheme('catppuccin-mocha')
|
|
213
|
+
* presetTheme('nord')
|
|
214
|
+
* ```
|
|
215
|
+
*/
|
|
216
|
+
export function presetTheme(name: string): Theme {
|
|
217
|
+
return createTheme().preset(name).build()
|
|
218
|
+
}
|