@timmo001/groundwork-tokens 0.0.0-stage → 0.0.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/LICENSE +201 -0
- package/README.md +34 -2
- package/generated/Themes.qml +7 -0
- package/generated/ansi.json +43 -0
- package/generated/opentui.ts +1536 -0
- package/generated/themes.css +3150 -0
- package/generated/themes.json +2604 -0
- package/generated/themes.ts +2606 -0
- package/package.json +55 -4
- package/src/color.ts +122 -0
- package/src/generate.ts +648 -0
- package/src/index.ts +11 -0
- package/src/runtime.ts +36 -0
- package/src/schema.ts +126 -0
- package/src/solve.ts +440 -0
- package/themes/base.json +20 -0
- package/themes/bridge.json +21 -0
- package/themes/catppuccin-frappe.json +21 -0
- package/themes/catppuccin-latte.json +21 -0
- package/themes/catppuccin-macchiato.json +21 -0
- package/themes/catppuccin-mocha.json +21 -0
- package/themes/glazing.json +20 -0
- package/themes/slate.json +20 -0
- package/themes/snag.json +21 -0
- package/themes/survey.json +20 -0
- package/themes/tokyo-night-day.json +21 -0
- package/themes/tokyo-night-moon.json +21 -0
- package/themes/tokyo-night-storm.json +21 -0
- package/themes/tokyo-night.json +21 -0
package/src/schema.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The schema for `themes/*.json`: fonts, style, and the colors a theme sets
|
|
3
|
+
* for each mode.
|
|
4
|
+
*
|
|
5
|
+
* @module
|
|
6
|
+
*/
|
|
7
|
+
import { Schema } from "effect";
|
|
8
|
+
|
|
9
|
+
/** A color written as a six-digit sRGB hex, such as `#1b1e23`. */
|
|
10
|
+
export const Hex = Schema.String.check(Schema.isPattern(/^#[0-9a-f]{6}$/));
|
|
11
|
+
|
|
12
|
+
export const Font = Schema.Literals([
|
|
13
|
+
"Mona Sans",
|
|
14
|
+
"System UI",
|
|
15
|
+
"Inter",
|
|
16
|
+
"Geist",
|
|
17
|
+
"IBM Plex Sans",
|
|
18
|
+
"Noto Sans",
|
|
19
|
+
"Albert Sans",
|
|
20
|
+
"Hubot Sans",
|
|
21
|
+
"Atkinson Hyperlegible Next",
|
|
22
|
+
"Public Sans",
|
|
23
|
+
"Source Sans 3",
|
|
24
|
+
"Figtree",
|
|
25
|
+
"Manrope",
|
|
26
|
+
"Instrument Sans",
|
|
27
|
+
"Onest",
|
|
28
|
+
"Schibsted Grotesk",
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
export type Font = typeof Font.Type;
|
|
32
|
+
|
|
33
|
+
export const MonoFont = Schema.Literals([
|
|
34
|
+
"Geist Mono",
|
|
35
|
+
"JetBrains Mono",
|
|
36
|
+
"Monaspace Neon",
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
export type MonoFont = typeof MonoFont.Type;
|
|
40
|
+
|
|
41
|
+
/** How a theme looks beyond color: shape, density, type and surface accents. */
|
|
42
|
+
export const Style = Schema.Struct({
|
|
43
|
+
font: Font,
|
|
44
|
+
/** The font for figures and tabular numbers, when it differs from `font`. */
|
|
45
|
+
figures: Schema.optionalKey(Schema.Union([Font, MonoFont])),
|
|
46
|
+
mono: MonoFont,
|
|
47
|
+
radius: Schema.Struct({
|
|
48
|
+
panel: Schema.Number,
|
|
49
|
+
control: Schema.Number,
|
|
50
|
+
chip: Schema.Number,
|
|
51
|
+
}),
|
|
52
|
+
border: Schema.Struct({
|
|
53
|
+
width: Schema.Number,
|
|
54
|
+
strength: Schema.Literals(["none", "hairline", "soft", "strong"]),
|
|
55
|
+
}),
|
|
56
|
+
weights: Schema.Struct({
|
|
57
|
+
regular: Schema.Number,
|
|
58
|
+
strong: Schema.Number,
|
|
59
|
+
}),
|
|
60
|
+
density: Schema.Literals(["compact", "normal", "roomy"]),
|
|
61
|
+
capitals: Schema.Boolean,
|
|
62
|
+
link: Schema.Struct({
|
|
63
|
+
color: Schema.Literals(["text", "accent"]),
|
|
64
|
+
underline: Schema.Literals(["thin", "thick"]),
|
|
65
|
+
}),
|
|
66
|
+
surfaceAccent: Schema.Literals(["none", "sheen", "marker", "flange"]),
|
|
67
|
+
rows: Schema.Literals(["cards", "dense"]),
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
export interface Style extends Schema.Schema.Type<typeof Style> {}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A core theme: two inputs solved into every role for light, dark and Darker.
|
|
74
|
+
* The background sets the dark surface and the tint carried into light; the
|
|
75
|
+
* accent sets the hue and chroma of the one color the theme is known by.
|
|
76
|
+
*/
|
|
77
|
+
export const SolvedTheme = Schema.Struct({
|
|
78
|
+
kind: Schema.tag("solved"),
|
|
79
|
+
id: Schema.String,
|
|
80
|
+
name: Schema.String,
|
|
81
|
+
description: Schema.String,
|
|
82
|
+
background: Hex,
|
|
83
|
+
accent: Hex,
|
|
84
|
+
style: Style,
|
|
85
|
+
/** Apps that use the theme, as examples. Any app can pick any theme. */
|
|
86
|
+
examples: Schema.optionalKey(Schema.Array(Schema.String)),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
export interface SolvedTheme extends Schema.Schema.Type<typeof SolvedTheme> {}
|
|
90
|
+
|
|
91
|
+
/** One mode of a mapped theme: its own palette placed onto the roles. */
|
|
92
|
+
export const Palette = Schema.Struct({
|
|
93
|
+
lower: Hex,
|
|
94
|
+
background: Hex,
|
|
95
|
+
raised: Hex,
|
|
96
|
+
border: Hex,
|
|
97
|
+
text: Hex,
|
|
98
|
+
muted: Hex,
|
|
99
|
+
accent: Hex,
|
|
100
|
+
info: Hex,
|
|
101
|
+
success: Hex,
|
|
102
|
+
important: Hex,
|
|
103
|
+
warning: Hex,
|
|
104
|
+
danger: Hex,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
export interface Palette extends Schema.Schema.Type<typeof Palette> {}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* An optional theme mapped from an existing palette, such as Catppuccin, so it
|
|
111
|
+
* stays faithful. Each flavor is one mode, and it takes Base's style.
|
|
112
|
+
*/
|
|
113
|
+
export const MappedTheme = Schema.Struct({
|
|
114
|
+
kind: Schema.tag("mapped"),
|
|
115
|
+
id: Schema.String,
|
|
116
|
+
name: Schema.String,
|
|
117
|
+
family: Schema.String,
|
|
118
|
+
mode: Schema.Literals(["light", "dark"]),
|
|
119
|
+
palette: Palette,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
export interface MappedTheme extends Schema.Schema.Type<typeof MappedTheme> {}
|
|
123
|
+
|
|
124
|
+
export const Theme = Schema.Union([SolvedTheme, MappedTheme]);
|
|
125
|
+
|
|
126
|
+
export type Theme = typeof Theme.Type;
|
package/src/solve.ts
ADDED
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Solves a theme: derives every color role in every mode from the colors it
|
|
3
|
+
* sets, and checks each pair against its contrast target.
|
|
4
|
+
*
|
|
5
|
+
* @module
|
|
6
|
+
*/
|
|
7
|
+
import {
|
|
8
|
+
contrast,
|
|
9
|
+
fromHex,
|
|
10
|
+
inGamut,
|
|
11
|
+
shift,
|
|
12
|
+
solveLightness,
|
|
13
|
+
withAlpha,
|
|
14
|
+
withLightness,
|
|
15
|
+
type Oklch,
|
|
16
|
+
} from "./color.ts";
|
|
17
|
+
import type { MappedTheme, SolvedTheme, Style, Theme } from "./schema.ts";
|
|
18
|
+
|
|
19
|
+
export const statuses = [
|
|
20
|
+
"info",
|
|
21
|
+
"success",
|
|
22
|
+
"important",
|
|
23
|
+
"warning",
|
|
24
|
+
"danger",
|
|
25
|
+
] as const;
|
|
26
|
+
|
|
27
|
+
export type Status = (typeof statuses)[number];
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* GitHub's Markdown alert colors in dark (note, tip, important, warning and
|
|
31
|
+
* caution), which give each status its hue and chroma.
|
|
32
|
+
*/
|
|
33
|
+
const statusSeeds: Record<Status, string> = {
|
|
34
|
+
info: "#5b9ee0",
|
|
35
|
+
success: "#52ab69",
|
|
36
|
+
important: "#aa89e2",
|
|
37
|
+
warning: "#c49027",
|
|
38
|
+
danger: "#e47c74",
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Contrast targets, each measured on the surface where the role reads worst:
|
|
43
|
+
* the lowest surface in light modes, where fields sit, and the overlay in
|
|
44
|
+
* dark ones, where menus and dialogs sit.
|
|
45
|
+
*/
|
|
46
|
+
export const targets = {
|
|
47
|
+
"text-strong": 11,
|
|
48
|
+
"text-primary": 9,
|
|
49
|
+
"text-secondary": 4.6,
|
|
50
|
+
accent: 5,
|
|
51
|
+
status: 4.8,
|
|
52
|
+
"on-accent": 4.5,
|
|
53
|
+
} as const;
|
|
54
|
+
|
|
55
|
+
export const modes = ["light", "dark", "darker"] as const;
|
|
56
|
+
|
|
57
|
+
export type Mode = (typeof modes)[number];
|
|
58
|
+
|
|
59
|
+
export type Roles = Record<string, Oklch>;
|
|
60
|
+
|
|
61
|
+
export interface ResolvedTheme {
|
|
62
|
+
readonly id: string;
|
|
63
|
+
readonly name: string;
|
|
64
|
+
readonly description: string;
|
|
65
|
+
readonly optional: boolean;
|
|
66
|
+
readonly family: string | undefined;
|
|
67
|
+
/** Apps that use the theme, as examples. */
|
|
68
|
+
readonly examples: readonly string[];
|
|
69
|
+
readonly style: Style;
|
|
70
|
+
readonly modes: Partial<Record<Mode, Roles>>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The text or surface color that reads best on a fill. */
|
|
74
|
+
const onFill = (fill: Oklch, candidates: readonly Oklch[]) =>
|
|
75
|
+
candidates.reduce((best, candidate) =>
|
|
76
|
+
contrast(candidate, fill) > contrast(best, fill) ? candidate : best,
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const accentRoles = (
|
|
80
|
+
accent: Oklch,
|
|
81
|
+
surfaces: { readonly default: Oklch; readonly ground: Oklch },
|
|
82
|
+
dark: boolean,
|
|
83
|
+
textStrong: Oklch,
|
|
84
|
+
link: Style["link"]["color"],
|
|
85
|
+
) => {
|
|
86
|
+
const direction = dark ? "lighter" : "darker";
|
|
87
|
+
|
|
88
|
+
const text =
|
|
89
|
+
contrast(accent, surfaces.ground) >= targets.accent
|
|
90
|
+
? accent
|
|
91
|
+
: solveLightness(accent, surfaces.ground, targets.accent, direction);
|
|
92
|
+
|
|
93
|
+
const step = dark ? 0.05 : -0.05;
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
accent: text,
|
|
97
|
+
"fill-accent-resting": text,
|
|
98
|
+
"fill-accent-hovered": inGamut(shift(text, step)),
|
|
99
|
+
"fill-accent-pressed": inGamut(shift(text, -step / 2)),
|
|
100
|
+
"fill-accent-quiet": withAlpha(text, 0.16),
|
|
101
|
+
"border-accent": withAlpha(text, 0.5),
|
|
102
|
+
"on-accent": onFill(text, [
|
|
103
|
+
surfaces.default,
|
|
104
|
+
{ l: 1, c: 0, h: 0 },
|
|
105
|
+
{ l: 0.15, c: 0, h: 0 },
|
|
106
|
+
]),
|
|
107
|
+
focus: text,
|
|
108
|
+
link: link === "accent" ? text : textStrong,
|
|
109
|
+
};
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const statusRoles = (
|
|
113
|
+
seeds: Record<Status, Oklch>,
|
|
114
|
+
ground: Oklch,
|
|
115
|
+
dark: boolean,
|
|
116
|
+
solve: boolean,
|
|
117
|
+
): Roles =>
|
|
118
|
+
Object.fromEntries(
|
|
119
|
+
statuses.flatMap((status) => {
|
|
120
|
+
const seed = seeds[status];
|
|
121
|
+
|
|
122
|
+
const text =
|
|
123
|
+
!solve || contrast(seed, ground) >= targets.status
|
|
124
|
+
? seed
|
|
125
|
+
: solveLightness(
|
|
126
|
+
seed,
|
|
127
|
+
ground,
|
|
128
|
+
targets.status,
|
|
129
|
+
dark ? "lighter" : "darker",
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
return [
|
|
133
|
+
[status, text],
|
|
134
|
+
[`fill-${status}-quiet`, withAlpha(text, 0.14)],
|
|
135
|
+
[`border-${status}`, withAlpha(text, 0.45)],
|
|
136
|
+
];
|
|
137
|
+
}),
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
const solvedMode = (theme: SolvedTheme, mode: "light" | "dark") => {
|
|
141
|
+
const background = fromHex(theme.background);
|
|
142
|
+
const dark = mode === "dark";
|
|
143
|
+
const tint = { h: background.h, c: background.c };
|
|
144
|
+
|
|
145
|
+
const surface = (l: number, chromaScale = 1) =>
|
|
146
|
+
inGamut({ l, c: tint.c * chromaScale, h: tint.h });
|
|
147
|
+
|
|
148
|
+
// A theme's dark background is where cards sit; the page goes one step
|
|
149
|
+
// below it, so layers read as raised above the page.
|
|
150
|
+
const base = dark ? background.l - 0.03 : 0.975;
|
|
151
|
+
const chroma = dark ? 1 : 0.6;
|
|
152
|
+
const away = dark ? 1 : -1;
|
|
153
|
+
|
|
154
|
+
// Lower surfaces are recessed, so darker in both modes. Dark steps are
|
|
155
|
+
// wider, since lightness differences read smaller near black.
|
|
156
|
+
const surfaces = {
|
|
157
|
+
lower: surface(base - (dark ? 0.03 : 0.025), chroma),
|
|
158
|
+
low: surface(base - (dark ? 0.015 : 0.012), chroma),
|
|
159
|
+
default: surface(base, chroma),
|
|
160
|
+
raised: surface(dark ? base + 0.05 : 0.995, dark ? 1 : 0.3),
|
|
161
|
+
overlay: surface(dark ? base + 0.09 : 1, dark ? 1 : 0),
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
/** A step away from the default surface, wider in dark mode. */
|
|
165
|
+
const step = (light: number, darkStep: number) =>
|
|
166
|
+
base + away * (dark ? darkStep : light);
|
|
167
|
+
|
|
168
|
+
const text = { l: 0.5, c: Math.min(tint.c * 0.4, 0.02), h: tint.h };
|
|
169
|
+
const direction = dark ? "lighter" : "darker";
|
|
170
|
+
// Where text reads worst: the lowest surface in light, the overlay in dark.
|
|
171
|
+
const ground = dark ? surfaces.overlay : surfaces.lower;
|
|
172
|
+
|
|
173
|
+
const textStrong = solveLightness(
|
|
174
|
+
text,
|
|
175
|
+
ground,
|
|
176
|
+
targets["text-strong"],
|
|
177
|
+
direction,
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
// Black and white primaries: near-white fill in dark, near-black in light.
|
|
181
|
+
const primary = inGamut({ ...text, l: dark ? 0.93 : 0.24 });
|
|
182
|
+
const accent = fromHex(theme.accent);
|
|
183
|
+
// An accent with no real color, as Base's, is the primary itself.
|
|
184
|
+
const neutralAccent = accent.c < 0.02;
|
|
185
|
+
|
|
186
|
+
const seeds: Record<Status, Oklch> = {
|
|
187
|
+
info: fromHex(statusSeeds.info),
|
|
188
|
+
success: fromHex(statusSeeds.success),
|
|
189
|
+
important: fromHex(statusSeeds.important),
|
|
190
|
+
warning: fromHex(statusSeeds.warning),
|
|
191
|
+
danger: fromHex(statusSeeds.danger),
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
"surface-lower": surfaces.lower,
|
|
196
|
+
"surface-low": surfaces.low,
|
|
197
|
+
"surface-default": surfaces.default,
|
|
198
|
+
"surface-raised": surfaces.raised,
|
|
199
|
+
"surface-overlay": surfaces.overlay,
|
|
200
|
+
"border-quiet": surface(step(0.07, 0.1), chroma),
|
|
201
|
+
"border-normal": surface(step(0.08, 0.14), chroma),
|
|
202
|
+
"border-loud": surface(step(0.2, 0.22), chroma),
|
|
203
|
+
"text-strong": textStrong,
|
|
204
|
+
"text-primary": solveLightness(
|
|
205
|
+
text,
|
|
206
|
+
ground,
|
|
207
|
+
targets["text-primary"],
|
|
208
|
+
direction,
|
|
209
|
+
),
|
|
210
|
+
"text-secondary": solveLightness(
|
|
211
|
+
text,
|
|
212
|
+
ground,
|
|
213
|
+
targets["text-secondary"],
|
|
214
|
+
direction,
|
|
215
|
+
),
|
|
216
|
+
"text-disabled": solveLightness(text, ground, 2.4, direction),
|
|
217
|
+
"fill-neutral-resting": surface(step(0.06, 0.09), chroma),
|
|
218
|
+
"fill-neutral-hovered": surface(step(0.09, 0.12), chroma),
|
|
219
|
+
"fill-neutral-pressed": surface(step(0.12, 0.15), chroma),
|
|
220
|
+
"fill-neutral-selected": surface(step(0.11, 0.14), chroma * 1.5),
|
|
221
|
+
"fill-neutral-disabled": surface(step(0.035, 0.06), chroma),
|
|
222
|
+
"fill-primary-resting": primary,
|
|
223
|
+
"fill-primary-hovered": inGamut(shift(primary, -away * 0.06)),
|
|
224
|
+
"fill-primary-pressed": inGamut(shift(primary, -away * 0.1)),
|
|
225
|
+
"on-primary": dark ? surfaces.default : surfaces.raised,
|
|
226
|
+
...accentRoles(
|
|
227
|
+
neutralAccent ? primary : accent,
|
|
228
|
+
{ default: surfaces.default, ground },
|
|
229
|
+
dark,
|
|
230
|
+
textStrong,
|
|
231
|
+
theme.style.link.color,
|
|
232
|
+
),
|
|
233
|
+
...statusRoles(seeds, ground, dark, true),
|
|
234
|
+
};
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Darker: every surface `by` darker, hues kept, field outlines strengthened.
|
|
239
|
+
* Solved themes' dark page is already a step down, so they go less far.
|
|
240
|
+
*/
|
|
241
|
+
const darker = (dark: Roles, by = 0.045) => {
|
|
242
|
+
const surfaces = Object.fromEntries(
|
|
243
|
+
Object.entries(dark)
|
|
244
|
+
.filter(
|
|
245
|
+
([role]) =>
|
|
246
|
+
role.startsWith("surface-") ||
|
|
247
|
+
role.startsWith("fill-neutral-") ||
|
|
248
|
+
role === "on-primary",
|
|
249
|
+
)
|
|
250
|
+
.map(([role, color]) => [
|
|
251
|
+
role,
|
|
252
|
+
inGamut(withLightness(color, color.l - by)),
|
|
253
|
+
]),
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
return {
|
|
257
|
+
...dark,
|
|
258
|
+
...surfaces,
|
|
259
|
+
"border-quiet": dark["border-normal"] ?? { l: 0, c: 0, h: 0 },
|
|
260
|
+
"border-normal": dark["border-loud"] ?? { l: 0, c: 0, h: 0 },
|
|
261
|
+
};
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const mappedRoles = (theme: MappedTheme) => {
|
|
265
|
+
const palette = {
|
|
266
|
+
lower: fromHex(theme.palette.lower),
|
|
267
|
+
background: fromHex(theme.palette.background),
|
|
268
|
+
raised: fromHex(theme.palette.raised),
|
|
269
|
+
border: fromHex(theme.palette.border),
|
|
270
|
+
text: fromHex(theme.palette.text),
|
|
271
|
+
muted: fromHex(theme.palette.muted),
|
|
272
|
+
accent: fromHex(theme.palette.accent),
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
const dark = theme.mode === "dark";
|
|
276
|
+
const away = dark ? 1 : -1;
|
|
277
|
+
|
|
278
|
+
const surfaces = {
|
|
279
|
+
default: palette.background,
|
|
280
|
+
ground: dark ? palette.raised : palette.lower,
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
const seeds: Record<Status, Oklch> = {
|
|
284
|
+
info: fromHex(theme.palette.info),
|
|
285
|
+
success: fromHex(theme.palette.success),
|
|
286
|
+
important: fromHex(theme.palette.important),
|
|
287
|
+
warning: fromHex(theme.palette.warning),
|
|
288
|
+
danger: fromHex(theme.palette.danger),
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
"surface-lower": palette.lower,
|
|
293
|
+
"surface-low": inGamut({
|
|
294
|
+
...palette.background,
|
|
295
|
+
l: (palette.lower.l + palette.background.l) / 2,
|
|
296
|
+
}),
|
|
297
|
+
"surface-default": palette.background,
|
|
298
|
+
"surface-raised": palette.raised,
|
|
299
|
+
"surface-overlay": inGamut(shift(palette.raised, away * 0.025)),
|
|
300
|
+
"border-quiet": inGamut(shift(palette.border, -away * 0.03)),
|
|
301
|
+
"border-normal": palette.border,
|
|
302
|
+
"border-loud": inGamut(shift(palette.border, away * 0.08)),
|
|
303
|
+
"text-strong": palette.text,
|
|
304
|
+
"text-primary": palette.text,
|
|
305
|
+
"text-secondary": palette.muted,
|
|
306
|
+
"text-disabled": withAlpha(palette.muted, 0.6),
|
|
307
|
+
"fill-neutral-resting": inGamut(shift(palette.raised, away * 0.03)),
|
|
308
|
+
"fill-neutral-hovered": inGamut(shift(palette.raised, away * 0.06)),
|
|
309
|
+
"fill-neutral-pressed": inGamut(shift(palette.raised, away * 0.09)),
|
|
310
|
+
"fill-neutral-selected": inGamut(shift(palette.raised, away * 0.08)),
|
|
311
|
+
"fill-neutral-disabled": inGamut(shift(palette.raised, away * 0.015)),
|
|
312
|
+
"fill-primary-resting": palette.text,
|
|
313
|
+
"fill-primary-hovered": inGamut(shift(palette.text, -away * 0.06)),
|
|
314
|
+
"fill-primary-pressed": inGamut(shift(palette.text, -away * 0.1)),
|
|
315
|
+
"on-primary": palette.background,
|
|
316
|
+
...accentRoles(palette.accent, surfaces, dark, palette.text, "accent"),
|
|
317
|
+
accent: palette.accent,
|
|
318
|
+
"fill-accent-resting": palette.accent,
|
|
319
|
+
focus: palette.accent,
|
|
320
|
+
...statusRoles(seeds, palette.raised, dark, false),
|
|
321
|
+
};
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
/** Base's style with accent links, for themes mapped from a palette. */
|
|
325
|
+
const mappedStyle = (base: Style): Style => ({
|
|
326
|
+
font: base.font,
|
|
327
|
+
mono: base.mono,
|
|
328
|
+
radius: base.radius,
|
|
329
|
+
border: base.border,
|
|
330
|
+
weights: base.weights,
|
|
331
|
+
density: base.density,
|
|
332
|
+
capitals: base.capitals,
|
|
333
|
+
link: { color: "accent", underline: base.link.underline },
|
|
334
|
+
surfaceAccent: base.surfaceAccent,
|
|
335
|
+
rows: base.rows,
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
/** Resolve a theme into roles. Mapped themes take `baseStyle`, Base's style. */
|
|
339
|
+
export const resolve = (theme: Theme, baseStyle: Style): ResolvedTheme => {
|
|
340
|
+
if (theme.kind === "mapped") {
|
|
341
|
+
const roles = mappedRoles(theme);
|
|
342
|
+
|
|
343
|
+
return {
|
|
344
|
+
id: theme.id,
|
|
345
|
+
name: theme.name,
|
|
346
|
+
description: `${theme.name}, mapped onto the roles so it stays faithful to the ${theme.family} palette.`,
|
|
347
|
+
optional: true,
|
|
348
|
+
family: theme.family,
|
|
349
|
+
examples: [],
|
|
350
|
+
style: mappedStyle(baseStyle),
|
|
351
|
+
modes:
|
|
352
|
+
theme.mode === "dark"
|
|
353
|
+
? { dark: roles, darker: darker(roles) }
|
|
354
|
+
: { light: roles },
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const dark = solvedMode(theme, "dark");
|
|
359
|
+
|
|
360
|
+
return {
|
|
361
|
+
id: theme.id,
|
|
362
|
+
name: theme.name,
|
|
363
|
+
description: theme.description,
|
|
364
|
+
optional: false,
|
|
365
|
+
family: undefined,
|
|
366
|
+
examples: theme.examples ?? [],
|
|
367
|
+
style: theme.style,
|
|
368
|
+
modes: {
|
|
369
|
+
light: solvedMode(theme, "light"),
|
|
370
|
+
dark,
|
|
371
|
+
darker: darker(dark, 0.015),
|
|
372
|
+
},
|
|
373
|
+
};
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
export interface Check {
|
|
377
|
+
readonly theme: string;
|
|
378
|
+
readonly mode: Mode;
|
|
379
|
+
readonly role: string;
|
|
380
|
+
readonly ratio: number;
|
|
381
|
+
readonly target: number;
|
|
382
|
+
readonly passes: boolean;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const checkedRoles = [
|
|
386
|
+
["text-strong", targets["text-strong"]],
|
|
387
|
+
["text-primary", targets["text-primary"]],
|
|
388
|
+
["text-secondary", targets["text-secondary"]],
|
|
389
|
+
["accent", targets.accent],
|
|
390
|
+
...statuses.map((status) => [status, targets.status] as const),
|
|
391
|
+
] as const;
|
|
392
|
+
|
|
393
|
+
const textSurfaces = [
|
|
394
|
+
"surface-lower",
|
|
395
|
+
"surface-low",
|
|
396
|
+
"surface-default",
|
|
397
|
+
"surface-raised",
|
|
398
|
+
"surface-overlay",
|
|
399
|
+
];
|
|
400
|
+
|
|
401
|
+
/** Every role with a contrast target, measured on the surface where it reads worst. */
|
|
402
|
+
export const check = (theme: ResolvedTheme): readonly Check[] =>
|
|
403
|
+
modes.flatMap((mode) => {
|
|
404
|
+
const roles = theme.modes[mode];
|
|
405
|
+
|
|
406
|
+
if (roles === undefined) return [];
|
|
407
|
+
|
|
408
|
+
const black = { l: 0, c: 0, h: 0 };
|
|
409
|
+
const raised = roles["surface-raised"] ?? black;
|
|
410
|
+
|
|
411
|
+
const measure = (
|
|
412
|
+
role: string,
|
|
413
|
+
against: readonly Oklch[],
|
|
414
|
+
target: number,
|
|
415
|
+
): Check => {
|
|
416
|
+
const ratio = Math.min(
|
|
417
|
+
...against.map((surface) => contrast(roles[role] ?? black, surface)),
|
|
418
|
+
);
|
|
419
|
+
|
|
420
|
+
return {
|
|
421
|
+
theme: theme.id,
|
|
422
|
+
mode,
|
|
423
|
+
role,
|
|
424
|
+
ratio,
|
|
425
|
+
target,
|
|
426
|
+
passes: ratio >= target - 0.005,
|
|
427
|
+
};
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
const surfaces = textSurfaces.map((surface) => roles[surface] ?? raised);
|
|
431
|
+
|
|
432
|
+
return [
|
|
433
|
+
...checkedRoles.map(([role, target]) => measure(role, surfaces, target)),
|
|
434
|
+
measure(
|
|
435
|
+
"on-accent",
|
|
436
|
+
[roles["fill-accent-resting"] ?? raised],
|
|
437
|
+
targets["on-accent"],
|
|
438
|
+
),
|
|
439
|
+
];
|
|
440
|
+
});
|
package/themes/base.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"kind": "solved",
|
|
3
|
+
"id": "base",
|
|
4
|
+
"name": "Base",
|
|
5
|
+
"description": "Neutral surfaces with a slight blue-gray tint, black and white primaries, and links as strong text. What every theme builds on.",
|
|
6
|
+
"background": "#1b1e23",
|
|
7
|
+
"accent": "#e5e8ec",
|
|
8
|
+
"style": {
|
|
9
|
+
"font": "Mona Sans",
|
|
10
|
+
"mono": "Geist Mono",
|
|
11
|
+
"radius": { "panel": 10, "control": 8, "chip": 6 },
|
|
12
|
+
"border": { "width": 1, "strength": "hairline" },
|
|
13
|
+
"weights": { "regular": 400, "strong": 600 },
|
|
14
|
+
"density": "normal",
|
|
15
|
+
"capitals": false,
|
|
16
|
+
"link": { "color": "text", "underline": "thin" },
|
|
17
|
+
"surfaceAccent": "none",
|
|
18
|
+
"rows": "cards"
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"kind": "solved",
|
|
3
|
+
"id": "bridge",
|
|
4
|
+
"name": "Bridge",
|
|
5
|
+
"description": "A clear, bright blue on neutral charcoal. Medium radii, soft borders and pill chips.",
|
|
6
|
+
"examples": ["System Bridge"],
|
|
7
|
+
"background": "#1e1e22",
|
|
8
|
+
"accent": "#549ff7",
|
|
9
|
+
"style": {
|
|
10
|
+
"font": "Inter",
|
|
11
|
+
"mono": "JetBrains Mono",
|
|
12
|
+
"radius": { "panel": 12, "control": 8, "chip": 999 },
|
|
13
|
+
"border": { "width": 1, "strength": "soft" },
|
|
14
|
+
"weights": { "regular": 400, "strong": 600 },
|
|
15
|
+
"density": "normal",
|
|
16
|
+
"capitals": false,
|
|
17
|
+
"link": { "color": "accent", "underline": "thin" },
|
|
18
|
+
"surfaceAccent": "none",
|
|
19
|
+
"rows": "cards"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"kind": "mapped",
|
|
3
|
+
"id": "catppuccin-frappe",
|
|
4
|
+
"name": "Catppuccin Frappé",
|
|
5
|
+
"family": "Catppuccin",
|
|
6
|
+
"mode": "dark",
|
|
7
|
+
"palette": {
|
|
8
|
+
"lower": "#232634",
|
|
9
|
+
"background": "#303446",
|
|
10
|
+
"raised": "#414559",
|
|
11
|
+
"border": "#51576d",
|
|
12
|
+
"text": "#c6d0f5",
|
|
13
|
+
"muted": "#a5adce",
|
|
14
|
+
"accent": "#ca9ee6",
|
|
15
|
+
"info": "#8caaee",
|
|
16
|
+
"success": "#a6d189",
|
|
17
|
+
"important": "#ca9ee6",
|
|
18
|
+
"warning": "#e5c890",
|
|
19
|
+
"danger": "#e78284"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"kind": "mapped",
|
|
3
|
+
"id": "catppuccin-latte",
|
|
4
|
+
"name": "Catppuccin Latte",
|
|
5
|
+
"family": "Catppuccin",
|
|
6
|
+
"mode": "light",
|
|
7
|
+
"palette": {
|
|
8
|
+
"lower": "#dce0e8",
|
|
9
|
+
"background": "#eff1f5",
|
|
10
|
+
"raised": "#e6e9ef",
|
|
11
|
+
"border": "#bcc0cc",
|
|
12
|
+
"text": "#4c4f69",
|
|
13
|
+
"muted": "#6c6f85",
|
|
14
|
+
"accent": "#8839ef",
|
|
15
|
+
"info": "#1e66f5",
|
|
16
|
+
"success": "#40a02b",
|
|
17
|
+
"important": "#8839ef",
|
|
18
|
+
"warning": "#df8e1d",
|
|
19
|
+
"danger": "#d20f39"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"kind": "mapped",
|
|
3
|
+
"id": "catppuccin-macchiato",
|
|
4
|
+
"name": "Catppuccin Macchiato",
|
|
5
|
+
"family": "Catppuccin",
|
|
6
|
+
"mode": "dark",
|
|
7
|
+
"palette": {
|
|
8
|
+
"lower": "#181926",
|
|
9
|
+
"background": "#24273a",
|
|
10
|
+
"raised": "#363a4f",
|
|
11
|
+
"border": "#494d64",
|
|
12
|
+
"text": "#cad3f5",
|
|
13
|
+
"muted": "#a5adcb",
|
|
14
|
+
"accent": "#c6a0f6",
|
|
15
|
+
"info": "#8aadf4",
|
|
16
|
+
"success": "#a6da95",
|
|
17
|
+
"important": "#c6a0f6",
|
|
18
|
+
"warning": "#eed49f",
|
|
19
|
+
"danger": "#ed8796"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"kind": "mapped",
|
|
3
|
+
"id": "catppuccin-mocha",
|
|
4
|
+
"name": "Catppuccin Mocha",
|
|
5
|
+
"family": "Catppuccin",
|
|
6
|
+
"mode": "dark",
|
|
7
|
+
"palette": {
|
|
8
|
+
"lower": "#11111b",
|
|
9
|
+
"background": "#1e1e2e",
|
|
10
|
+
"raised": "#313244",
|
|
11
|
+
"border": "#45475a",
|
|
12
|
+
"text": "#cdd6f4",
|
|
13
|
+
"muted": "#a6adc8",
|
|
14
|
+
"accent": "#cba6f7",
|
|
15
|
+
"info": "#89b4fa",
|
|
16
|
+
"success": "#a6e3a1",
|
|
17
|
+
"important": "#cba6f7",
|
|
18
|
+
"warning": "#f9e2af",
|
|
19
|
+
"danger": "#f38ba8"
|
|
20
|
+
}
|
|
21
|
+
}
|