@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,325 @@
1
+ /**
2
+ * The catalogue's gate.
3
+ *
4
+ * Nothing enters this package on taste. Every theme — imported from a stranger's
5
+ * palette or authored here — is measured against the same floors, and this module
6
+ * is where that happens. It is what makes the catalogue able to accept external
7
+ * palettes at all: a palette either clears the floors or it fails the build with
8
+ * the pairing and the number, and a reviewer never has to decide by eye whether
9
+ * `#8f8f8f` on `#2e2e2e` is acceptable.
10
+ *
11
+ * ## What is measured, and what is not
12
+ *
13
+ * Measured: every pairing a component renders as *text*, plus the border and
14
+ * selection pairings, which are decorative and have a perceptibility floor rather
15
+ * than a legibility one.
16
+ *
17
+ * Not measured: a colour against itself, and the surface ladder against anything
18
+ * but the canvas. A rung's job is to be distinguishable from the canvas, not to
19
+ * carry text — components put text on `background`, and the ladder is decoration on
20
+ * top of it. Measuring the ladder against every foreground would produce failures
21
+ * no theme could pass and no component would care about.
22
+ *
23
+ * ## Why the validator is exported
24
+ *
25
+ * A consumer that adds its own theme — an application with a brand palette, or a
26
+ * test that builds a theme from a design tool — needs to run the same gate. It is
27
+ * the only way to know a theme is admissible without reimplementing the rules.
28
+ */
29
+
30
+ import type { AdeaTheme, AnsiKey, ThemeColorKey } from './schema'
31
+ import { ANSI_KEYS, THEME_COLOR_KEYS } from './schema'
32
+ import type { Oklch } from './oklch'
33
+ import { contrastRatio, deltaEok, parseColor } from './oklch'
34
+ import { CONTRAST_FLOORS } from './normalize'
35
+ import { STATUS_ROLES, statusForeground } from './derive'
36
+
37
+ /** One measured pairing that failed. */
38
+ export interface ContrastFinding {
39
+ themeId: string
40
+ /** The role the finding is about, e.g. `textMuted`. */
41
+ role: string
42
+ /** The role or roles it was measured against. */
43
+ against: readonly string[]
44
+ ratio: number
45
+ minimum: number
46
+ message: string
47
+ }
48
+
49
+ /**
50
+ * The pairings a theme is required to satisfy.
51
+ *
52
+ * Declared as data rather than as a sequence of assertions so that the set is
53
+ * reviewable in one place, and so a consumer can see exactly what is guaranteed.
54
+ * `background` appears as the counterpart almost everywhere because a component's
55
+ * default surface is the canvas; the two exceptions are the pairs drawn on a
56
+ * raised surface or on the accent.
57
+ */
58
+ export interface Pairing {
59
+ role: ThemeColorKey | AnsiKey | 'accentForeground' | 'border' | 'borderMuted' | 'selection'
60
+ against: readonly (ThemeColorKey | 'background' | 'accent')[]
61
+ minimum: number
62
+ /** Findings for pairings marked `advisory` are reported but do not fail. */
63
+ advisory?: boolean
64
+ }
65
+
66
+ /**
67
+ * Every pairing the catalogue guarantees.
68
+ *
69
+ * `textSubtle` and `brightBlack` are held to 3:1 rather than 4.5:1 — see
70
+ * `derive` and the schema's note on `textSubtle`. That is not a relaxation for
71
+ * convenience: at 4.5:1 there is no visible difference between tertiary and
72
+ * secondary text and the role stops earning its place in the schema.
73
+ */
74
+ export const REQUIRED_PAIRINGS: readonly Pairing[] = Object.freeze([
75
+ { role: 'text', against: ['background'], minimum: CONTRAST_FLOORS.text },
76
+ { role: 'foreground', against: ['background'], minimum: CONTRAST_FLOORS.text },
77
+ { role: 'textMuted', against: ['background'], minimum: CONTRAST_FLOORS.textMuted },
78
+ { role: 'text', against: ['surface'], minimum: CONTRAST_FLOORS.text },
79
+ { role: 'text', against: ['surfaceElevated'], minimum: CONTRAST_FLOORS.text },
80
+ { role: 'textMuted', against: ['surface'], minimum: CONTRAST_FLOORS.textMuted },
81
+ { role: 'textSubtle', against: ['background'], minimum: CONTRAST_FLOORS.textSubtle },
82
+ { role: 'accent', against: ['background'], minimum: CONTRAST_FLOORS.accent },
83
+ {
84
+ role: 'accent',
85
+ against: ['surface', 'surfaceElevated'],
86
+ minimum: CONTRAST_FLOORS.accentRaised,
87
+ },
88
+ { role: 'accentForeground', against: ['accent'], minimum: CONTRAST_FLOORS.accentForeground },
89
+ { role: 'border', against: ['background'], minimum: CONTRAST_FLOORS.border },
90
+ { role: 'borderMuted', against: ['background'], minimum: CONTRAST_FLOORS.borderMuted },
91
+ { role: 'selection', against: ['background'], minimum: CONTRAST_FLOORS.selection },
92
+ ])
93
+
94
+ /**
95
+ * The status pairings, which are the same for each of the four roles.
96
+ *
97
+ * Two tiers, matching the normalizer: the body floor on the canvas, where a status
98
+ * colour is read as small text, and the indicator floor on a raised surface. The
99
+ * reasoning is in `normalize.CONTRAST_FLOORS.statusRaised`.
100
+ */
101
+ export const STATUS_PAIRINGS: readonly Pairing[] = Object.freeze([
102
+ ...STATUS_ROLES.map((role) => ({
103
+ role,
104
+ against: ['background'] as const,
105
+ minimum: CONTRAST_FLOORS.status,
106
+ })),
107
+ ...STATUS_ROLES.map((role) => ({
108
+ role,
109
+ against: ['surface', 'surfaceElevated'] as const,
110
+ minimum: CONTRAST_FLOORS.statusRaised,
111
+ })),
112
+ ])
113
+
114
+ /**
115
+ * The greyscale ANSI invariants.
116
+ *
117
+ * Deliberately **not** contrast floors. A palette's ANSI greyscale is the author's
118
+ * decision and the useful ones are not high-contrast: on a light terminal ANSI
119
+ * white is nearly the canvas by definition, because it is the colour used to *fill*
120
+ * rather than to write with, and holding it to a legibility floor would reject
121
+ * every well-made light scheme. Base24 compounds this by not naming `black`,
122
+ * `white` or `brightBlack` at all.
123
+ *
124
+ * What is worth asserting is the pair of failures that are actually defects and are
125
+ * invisible in a screenshot:
126
+ *
127
+ * - **Collapse** — a ramp whose roles resolve to the same colour, which means the
128
+ * theme cannot express dim text at all.
129
+ * - **Inversion** — an ANSI black lighter than its bright black on a dark theme, or
130
+ * a light theme whose "white" is darker than its text. This is the defect the
131
+ * normalizer's ramp logic was rewritten to avoid, and the one a palette with a
132
+ * degenerate `base05`/`base06`/`base07` produces.
133
+ */
134
+ export interface AnsiInvariant {
135
+ themeId: string
136
+ role: string
137
+ message: string
138
+ }
139
+
140
+ /** Checks the greyscale ramp for collapse and inversion. */
141
+ export function checkAnsiRamp(theme: AdeaTheme): AnsiInvariant[] {
142
+ const findings: AnsiInvariant[] = []
143
+ const black = parseColor(theme.ansi.black)
144
+ const brightBlack = parseColor(theme.ansi.brightBlack)
145
+ const white = parseColor(theme.ansi.white)
146
+ const brightWhite = parseColor(theme.ansi.brightWhite)
147
+
148
+ if (black && brightBlack) {
149
+ if (deltaEok(black, brightBlack) < 0.02) {
150
+ findings.push({
151
+ themeId: theme.id,
152
+ role: 'black',
153
+ message: 'ANSI black and bright black resolve to the same colour, so the theme cannot express dim text',
154
+ })
155
+ }
156
+ // On both appearances ANSI 8 sits above ANSI 0 in lightness: it is the dim grey
157
+ // a comment is written in, and ANSI 0 is the darker of the pair.
158
+ if (brightBlack.l <= black.l) {
159
+ findings.push({
160
+ themeId: theme.id,
161
+ role: 'brightBlack',
162
+ message: `ANSI bright black (L ${brightBlack.l.toFixed(3)}) is not lighter than ANSI black (L ${black.l.toFixed(3)}), which inverts the greyscale ramp`,
163
+ })
164
+ }
165
+ }
166
+
167
+ // The light end is deliberately not asserted. On a light terminal the ANSI white
168
+ // and bright white roles are *foregrounds*: GitHub's light scheme, which Adea's
169
+ // own light theme uses, sets white to a mid grey and bright white darker still,
170
+ // and several palettes in the catalogue set the two to the same value. There is no
171
+ // invariant there — unlike ANSI 0/8, whose ordering holds in both appearances.
172
+ void white
173
+ void brightWhite
174
+
175
+ return findings
176
+ }
177
+
178
+ function resolve(theme: AdeaTheme, role: string): Oklch | undefined {
179
+ if (role === 'accent') return parseColor(theme.colors.accent)
180
+ if (role === 'background') return parseColor(theme.colors.background)
181
+ if ((THEME_COLOR_KEYS as readonly string[]).includes(role)) {
182
+ return parseColor(theme.colors[role as ThemeColorKey])
183
+ }
184
+ if ((ANSI_KEYS as readonly string[]).includes(role)) {
185
+ return parseColor(theme.ansi[role as AnsiKey])
186
+ }
187
+ if (role === 'cursor') return parseColor(theme.cursor)
188
+ if (role === 'selection') return parseColor(theme.selection)
189
+ return undefined
190
+ }
191
+
192
+ /** Measures one pairing, returning a finding when it fails. */
193
+ function measure(theme: AdeaTheme, pairing: Pairing): ContrastFinding | undefined {
194
+ const subject = resolve(theme, pairing.role)
195
+ if (!subject) {
196
+ return {
197
+ themeId: theme.id,
198
+ role: pairing.role,
199
+ against: pairing.against,
200
+ ratio: 0,
201
+ minimum: pairing.minimum,
202
+ message: `${pairing.role} is missing or is not a colour`,
203
+ }
204
+ }
205
+
206
+ for (const counterpartName of pairing.against) {
207
+ const counterpart = resolve(theme, counterpartName)
208
+ if (!counterpart) continue
209
+ const ratio = contrastRatio(subject, counterpart)
210
+ if (ratio < pairing.minimum - 1e-9) {
211
+ return {
212
+ themeId: theme.id,
213
+ role: pairing.role,
214
+ against: [counterpartName],
215
+ ratio,
216
+ minimum: pairing.minimum,
217
+ message: `${pairing.role} measures ${ratio.toFixed(2)}:1 against ${counterpartName}, below the ${pairing.minimum}:1 floor`,
218
+ }
219
+ }
220
+ }
221
+
222
+ return undefined
223
+ }
224
+
225
+ /**
226
+ * Validates one theme.
227
+ *
228
+ * Returns every failure rather than the first, because a theme with three bad
229
+ * pairings should be fixed in one pass — and because a palette that fails one floor
230
+ * usually fails several, and seeing all of them is what shows which single value is
231
+ * wrong.
232
+ */
233
+ export function validateTheme(theme: AdeaTheme): ContrastFinding[] {
234
+ const findings: ContrastFinding[] = []
235
+
236
+ for (const pairing of [...REQUIRED_PAIRINGS, ...STATUS_PAIRINGS]) {
237
+ const finding = measure(theme, pairing)
238
+ if (finding) findings.push(finding)
239
+ }
240
+
241
+ // The greyscale ramp's failures are structural rather than contrast-based, so
242
+ // they are reported as findings with no ratio.
243
+ for (const invariant of checkAnsiRamp(theme)) {
244
+ findings.push({
245
+ themeId: invariant.themeId,
246
+ role: invariant.role,
247
+ against: [],
248
+ ratio: 0,
249
+ minimum: 0,
250
+ message: invariant.message,
251
+ })
252
+ }
253
+
254
+ // The status foreground pairing cannot be declared as a table entry because it
255
+ // depends on the theme: the correct colour is whichever extreme of the theme
256
+ // measures better, so it is derived and then measured rather than named.
257
+ for (const role of STATUS_ROLES) {
258
+ const fill = parseColor(theme.colors[role])
259
+ const chosen = parseColor(statusForeground(theme, role))
260
+ if (!fill || !chosen) continue
261
+ const ratio = contrastRatio(chosen, fill)
262
+ if (ratio < CONTRAST_FLOORS.accentForeground - 1e-9) {
263
+ findings.push({
264
+ themeId: theme.id,
265
+ role: `${role}-foreground`,
266
+ against: [role],
267
+ ratio,
268
+ minimum: CONTRAST_FLOORS.accentForeground,
269
+ message: `no text colour clears ${CONTRAST_FLOORS.accentForeground}:1 on the ${role} fill; the best of the theme's two extremes is ${ratio.toFixed(2)}:1`,
270
+ })
271
+ }
272
+ }
273
+
274
+ return findings
275
+ }
276
+
277
+ /**
278
+ * Validates a whole catalogue.
279
+ *
280
+ * Also checks the things that are true of a catalogue rather than of a theme:
281
+ * unique ids, a shape that is complete, and a mix of appearances. A catalogue that
282
+ * is all dark leaves a light-theme user with one option, which is a defect in the
283
+ * catalogue rather than in any theme.
284
+ */
285
+ export function validateCatalogue(themes: readonly AdeaTheme[]): ContrastFinding[] {
286
+ const findings: ContrastFinding[] = []
287
+
288
+ const ids = new Set<string>()
289
+ for (const theme of themes) {
290
+ if (ids.has(theme.id)) {
291
+ findings.push({
292
+ themeId: theme.id,
293
+ role: 'id',
294
+ against: [],
295
+ ratio: 0,
296
+ minimum: 0,
297
+ message: `duplicate theme id: ${theme.id}`,
298
+ })
299
+ }
300
+ ids.add(theme.id)
301
+ findings.push(...validateTheme(theme))
302
+ }
303
+
304
+ const light = themes.filter((theme) => theme.appearance === 'light').length
305
+ const dark = themes.filter((theme) => theme.appearance === 'dark').length
306
+ if (light === 0 || dark === 0) {
307
+ findings.push({
308
+ themeId: 'catalogue',
309
+ role: 'appearance',
310
+ against: [],
311
+ ratio: 0,
312
+ minimum: 0,
313
+ message: `catalogue covers ${light} light and ${dark} dark themes; both appearances must be represented`,
314
+ })
315
+ }
316
+
317
+ return findings
318
+ }
319
+
320
+ /** Formats findings for a terminal, one per line. */
321
+ export function formatFindings(findings: readonly ContrastFinding[]): string {
322
+ return findings
323
+ .map((finding) => `${finding.themeId} ${finding.role}: ${finding.message}`)
324
+ .join('\n')
325
+ }