@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/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
+ }