@adea-ai/themes 0.1.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 (109) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +110 -0
  3. package/README.md +157 -0
  4. package/dist/adapters/base24.d.ts +117 -0
  5. package/dist/adapters/base24.d.ts.map +1 -0
  6. package/dist/adapters/base24.js +313 -0
  7. package/dist/adapters/base24.js.map +1 -0
  8. package/dist/adapters/css.d.ts +68 -0
  9. package/dist/adapters/css.d.ts.map +1 -0
  10. package/dist/adapters/css.js +107 -0
  11. package/dist/adapters/css.js.map +1 -0
  12. package/dist/adapters/shadcn.d.ts +43 -0
  13. package/dist/adapters/shadcn.d.ts.map +1 -0
  14. package/dist/adapters/shadcn.js +89 -0
  15. package/dist/adapters/shadcn.js.map +1 -0
  16. package/dist/adapters/shiki.d.ts +60 -0
  17. package/dist/adapters/shiki.d.ts.map +1 -0
  18. package/dist/adapters/shiki.js +135 -0
  19. package/dist/adapters/shiki.js.map +1 -0
  20. package/dist/adapters/tailwind.d.ts +35 -0
  21. package/dist/adapters/tailwind.d.ts.map +1 -0
  22. package/dist/adapters/tailwind.js +58 -0
  23. package/dist/adapters/tailwind.js.map +1 -0
  24. package/dist/adapters/xterm.d.ts +64 -0
  25. package/dist/adapters/xterm.d.ts.map +1 -0
  26. package/dist/adapters/xterm.js +112 -0
  27. package/dist/adapters/xterm.js.map +1 -0
  28. package/dist/catalogue.d.ts +66 -0
  29. package/dist/catalogue.d.ts.map +1 -0
  30. package/dist/catalogue.js +110 -0
  31. package/dist/catalogue.js.map +1 -0
  32. package/dist/derive.d.ts +89 -0
  33. package/dist/derive.d.ts.map +1 -0
  34. package/dist/derive.js +165 -0
  35. package/dist/derive.js.map +1 -0
  36. package/dist/generated/schemes.d.ts +16 -0
  37. package/dist/generated/schemes.d.ts.map +1 -0
  38. package/dist/generated/schemes.js +880 -0
  39. package/dist/generated/schemes.js.map +1 -0
  40. package/dist/generated/themes.d.ts +11 -0
  41. package/dist/generated/themes.d.ts.map +1 -0
  42. package/dist/generated/themes.js +1650 -0
  43. package/dist/generated/themes.js.map +1 -0
  44. package/dist/index.d.ts +67 -0
  45. package/dist/index.d.ts.map +1 -0
  46. package/dist/index.js +58 -0
  47. package/dist/index.js.map +1 -0
  48. package/dist/normalize.d.ts +160 -0
  49. package/dist/normalize.d.ts.map +1 -0
  50. package/dist/normalize.js +795 -0
  51. package/dist/normalize.js.map +1 -0
  52. package/dist/oklch.d.ts +141 -0
  53. package/dist/oklch.d.ts.map +1 -0
  54. package/dist/oklch.js +306 -0
  55. package/dist/oklch.js.map +1 -0
  56. package/dist/schema.d.ts +178 -0
  57. package/dist/schema.d.ts.map +1 -0
  58. package/dist/schema.js +69 -0
  59. package/dist/schema.js.map +1 -0
  60. package/dist/sources.d.ts +171 -0
  61. package/dist/sources.d.ts.map +1 -0
  62. package/dist/sources.js +559 -0
  63. package/dist/sources.js.map +1 -0
  64. package/dist/validate.d.ts +121 -0
  65. package/dist/validate.d.ts.map +1 -0
  66. package/dist/validate.js +255 -0
  67. package/dist/validate.js.map +1 -0
  68. package/package.json +106 -0
  69. package/palettes/ayu-light.json +38 -0
  70. package/palettes/ayu-mirage.json +38 -0
  71. package/palettes/ayu.json +38 -0
  72. package/palettes/catppuccin-frappe.json +38 -0
  73. package/palettes/catppuccin-latte.json +38 -0
  74. package/palettes/catppuccin-macchiato.json +38 -0
  75. package/palettes/catppuccin-mocha.json +38 -0
  76. package/palettes/dracula.json +38 -0
  77. package/palettes/everforest-dark.json +38 -0
  78. package/palettes/everforest-light.json +38 -0
  79. package/palettes/gruvbox-dark.json +38 -0
  80. package/palettes/gruvbox-light.json +38 -0
  81. package/palettes/kanagawa.json +38 -0
  82. package/palettes/monokai.json +38 -0
  83. package/palettes/nord.json +38 -0
  84. package/palettes/one-dark.json +38 -0
  85. package/palettes/rosepine-dawn.json +38 -0
  86. package/palettes/rosepine-moon.json +38 -0
  87. package/palettes/rosepine.json +38 -0
  88. package/palettes/solarized-dark.json +38 -0
  89. package/palettes/solarized-light.json +38 -0
  90. package/palettes/tokyonight-day.json +38 -0
  91. package/palettes/tokyonight-night.json +38 -0
  92. package/palettes/tokyonight-storm.json +38 -0
  93. package/palettes/vesper.json +38 -0
  94. package/src/adapters/base24.ts +355 -0
  95. package/src/adapters/css.ts +149 -0
  96. package/src/adapters/shadcn.ts +99 -0
  97. package/src/adapters/shiki.ts +168 -0
  98. package/src/adapters/tailwind.ts +79 -0
  99. package/src/adapters/xterm.ts +159 -0
  100. package/src/catalogue.ts +129 -0
  101. package/src/derive.ts +203 -0
  102. package/src/generated/schemes.ts +882 -0
  103. package/src/generated/themes.ts +1652 -0
  104. package/src/index.ts +146 -0
  105. package/src/normalize.ts +1010 -0
  106. package/src/oklch.ts +366 -0
  107. package/src/schema.ts +222 -0
  108. package/src/sources.ts +682 -0
  109. package/src/validate.ts +325 -0
@@ -0,0 +1,38 @@
1
+ {
2
+ "system": "base24",
3
+ "name": "TokyoNight Day",
4
+ "author": "iTerm2-Color-Schemes (via oklch-terminal-themes)",
5
+ "variant": "light",
6
+ "palette": {
7
+ "base00": "#e1e2e7",
8
+ "base01": "#bcc5e4",
9
+ "base02": "#99a7df",
10
+ "base03": "#a1a6c5",
11
+ "base04": "#6886c4",
12
+ "base05": "#3760bf",
13
+ "base06": "#3760bf",
14
+ "base07": "#3760bf",
15
+ "base08": "#f52a65",
16
+ "base09": "#c65924",
17
+ "base0A": "#8c6c3e",
18
+ "base0B": "#587539",
19
+ "base0C": "#007197",
20
+ "base0D": "#2e7de9",
21
+ "base0E": "#9854f1",
22
+ "base0F": "#cd8f75",
23
+ "base10": "#e9eaef",
24
+ "base11": "#f1f2f7",
25
+ "base12": "#f52a65",
26
+ "base13": "#8c6c3e",
27
+ "base14": "#587539",
28
+ "base15": "#007197",
29
+ "base16": "#2e7de9",
30
+ "base17": "#9854f1"
31
+ },
32
+ "source": {
33
+ "slug": "tokyonight-day",
34
+ "repository": "https://github.com/williamzujkowski/oklch-terminal-themes",
35
+ "revision": "9e800e7fe760081d4c10317498038ed4227341d6",
36
+ "path": "data/schemes/base24/tokyonight-day.yaml"
37
+ }
38
+ }
@@ -0,0 +1,38 @@
1
+ {
2
+ "system": "base24",
3
+ "name": "TokyoNight Night",
4
+ "author": "iTerm2-Color-Schemes (via oklch-terminal-themes)",
5
+ "variant": "dark",
6
+ "palette": {
7
+ "base00": "#1a1b26",
8
+ "base01": "#20283e",
9
+ "base02": "#283457",
10
+ "base03": "#414868",
11
+ "base04": "#7e86ac",
12
+ "base05": "#c0caf5",
13
+ "base06": "#c0caf5",
14
+ "base07": "#c0caf5",
15
+ "base08": "#f7768e",
16
+ "base09": "#f49168",
17
+ "base0A": "#e0af68",
18
+ "base0B": "#9ece6a",
19
+ "base0C": "#7dcfff",
20
+ "base0D": "#7aa2f7",
21
+ "base0E": "#bb9af7",
22
+ "base0F": "#9c6954",
23
+ "base10": "#12131d",
24
+ "base11": "#0a0a14",
25
+ "base12": "#f7768e",
26
+ "base13": "#e0af68",
27
+ "base14": "#9ece6a",
28
+ "base15": "#7dcfff",
29
+ "base16": "#7aa2f7",
30
+ "base17": "#bb9af7"
31
+ },
32
+ "source": {
33
+ "slug": "tokyonight-night",
34
+ "repository": "https://github.com/williamzujkowski/oklch-terminal-themes",
35
+ "revision": "9e800e7fe760081d4c10317498038ed4227341d6",
36
+ "path": "data/schemes/base24/tokyonight-night.yaml"
37
+ }
38
+ }
@@ -0,0 +1,38 @@
1
+ {
2
+ "system": "base24",
3
+ "name": "TokyoNight Storm",
4
+ "author": "iTerm2-Color-Schemes (via oklch-terminal-themes)",
5
+ "variant": "dark",
6
+ "palette": {
7
+ "base00": "#24283b",
8
+ "base01": "#2c395e",
9
+ "base02": "#364a82",
10
+ "base03": "#4e5575",
11
+ "base04": "#858db3",
12
+ "base05": "#c0caf5",
13
+ "base06": "#c0caf5",
14
+ "base07": "#c0caf5",
15
+ "base08": "#f7768e",
16
+ "base09": "#f49168",
17
+ "base0A": "#e0af68",
18
+ "base0B": "#9ece6a",
19
+ "base0C": "#7dcfff",
20
+ "base0D": "#7aa2f7",
21
+ "base0E": "#bb9af7",
22
+ "base0F": "#a26f5a",
23
+ "base10": "#1c2032",
24
+ "base11": "#14182a",
25
+ "base12": "#f7768e",
26
+ "base13": "#e0af68",
27
+ "base14": "#9ece6a",
28
+ "base15": "#7dcfff",
29
+ "base16": "#7aa2f7",
30
+ "base17": "#bb9af7"
31
+ },
32
+ "source": {
33
+ "slug": "tokyonight-storm",
34
+ "repository": "https://github.com/williamzujkowski/oklch-terminal-themes",
35
+ "revision": "9e800e7fe760081d4c10317498038ed4227341d6",
36
+ "path": "data/schemes/base24/tokyonight-storm.yaml"
37
+ }
38
+ }
@@ -0,0 +1,38 @@
1
+ {
2
+ "system": "base24",
3
+ "name": "Vesper",
4
+ "author": "iTerm2-Color-Schemes (via oklch-terminal-themes)",
5
+ "variant": "dark",
6
+ "palette": {
7
+ "base00": "#101010",
8
+ "base01": "#4f442c",
9
+ "base02": "#988049",
10
+ "base03": "#7e7e7e",
11
+ "base04": "#bcbcbc",
12
+ "base05": "#ffffff",
13
+ "base06": "#ffffff",
14
+ "base07": "#ffffff",
15
+ "base08": "#f5a191",
16
+ "base09": "#eead94",
17
+ "base0A": "#e6b99d",
18
+ "base0B": "#90b99f",
19
+ "base0C": "#ea83a5",
20
+ "base0D": "#aca1cf",
21
+ "base0E": "#e29eca",
22
+ "base0F": "#947366",
23
+ "base10": "#060606",
24
+ "base11": "#010101",
25
+ "base12": "#ff8080",
26
+ "base13": "#ffc799",
27
+ "base14": "#99ffe4",
28
+ "base15": "#f591b2",
29
+ "base16": "#b9aeda",
30
+ "base17": "#ecaad6"
31
+ },
32
+ "source": {
33
+ "slug": "vesper",
34
+ "repository": "https://github.com/williamzujkowski/oklch-terminal-themes",
35
+ "revision": "9e800e7fe760081d4c10317498038ed4227341d6",
36
+ "path": "data/schemes/base24/vesper.yaml"
37
+ }
38
+ }
@@ -0,0 +1,355 @@
1
+ /**
2
+ * The Base24 bridge.
3
+ *
4
+ * Base24 is the interchange format this package speaks to the outside world, in
5
+ * both directions. Importing is the normalizer's first step for every theme that
6
+ * is not authored here ({@link "./normalize"}); exporting is how a theme reaches a
7
+ * terminal, a tmux theme, or another tool that has never heard of Adea.
8
+ *
9
+ * ## The slot order is not the ANSI order
10
+ *
11
+ * Base24's `base12`–`base17` are *bright red, bright yellow, bright green, bright
12
+ * cyan, bright blue, bright magenta* — the order Base16 needed to keep its ramps
13
+ * monotonic, not the ANSI order of yellow before green. Reading them positionally
14
+ * as ANSI 1–6 silently swaps green and yellow and blue and cyan, which is the kind
15
+ * of mistake that survives review because the theme still *looks* like itself.
16
+ * {@link BASE24_TO_ANSI} names the mapping explicitly so it cannot be inferred
17
+ * wrongly.
18
+ *
19
+ * ## The two slots with no Adea role
20
+ *
21
+ * `base09` (orange) and `base0F` (brown) have no counterpart in the canonical
22
+ * schema: Adea has no "constant" or "deprecated" role, because a UI does not. On
23
+ * export they are re-derived from `red` and `yellow` by a fixed rotation, and
24
+ * {@link AdeaThemeRecord} carries the vendored schemes separately for callers who
25
+ * need the originals byte-for-byte. Round-tripping is therefore lossless for every
26
+ * role Adea models, and explicitly approximate for the two it does not — rather
27
+ * than silently lossy, which is what an unnamed slot would have been.
28
+ */
29
+
30
+ import type { AdeaAnsi, AdeaTheme, AdeaThemeColors } from '../schema'
31
+ import type { Oklch } from '../oklch'
32
+ import { formatOklch, parseColor } from '../oklch'
33
+
34
+ /** The twenty-four Base24 slots, in the order the specification lists them. */
35
+ export const BASE24_SLOTS = [
36
+ 'base00',
37
+ 'base01',
38
+ 'base02',
39
+ 'base03',
40
+ 'base04',
41
+ 'base05',
42
+ 'base06',
43
+ 'base07',
44
+ 'base08',
45
+ 'base09',
46
+ 'base0A',
47
+ 'base0B',
48
+ 'base0C',
49
+ 'base0D',
50
+ 'base0E',
51
+ 'base0F',
52
+ 'base10',
53
+ 'base11',
54
+ 'base12',
55
+ 'base13',
56
+ 'base14',
57
+ 'base15',
58
+ 'base16',
59
+ 'base17',
60
+ ] as const
61
+
62
+ export type Base24Slot = (typeof BASE24_SLOTS)[number]
63
+
64
+ /** A Base24 palette. Values are colours in any notation {@link parseColor} accepts. */
65
+ export type Base24Palette = Readonly<Record<Base24Slot, string>>
66
+
67
+ /** A Base24 scheme: the palette plus the metadata the specification defines. */
68
+ export interface Base24Scheme {
69
+ system: 'base24'
70
+ name: string
71
+ author: string
72
+ variant: 'dark' | 'light'
73
+ palette: Base24Palette
74
+ }
75
+
76
+ /**
77
+ * What each slot means, in the specification's own terms.
78
+ *
79
+ * Kept as data because the normalizer reads it rather than hard-coding slot names
80
+ * in its logic, and because it is the documentation a reader needs to check that
81
+ * the mapping is right.
82
+ */
83
+ export const BASE24_SLOT_MEANING: Readonly<Record<Base24Slot, string>> = Object.freeze({
84
+ base00: 'Background',
85
+ base01: 'Lighter background (status bars, line numbers)',
86
+ base02: 'Selection background',
87
+ base03: 'Comments, invisibles',
88
+ base04: 'Dark foreground (status bars)',
89
+ base05: 'Default foreground, caret, delimiters',
90
+ base06: 'Light foreground',
91
+ base07: 'Light background',
92
+ base08: 'Variables, XML tags, diff deleted',
93
+ base09: 'Constants, numbers, XML attributes',
94
+ base0A: 'Classes, search background, diff changed',
95
+ base0B: 'Strings, inserted',
96
+ base0C: 'Support, regex, escape characters',
97
+ base0D: 'Functions, methods, headings',
98
+ base0E: 'Keywords, storage, selector',
99
+ base0F: 'Deprecated, embedded tags',
100
+ base10: 'Dark black',
101
+ base11: 'Darker than base10',
102
+ base12: 'Bright red',
103
+ base13: 'Bright yellow',
104
+ base14: 'Bright green',
105
+ base15: 'Bright cyan',
106
+ base16: 'Bright blue',
107
+ base17: 'Bright magenta',
108
+ })
109
+
110
+ /**
111
+ * Base24 slot → ANSI role.
112
+ *
113
+ * `black`, `white` and `brightBlack` are absent because Base24 does not name them:
114
+ * the background and foreground slots do double duty. The normalizer decides those
115
+ * three; see its `ANSI_FROM_BASE24` note for why the choice is the one it is.
116
+ */
117
+ export const BASE24_TO_ANSI = Object.freeze({
118
+ red: 'base08',
119
+ yellow: 'base0A',
120
+ green: 'base0B',
121
+ cyan: 'base0C',
122
+ blue: 'base0D',
123
+ magenta: 'base0E',
124
+ brightRed: 'base12',
125
+ brightYellow: 'base13',
126
+ brightGreen: 'base14',
127
+ brightCyan: 'base15',
128
+ brightBlue: 'base16',
129
+ brightMagenta: 'base17',
130
+ } as const satisfies Partial<Record<keyof AdeaAnsi, Base24Slot>>)
131
+
132
+ /** The inverse of {@link BASE24_TO_ANSI}, for export. */
133
+ export const ANSI_TO_BASE24: Readonly<Record<string, Base24Slot>> = Object.freeze(
134
+ Object.fromEntries(
135
+ Object.entries(BASE24_TO_ANSI).map(([ansi, slot]) => [slot, ansi])
136
+ ) as Record<string, Base24Slot>
137
+ )
138
+
139
+ /**
140
+ * How each semantic role is written back to a Base24 slot on export.
141
+ *
142
+ * The choices are not arbitrary. `base01` receives `black` rather than the
143
+ * background because an ANSI black that equals the background is invisible as
144
+ * foreground text — which is precisely the defect the import side documents. The
145
+ * surface ladder has no Base24 slot at all, so it is deliberately absent here:
146
+ * Base24 cannot express it, and pretending otherwise by packing four rungs into
147
+ * `base01`/`base02` would make the export lie about what it preserves.
148
+ */
149
+ export const THEME_TO_BASE24: Readonly<Record<keyof AdeaThemeColors, Base24Slot | undefined>> =
150
+ Object.freeze({
151
+ background: 'base00',
152
+ foreground: 'base05',
153
+ surface: undefined,
154
+ surfaceElevated: undefined,
155
+ surfaceHover: undefined,
156
+ surfaceActive: undefined,
157
+ border: undefined,
158
+ borderMuted: undefined,
159
+ text: 'base06',
160
+ textMuted: 'base04',
161
+ textSubtle: 'base03',
162
+ accent: 'base0D',
163
+ accentForeground: undefined,
164
+ success: 'base0B',
165
+ warning: 'base0A',
166
+ error: 'base08',
167
+ info: 'base0C',
168
+ })
169
+
170
+ /** Thrown when a palette is missing a slot or holds an unparseable colour. */
171
+ export class Base24ParseError extends Error {
172
+ constructor(
173
+ message: string,
174
+ readonly slot: string
175
+ ) {
176
+ super(message)
177
+ this.name = 'Base24ParseError'
178
+ }
179
+ }
180
+
181
+ /** Parses every slot of a palette into OKLCH, or throws naming the offending slot. */
182
+ export function parseBase24Palette(palette: Base24Palette): Record<Base24Slot, Oklch> {
183
+ const parsed = {} as Record<Base24Slot, Oklch>
184
+ for (const slot of BASE24_SLOTS) {
185
+ const raw = palette[slot]
186
+ if (typeof raw !== 'string' || raw.length === 0) {
187
+ throw new Base24ParseError(`Base24 slot ${slot} is missing`, slot)
188
+ }
189
+ const color = parseColor(raw)
190
+ if (!color) {
191
+ throw new Base24ParseError(`Base24 slot ${slot} is not a colour: ${String(raw)}`, slot)
192
+ }
193
+ parsed[slot] = color
194
+ }
195
+ return parsed
196
+ }
197
+
198
+ /**
199
+ * Rotates a hue by a fixed number of degrees.
200
+ *
201
+ * Used only for the two Base24 slots Adea has no role for. A rotation is chosen
202
+ * over an interpolation toward grey because both source colours are already
203
+ * saturated, and a rotation keeps saturation while moving the hue far enough to
204
+ * read as a distinct token.
205
+ */
206
+ function rotateHue(color: Oklch, degrees: number): Oklch {
207
+ return { ...color, h: (color.h + degrees + 360) % 360 }
208
+ }
209
+
210
+ /**
211
+ * Writes a theme back to Base24.
212
+ *
213
+ * `base09` and `base0F` are synthesised — see this module's header. `base10` and
214
+ * `base11` are the two rungs below `base00`, which is what the specification
215
+ * defines them as and what a terminal needs in order to draw a black that is
216
+ * darker than the background.
217
+ */
218
+ export function toBase24(
219
+ theme: AdeaTheme,
220
+ options: { author?: string; name?: string } = {}
221
+ ): Base24Scheme {
222
+ const background = requireColor(theme.colors.background, 'background')
223
+ const red = requireColor(theme.ansi.red, 'ansi.red')
224
+ const yellow = requireColor(theme.ansi.yellow, 'ansi.yellow')
225
+ const text = requireColor(theme.colors.text, 'text')
226
+
227
+ const palette: Record<Base24Slot, string> = {
228
+ base00: theme.colors.background,
229
+ base01: theme.ansi.black,
230
+ base02: theme.selection,
231
+ base03: theme.colors.textSubtle,
232
+ base04: theme.colors.textMuted,
233
+ base05: theme.colors.foreground,
234
+ base06: theme.colors.text,
235
+ base07: theme.ansi.brightWhite,
236
+ base08: theme.ansi.red,
237
+ // Synthesised: Base24's constants/orange slot and its deprecated/brown slot
238
+ // have no Adea role. See the module header.
239
+ base09: formatOklch(rotateHue(red, -18)),
240
+ base0A: theme.ansi.yellow,
241
+ base0B: theme.ansi.green,
242
+ base0C: theme.ansi.cyan,
243
+ base0D: theme.ansi.blue,
244
+ base0E: theme.ansi.magenta,
245
+ base0F: formatOklch(rotateHue(yellow, 22)),
246
+ base10: formatOklch({ ...background, l: Math.max(0, background.l - 0.03) }),
247
+ base11: formatOklch({ ...background, l: Math.max(0, background.l - 0.06) }),
248
+ base12: theme.ansi.brightRed,
249
+ base13: theme.ansi.brightYellow,
250
+ base14: theme.ansi.brightGreen,
251
+ base15: theme.ansi.brightCyan,
252
+ base16: theme.ansi.brightBlue,
253
+ base17: theme.ansi.brightMagenta,
254
+ }
255
+
256
+ // `base05` is the default foreground and the caret; a palette whose text role
257
+ // differs from its ANSI white means the two disagree about which is the body
258
+ // colour. The foreground role wins, because every contrast floor is defined
259
+ // against it.
260
+ void text
261
+
262
+ return {
263
+ system: 'base24',
264
+ name: options.name ?? theme.name,
265
+ author: options.author ?? 'Adea',
266
+ variant: theme.appearance,
267
+ palette,
268
+ }
269
+ }
270
+
271
+ function requireColor(value: string, role: string): Oklch {
272
+ const color = parseColor(value)
273
+ if (!color) throw new Error(`Theme role ${role} is not a colour: ${value}`)
274
+ return color
275
+ }
276
+
277
+ /** Serialises a scheme to the YAML dialect Base24 schemes are published in. */
278
+ export function formatBase24Scheme(scheme: Base24Scheme): string {
279
+ const lines = [
280
+ 'system: "base24"',
281
+ `name: ${JSON.stringify(scheme.name)}`,
282
+ `author: ${JSON.stringify(scheme.author)}`,
283
+ `variant: ${JSON.stringify(scheme.variant)}`,
284
+ 'palette:',
285
+ ...BASE24_SLOTS.map((slot) => ` ${slot}: ${JSON.stringify(scheme.palette[slot])}`),
286
+ ]
287
+ return `${lines.join('\n')}\n`
288
+ }
289
+
290
+ /**
291
+ * Reads the YAML dialect Base24 schemes are published in.
292
+ *
293
+ * Hand-written rather than pulled from a YAML library: the format is a fixed set
294
+ * of scalar keys, the package has no runtime dependencies and no build-time
295
+ * dependencies beyond the TypeScript compiler, and a general parser would accept
296
+ * documents this bridge cannot actually honour. Anything outside the specification
297
+ * is rejected loudly instead of being parsed into a half-scheme.
298
+ */
299
+ export function parseBase24Scheme(source: string): Base24Scheme {
300
+ const scalars = new Map<string, string>()
301
+ const palette = new Map<string, string>()
302
+ let inPalette = false
303
+
304
+ for (const rawLine of source.split('\n')) {
305
+ // Strip comments before trimming so the dataset's provenance comments, which
306
+ // sit on the same line as a value, do not end up inside the colour string.
307
+ const line = rawLine.replace(/\s+#.*$/, '').trimEnd()
308
+ if (line.trim().length === 0) continue
309
+
310
+ const indented = /^\s/.test(line)
311
+ const separator = line.indexOf(':')
312
+ if (separator === -1) continue
313
+
314
+ const key = (indented ? line.slice(0, separator) : line.slice(0, separator)).trim()
315
+ const value = line
316
+ .slice(separator + 1)
317
+ .trim()
318
+ .replace(/^["']|["']$/g, '')
319
+
320
+ if (!indented) {
321
+ inPalette = key === 'palette'
322
+ if (!inPalette) scalars.set(key, value)
323
+ continue
324
+ }
325
+ if (inPalette) palette.set(key, value)
326
+ }
327
+
328
+ const missing = ['name', 'variant'].filter((key) => !scalars.get(key))
329
+ if (missing.length > 0) {
330
+ throw new Error(`Base24 scheme is missing required keys: ${missing.join(', ')}`)
331
+ }
332
+
333
+ const variant = scalars.get('variant')
334
+ if (variant !== 'dark' && variant !== 'light') {
335
+ throw new Error(`Base24 scheme variant must be dark or light, got: ${String(variant)}`)
336
+ }
337
+
338
+ const resolved = {} as Record<Base24Slot, string>
339
+ for (const slot of BASE24_SLOTS) {
340
+ const value = palette.get(slot)
341
+ if (!value) throw new Base24ParseError(`Base24 scheme is missing ${slot}`, slot)
342
+ if (!parseColor(value)) {
343
+ throw new Base24ParseError(`Base24 scheme has an unparseable ${slot}: ${value}`, slot)
344
+ }
345
+ resolved[slot] = value
346
+ }
347
+
348
+ return {
349
+ system: 'base24',
350
+ name: scalars.get('name') ?? '',
351
+ author: scalars.get('author') ?? 'unknown',
352
+ variant,
353
+ palette: resolved,
354
+ }
355
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * The application adapter: a theme as CSS custom properties.
3
+ *
4
+ * This is the adapter an application uses. Everything a component needs is written
5
+ * onto a selector as a custom property, in OKLCH, so the browser does the colour
6
+ * work and a theme switch is an attribute change rather than a re-render.
7
+ *
8
+ * ## Two names for the same values
9
+ *
10
+ * {@link themeCssVariables} emits the canonical names — `--adea-background`,
11
+ * `--adea-surface` — and is what a new consumer should use.
12
+ * `adapters/shadcn.ts` emits the shadcn vocabulary this organisation's components
13
+ * are already written against. Both are generated from the same theme object, so
14
+ * the two can never disagree about what `surface` is; that is the entire reason to
15
+ * have one source.
16
+ *
17
+ * ## What is derived rather than stored
18
+ *
19
+ * A handful of variables have no role in the canonical schema and are computed:
20
+ * the chart series (see `syntax.ts`), the status tints used behind status text, and
21
+ * the ANSI variables. They are derived on every call rather than cached, because
22
+ * they are pure functions of the theme and caching them would put a second copy of
23
+ * the truth in the module.
24
+ *
25
+ * ## Why `oklch()` strings and not hex
26
+ *
27
+ * Custom properties are evaluated by the browser. Writing OKLCH means the values
28
+ * stay on the perceptual axis all the way to paint, so a consumer can compose them
29
+ * — `color-mix(in oklch, var(--adea-accent), transparent 20%)` — and get a
30
+ * predictable result. Converted to hex on the way out, that composition would be
31
+ * an sRGB blend, which is what makes hand-mixed tints go muddy.
32
+ */
33
+
34
+ import type { AdeaTheme, AdeaThemeColors } from '../schema'
35
+ import { THEME_COLOR_KEYS, ANSI_KEYS } from '../schema'
36
+ import { STATUS_ROLES, chartSeries, statusForeground, tint } from '../derive'
37
+
38
+ /** How the variables are named. */
39
+ export interface CssOptions {
40
+ /** The custom-property namespace. Defaults to `adea`. */
41
+ prefix?: string
42
+ /** The selector the variables are written on. Defaults to `:root`. */
43
+ selector?: string
44
+ /** Also emit the derived status tints, chart series and ansi ramp. Defaults to true. */
45
+ includeDerived?: boolean
46
+ /** Indent each declaration. Used by {@link catalogueCss}. */
47
+ indent?: string
48
+ }
49
+
50
+ const DEFAULT_PREFIX = 'adea'
51
+
52
+ function variableName(name: string, prefix: string): string {
53
+ return prefix ? `--${prefix}-${name}` : `--${name}`
54
+ }
55
+
56
+ /** camelCase to kebab-case, the way the property names read in CSS. */
57
+ function kebab(name: string): string {
58
+ return name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)
59
+ }
60
+
61
+ /**
62
+ * The literal roles, plus the derived ones.
63
+ *
64
+ * `borderMuted` is emitted for completeness even though shadcn's vocabulary has no
65
+ * equivalent; a consumer that wants a quieter rule can use it directly.
66
+ */
67
+ export function themeCssVariables(
68
+ theme: AdeaTheme,
69
+ options: Omit<CssOptions, 'selector' | 'indent'> = {}
70
+ ): Record<string, string> {
71
+ const prefix = options.prefix ?? DEFAULT_PREFIX
72
+ const includeDerived = options.includeDerived ?? true
73
+ const variables: Record<string, string> = {}
74
+
75
+ for (const key of THEME_COLOR_KEYS) {
76
+ variables[variableName(kebab(key), prefix)] = theme.colors[key]
77
+ }
78
+
79
+ variables[variableName('cursor', prefix)] = theme.cursor
80
+ variables[variableName('selection', prefix)] = theme.selection
81
+
82
+ if (!includeDerived) return variables
83
+
84
+ for (const key of ANSI_KEYS) {
85
+ variables[variableName(`ansi-${kebab(key)}`, prefix)] = theme.ansi[key]
86
+ }
87
+
88
+ chartSeries(theme).forEach((value, index) => {
89
+ variables[variableName(`chart-${index + 1}`, prefix)] = value
90
+ })
91
+
92
+ // Status fills and the text that goes on them. The fill is the role at low
93
+ // strength against the canvas; the foreground is the theme's legible extreme for
94
+ // that fill, not the theme's body text — see `derive.statusForeground`.
95
+ for (const role of STATUS_ROLES) {
96
+ variables[variableName(`${role}-subtle`, prefix)] = tint(
97
+ theme.colors[role],
98
+ theme.colors.background
99
+ )
100
+ variables[variableName(`${role}-foreground`, prefix)] = statusForeground(theme, role)
101
+ }
102
+
103
+ // A dimmer rule for tables and inner separators, and the surface one step below
104
+ // the canvas that a well or a code block sits in.
105
+ variables[variableName('surface-sunken', prefix)] = tint(
106
+ theme.colors.text,
107
+ theme.colors.background,
108
+ 0.03
109
+ )
110
+
111
+ return variables
112
+ }
113
+
114
+ /** A CSS rule for one theme. */
115
+ export function themeCss(theme: AdeaTheme, options: CssOptions = {}): string {
116
+ const selector = options.selector ?? ':root'
117
+ const indent = options.indent ?? ' '
118
+ const entries = Object.entries(themeCssVariables(theme, options))
119
+
120
+ const body = entries
121
+ .map(([name, value]) => `${indent}${name}: ${value};`)
122
+ .join('\n')
123
+
124
+ return `${selector} {\n${body}\n}`
125
+ }
126
+
127
+ /**
128
+ * One rule per theme, selected by attribute.
129
+ *
130
+ * This is what a consumer ships when it wants the whole catalogue without running
131
+ * JavaScript: every theme's variables are in the stylesheet, and switching is
132
+ * `document.documentElement.dataset.theme = id`. The cost is that all of them
133
+ * download; an application that prefers a smaller bundle should call
134
+ * {@link themeCss} for one theme at a time instead.
135
+ */
136
+ export function catalogueCss(
137
+ themes: readonly AdeaTheme[],
138
+ options: CssOptions & { attribute?: string } = {}
139
+ ): string {
140
+ const attribute = options.attribute ?? 'data-theme'
141
+ return themes
142
+ .map((theme) => themeCss(theme, { ...options, selector: `[${attribute}='${theme.id}']` }))
143
+ .join('\n\n')
144
+ }
145
+
146
+ /** The canonical variable name for a role, so consumers need not build the string. */
147
+ export function cssVariableName(role: keyof AdeaThemeColors, prefix = DEFAULT_PREFIX): string {
148
+ return variableName(kebab(role), prefix)
149
+ }