vscode-bauhaus-theme 1.0.1

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.
@@ -0,0 +1,6 @@
1
+ #!/bin/sh
2
+ # Print the 16 ANSI colors as text and as backgrounds.
3
+ for style in 3 9; do
4
+ for n in 0 1 2 3 4 5 6 7; do printf "\033[${style}${n}m %-8s\033[0m" "$style$n"; done; echo
5
+ done
6
+ for n in 0 1 2 3 4 5 6 7; do printf "\033[4${n}m %-8s\033[0m" "4$n"; done; echo
@@ -0,0 +1,15 @@
1
+ :root {
2
+ --red: #95171d;
3
+ }
4
+
5
+ .editor > #main:hover::before,
6
+ a[href^="https"] {
7
+ color: var(--red);
8
+ margin: 0 auto 1.5rem;
9
+ font-family: "Futura", sans-serif !important;
10
+ transition: opacity 200ms ease-in-out;
11
+ }
12
+
13
+ @media (prefers-color-scheme: dark) {
14
+ body { background: rgb(34 34 34 / 90%); }
15
+ }
@@ -0,0 +1,7 @@
1
+ --- a/colors.json
2
+ +++ b/colors.json
3
+ @@ -1,3 +1,3 @@
4
+ {
5
+ - "red": "#95171d",
6
+ + "red": "#e0615b",
7
+ "blue": "#30436a"
@@ -0,0 +1,19 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <title>Bauhaus</title>
6
+ <link rel="stylesheet" href="sample.css" />
7
+ </head>
8
+ <body class="editor">
9
+ <!-- Comment -->
10
+ <main id="main" data-theme="dark">
11
+ <h1>Form follows function</h1>
12
+ <a href="https://bauhaus.de">Link &amp; entity</a>
13
+ <button type="button" disabled>Submit</button>
14
+ </main>
15
+ <script type="module">
16
+ document.querySelector("#main").hidden = false;
17
+ </script>
18
+ </body>
19
+ </html>
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "bauhaus",
3
+ "version": 1,
4
+ "enabled": true,
5
+ "hues": {
6
+ "primary": {
7
+ "red": { "hex": "#95171d", "alpha": null }
8
+ }
9
+ },
10
+ "list": [1, 2.5, "three"]
11
+ }
@@ -0,0 +1,19 @@
1
+ # Heading 1
2
+
3
+ ## Heading 2
4
+
5
+ Paragraph with **bold**, _italic_, ~~strike~~, `inline code` and a [link](https://bauhaus.de).
6
+
7
+ > Less is more.
8
+
9
+ - List item
10
+ - [ ] Task
11
+
12
+ > [!WARNING]
13
+ > Alert block.
14
+
15
+ ```js
16
+ const form = (fn) => fn;
17
+ ```
18
+
19
+ ---
@@ -0,0 +1,19 @@
1
+ from dataclasses import dataclass
2
+ import re
3
+
4
+ HEX = re.compile(r"^#([0-9a-f]{6})$", re.I)
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class Color:
9
+ """A color with a hex value."""
10
+
11
+ name: str
12
+ hex: str = "#000000"
13
+
14
+ def luminance(self, gamma: float = 2.4) -> float:
15
+ r, g, b = (int(self.hex[i : i + 2], 16) / 255 for i in (1, 3, 5))
16
+ return 0.2126 * r**gamma + 0.7152 * g + 0.0722 * b if self else None
17
+
18
+
19
+ print(f"{Color('red', '#95171d').luminance():.3f}", True, None)
@@ -0,0 +1,35 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import type { Palette } from "./types";
3
+
4
+ /**
5
+ * Load a palette from disk.
6
+ * @param path Relative path.
7
+ */
8
+ @sealed
9
+ export class PaletteLoader<T extends Palette> implements Loader {
10
+ static readonly DEFAULT_PATH = "./colors.json";
11
+ #cache = new Map<string, T>();
12
+
13
+ constructor(private readonly root: string) {}
14
+
15
+ async load(path = PaletteLoader.DEFAULT_PATH): Promise<T | undefined> {
16
+ if (this.#cache.has(path)) return this.#cache.get(path);
17
+ try {
18
+ const json = JSON.parse(await readFile(`${this.root}/${path}`, "utf8"));
19
+ const ratio = (json.contrast ?? 4.5) * 2 + 0x1f;
20
+ for (const [key, value] of Object.entries(json)) {
21
+ if (!/^#[0-9a-f]{6}$/i.test(value as string)) throw new Error(`Invalid ${key}`);
22
+ }
23
+ this.#cache.set(path, json);
24
+ return ratio > 0 ? json : null;
25
+ } catch (error) {
26
+ console.error(error);
27
+ }
28
+ }
29
+ }
30
+
31
+ enum Mode { Dark = "dark", Light = "light" }
32
+ const loader = new PaletteLoader(process.cwd());
33
+ export default loader;
34
+ // @ts-expect-error deprecated API
35
+ loader.legacyLoad();
@@ -0,0 +1,158 @@
1
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
+
3
+ import reference from "../reference/theme-colors.json" with { type: "json" };
4
+ import { accents } from "../src/accents.js";
5
+ import { contrast } from "../src/color.js";
6
+ import { palettes } from "../src/palettes.js";
7
+ import { roles, semanticTokenColors, tokenColors } from "../src/syntax.js";
8
+ import { workbench } from "../src/workbench.js";
9
+
10
+ const root = new URL("../", import.meta.url);
11
+ const writeJson = (path, data) =>
12
+ writeFile(new URL(path, root), `${JSON.stringify(data, null, 2)}\n`);
13
+ const capitalize = (s) => s[0].toUpperCase() + s.slice(1);
14
+
15
+ const AA = 4.5;
16
+ const grade = (ratio) =>
17
+ ratio >= 7 ? "AAA" : ratio >= AA ? "AA" : ratio >= 3 ? "AA large" : "fail";
18
+
19
+ // Validation
20
+ const known = new Set(reference.keys);
21
+ const translucent = new Set(reference.translucent);
22
+ function validate(name, colors) {
23
+ const errors = Object.entries(colors).flatMap(([key, value]) => [
24
+ ...(known.has(key) ? [] : [`unknown key ${key}`]),
25
+ ...(translucent.has(key) &&
26
+ !/^#[0-9a-f]{6}(?![fF]{2})[0-9a-f]{2}$/i.test(value)
27
+ ? [`${key} must not be opaque (${value})`]
28
+ : []),
29
+ ]);
30
+ if (errors.length) throw new Error(`${name}:\n ${errors.join("\n ")}`);
31
+ }
32
+
33
+ // Themes
34
+ await rm(new URL("themes", root), { recursive: true, force: true });
35
+ await mkdir(new URL("themes", root));
36
+
37
+ // Other accent styles are documented as color customizations over the default one.
38
+ const DEFAULT_ACCENT = "tricolor";
39
+
40
+ const themes = [];
41
+ const customizations = {};
42
+ for (const palette of palettes) {
43
+ const label = `Bauhaus ${capitalize(palette.type)}`;
44
+ const path = `themes/bauhaus-${palette.id}.json`;
45
+ const colors = workbench(palette, accents[DEFAULT_ACCENT].accent(palette));
46
+ validate(label, colors);
47
+ await writeJson(path, {
48
+ $schema: "vscode://schemas/color-theme",
49
+ name: label,
50
+ type: palette.type,
51
+ semanticHighlighting: true,
52
+ colors,
53
+ tokenColors: tokenColors(palette),
54
+ semanticTokenColors: semanticTokenColors(palette),
55
+ });
56
+ themes.push({
57
+ label,
58
+ uiTheme: palette.type === "dark" ? "vs-dark" : "vs",
59
+ path: `./${path}`,
60
+ });
61
+
62
+ for (const [id, { accent }] of Object.entries(accents)) {
63
+ if (id === DEFAULT_ACCENT) continue;
64
+ const variant = workbench(palette, accent(palette));
65
+ (customizations[id] ??= {})[`[${label}]`] = Object.fromEntries(
66
+ Object.entries(variant).filter(([key, value]) => colors[key] !== value),
67
+ );
68
+ }
69
+ }
70
+
71
+ const pkg = JSON.parse(await readFile(new URL("package.json", root)));
72
+ pkg.contributes.themes = themes;
73
+ await writeJson("package.json", pkg);
74
+
75
+ // Palettes and contrast report
76
+ const report = palettes.map((palette) => {
77
+ const syntax = Object.entries(roles(palette)).map(([role, color]) => {
78
+ const ratio = contrast(color, palette.bg.editor);
79
+ return { role, color, ratio: +ratio.toFixed(2), grade: grade(ratio) };
80
+ });
81
+ const fills = Object.entries(palette.fill).map(([hue, color]) => {
82
+ const ratio = contrast(color, palette.onFill[hue]);
83
+ return {
84
+ hue,
85
+ color,
86
+ text: palette.onFill[hue],
87
+ ratio: +ratio.toFixed(2),
88
+ grade: grade(ratio),
89
+ };
90
+ });
91
+ return { ...palette, syntax, fills };
92
+ });
93
+ await writeJson("palettes.json", report);
94
+
95
+ const markdown = report
96
+ .map(
97
+ ({ type, bg, fg, ink, bright, syntax, fills }) => `### ${capitalize(type)}
98
+
99
+ | Surface | Color |
100
+ | --- | --- |
101
+ ${Object.entries({ ...bg, "fg.default": fg.default, "fg.muted": fg.muted })
102
+ .map(([key, color]) => `| ${key} | \`${color}\` |`)
103
+ .join("\n")}
104
+
105
+ | Hue | Ink | Bright (terminal) |
106
+ | --- | --- | --- |
107
+ ${Object.keys(ink)
108
+ .map((hue) => `| ${hue} | \`${ink[hue]}\` | \`${bright[hue]}\` |`)
109
+ .join("\n")}
110
+
111
+ | Syntax role | Color | Contrast on editor | WCAG 2.0 |
112
+ | --- | --- | --- | --- |
113
+ ${syntax.map(({ role, color, ratio, grade }) => `| ${role} | \`${color}\` | ${ratio}:1 | ${grade} |`).join("\n")}
114
+
115
+ | Fill | Text | Contrast | WCAG 2.0 |
116
+ | --- | --- | --- | --- |
117
+ ${fills.map(({ hue, color, text, ratio, grade }) => `| ${hue} \`${color}\` | \`${text}\` | ${ratio}:1 | ${grade} |`).join("\n")}
118
+ `,
119
+ )
120
+ .join("\n");
121
+
122
+ const accentsMarkdown = Object.entries(customizations)
123
+ .map(
124
+ ([id, colors]) => `### ${accents[id].name}
125
+
126
+ \`\`\`json
127
+ ${JSON.stringify({ "workbench.colorCustomizations": colors }, null, 2)}
128
+ \`\`\`
129
+ `,
130
+ )
131
+ .join("\n");
132
+
133
+ const replaceSection = (text, name, content) =>
134
+ text.replace(
135
+ new RegExp(`(<!-- ${name}:start -->\n)[\\s\\S]*(<!-- ${name}:end -->)`),
136
+ `$1\n${content}\n$2`,
137
+ );
138
+
139
+ const readmeUrl = new URL("README.md", root);
140
+ const readme = await readFile(readmeUrl, "utf8");
141
+ await writeFile(
142
+ readmeUrl,
143
+ replaceSection(
144
+ replaceSection(readme, "palettes", markdown),
145
+ "accents",
146
+ accentsMarkdown,
147
+ ),
148
+ );
149
+
150
+ for (const { type, syntax } of report) {
151
+ const failing = syntax.filter(({ ratio }) => ratio < AA);
152
+ console.log(
153
+ `${capitalize(type)}: ${failing.length ? `below AA → ${failing.map(({ role, ratio }) => `${role} ${ratio}`).join(", ")}` : "all syntax roles ≥ AA"}`,
154
+ );
155
+ }
156
+ console.log(
157
+ `${themes.length} themes written, validated against theme color reference approved ${reference.approved}`,
158
+ );
@@ -0,0 +1,13 @@
1
+ import { readFile, writeFile } from "node:fs/promises";
2
+
3
+ import { Resvg } from "@resvg/resvg-js";
4
+
5
+ const SIZE = 256;
6
+
7
+ const root = new URL("./", import.meta.url);
8
+ const svg = await readFile(new URL("icon.svg", root));
9
+ const png = new Resvg(svg, { fitTo: { mode: "width", value: SIZE } })
10
+ .render()
11
+ .asPng();
12
+ await writeFile(new URL("../icon.png", root), png);
13
+ console.log(`icon.png written (${SIZE}px)`);
@@ -0,0 +1,24 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128">
2
+ <!-- Dark theme window: paper outline keeps it visible on dark backgrounds -->
3
+ <g transform="translate(8 8)">
4
+ <rect width="80" height="80" rx="10" fill="#2b2119" stroke="#f2f2f2" stroke-width="2"/>
5
+ <circle cx="13" cy="13" r="5" fill="#95171d"/>
6
+ <circle cx="27" cy="13" r="5" fill="#cb9820"/>
7
+ <circle cx="41" cy="13" r="5" fill="#30436a"/>
8
+ <rect x="8" y="30" width="50" height="10" fill="#30436a"/>
9
+ <rect x="18" y="46" width="40" height="10" fill="#95171d"/>
10
+ <rect x="18" y="62" width="24" height="10" fill="#cb9820"/>
11
+ </g>
12
+ <!-- Light theme window -->
13
+ <g transform="translate(40 40)">
14
+ <rect width="80" height="80" rx="10" fill="#f2f2f2" stroke="#2b2119" stroke-width="2"/>
15
+ <circle cx="13" cy="13" r="5" fill="#95171d"/>
16
+ <circle cx="27" cy="13" r="5" fill="#cb9820"/>
17
+ <circle cx="41" cy="13" r="5" fill="#30436a"/>
18
+ <!-- Fold marker opening an indented block -->
19
+ <polygon points="8,30 22,30 15,40" fill="#cb9820"/>
20
+ <rect x="28" y="30" width="44" height="10" fill="#30436a"/>
21
+ <rect x="38" y="46" width="34" height="10" fill="#95171d"/>
22
+ <rect x="38" y="62" width="20" height="10" fill="#cb9820"/>
23
+ </g>
24
+ </svg>
@@ -0,0 +1,26 @@
1
+ // Snapshot the official theme color reference so the build can validate keys offline.
2
+ import { writeFile } from "node:fs/promises";
3
+
4
+ const SOURCE =
5
+ "https://raw.githubusercontent.com/microsoft/vscode-docs/main/api/references/theme-color.md";
6
+
7
+ const markdown = await (await fetch(SOURCE)).text();
8
+ const entries = [...markdown.matchAll(/^- `([A-Za-z0-9.]+)`:(.*)$/gm)];
9
+
10
+ const reference = {
11
+ source: SOURCE,
12
+ approved: markdown.match(/^DateApproved: (.+)$/m)?.[1],
13
+ keys: [...new Set(entries.map(([, key]) => key))].sort(),
14
+ translucent: entries
15
+ .filter(([, , description]) => /not be opaque/i.test(description))
16
+ .map(([, key]) => key)
17
+ .sort(),
18
+ };
19
+
20
+ await writeFile(
21
+ new URL("../reference/theme-colors.json", import.meta.url),
22
+ `${JSON.stringify(reference, null, 2)}\n`,
23
+ );
24
+ console.log(
25
+ `${reference.keys.length} keys (${reference.translucent.length} translucent), approved ${reference.approved}`,
26
+ );
package/src/accents.js ADDED
@@ -0,0 +1,86 @@
1
+ /** @typedef {import("./palettes.js").Palette} Palette */
2
+
3
+ /**
4
+ * @typedef {object} Pair
5
+ * @property {string} background
6
+ * @property {string} foreground
7
+ */
8
+
9
+ /**
10
+ * @typedef {object} ActiveItems
11
+ * @property {string} tab Active editor tab line.
12
+ * @property {Pair} activity Active activity bar item block.
13
+ * @property {Pair} panel Active panel tab block.
14
+ */
15
+
16
+ /**
17
+ * @typedef {object} Accent
18
+ * @property {string} focus Focus border, cursor, progress, links.
19
+ * @property {ActiveItems} active
20
+ * @property {Pair} statusBar
21
+ * @property {Pair} debugging
22
+ * @property {Pair} noFolder
23
+ * @property {Pair} remote
24
+ * @property {Pair} badge
25
+ * @property {Pair} button
26
+ */
27
+
28
+ const fill = (p, hue) => ({
29
+ background: p.fill[hue],
30
+ foreground: p.onFill[hue],
31
+ });
32
+ const chrome = (p) => ({ background: p.bg.chrome, foreground: p.fg.muted });
33
+ const active = (p, activity, panel = activity) => ({
34
+ tab: p.ink.red,
35
+ activity: fill(p, activity),
36
+ panel: fill(p, panel),
37
+ });
38
+
39
+ /**
40
+ * @typedef {object} AccentStyle
41
+ * @property {string} name
42
+ * @property {function(Palette): Accent} accent
43
+ */
44
+
45
+ /** @type {Object<string, AccentStyle>} */
46
+ export const accents = {
47
+ blocks: {
48
+ name: "Blocks",
49
+ accent: (p) => ({
50
+ focus: p.ink.red,
51
+ active: active(p, "red"),
52
+ statusBar: fill(p, "red"),
53
+ debugging: fill(p, "yellow"),
54
+ noFolder: fill(p, "blue"),
55
+ remote: fill(p, "blue"),
56
+ badge: fill(p, "red"),
57
+ button: fill(p, "red"),
58
+ }),
59
+ },
60
+ restrained: {
61
+ name: "Restrained",
62
+ accent: (p) => ({
63
+ focus: p.ink.red,
64
+ active: active(p, "red"),
65
+ statusBar: chrome(p),
66
+ debugging: fill(p, "red"),
67
+ noFolder: chrome(p),
68
+ remote: fill(p, "red"),
69
+ badge: fill(p, "red"),
70
+ button: fill(p, "red"),
71
+ }),
72
+ },
73
+ tricolor: {
74
+ name: "Tricolor",
75
+ accent: (p) => ({
76
+ focus: p.ink.red,
77
+ active: active(p, "yellow", "blue"),
78
+ statusBar: fill(p, "blue"),
79
+ debugging: fill(p, "red"),
80
+ noFolder: fill(p, "green"),
81
+ remote: fill(p, "yellow"),
82
+ badge: fill(p, "red"),
83
+ button: fill(p, "red"),
84
+ }),
85
+ },
86
+ };
package/src/color.js ADDED
@@ -0,0 +1,162 @@
1
+ import {
2
+ create,
3
+ fromHex,
4
+ fromOklab,
5
+ fromOklch as colorFromOklch,
6
+ toHex,
7
+ toLinear,
8
+ toOklab,
9
+ toOklch as colorToOklch,
10
+ } from "pex-color";
11
+
12
+ const parse = (hex) => fromHex(create(), hex);
13
+ const format = (color) => toHex(color).toLowerCase();
14
+ const lerp = (a, b, t) => a + (b - a) * t;
15
+
16
+ /**
17
+ * WCAG 2.0 relative luminance.
18
+ *
19
+ * @param {string} hex
20
+ * @returns {number}
21
+ */
22
+ export function luminance(hex) {
23
+ // WCAG 2.0's 0.03928 linearization threshold and sRGB's 0.04045 give the same result on 8-bit channels.
24
+ const [r, g, b] = toLinear(parse(hex));
25
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
26
+ }
27
+
28
+ /**
29
+ * WCAG 2.0 contrast ratio between two opaque colors.
30
+ *
31
+ * @param {string} a
32
+ * @param {string} b
33
+ * @returns {number} From 1 to 21.
34
+ */
35
+ export function contrast(a, b) {
36
+ const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
37
+ return (hi + 0.05) / (lo + 0.05);
38
+ }
39
+
40
+ /**
41
+ * Convert to OKLCH.
42
+ *
43
+ * @param {string} hex
44
+ * @returns {[number, number, number]} Lightness, chroma, hue in turns.
45
+ */
46
+ export const toOklch = (hex) => colorToOklch(parse(hex)).slice(0, 3);
47
+
48
+ // Tolerance applies to linear values: gamma encoding would make it ~13x stricter near 0.
49
+ const inGamut = (color) =>
50
+ toLinear(color)
51
+ .slice(0, 3)
52
+ .every((v) => v >= -1e-4 && v <= 1 + 1e-4);
53
+
54
+ /**
55
+ * Convert from OKLCH, reducing chroma until the color fits in sRGB.
56
+ *
57
+ * @param {[number, number, number]} lch Lightness, chroma, hue in turns.
58
+ * @returns {string}
59
+ */
60
+ export function fromOklch([L, C, H]) {
61
+ const color = create();
62
+ while (!inGamut(colorFromOklch(color, L, C, H))) C = Math.max(0, C - 0.002);
63
+ return format(color);
64
+ }
65
+
66
+ /**
67
+ * Interpolate two colors in OKLab.
68
+ *
69
+ * @param {string} a
70
+ * @param {string} b
71
+ * @param {number} [t=0.5]
72
+ * @returns {string}
73
+ */
74
+ export function mix(a, b, t = 0.5) {
75
+ const [La, Aa, Ba] = toOklab(parse(a));
76
+ const [Lb, Ab, Bb] = toOklab(parse(b));
77
+ return format(
78
+ fromOklab(create(), lerp(La, Lb, t), lerp(Aa, Ab, t), lerp(Ba, Bb, t)),
79
+ );
80
+ }
81
+
82
+ /**
83
+ * Interpolate two colors in OKLCH along the shortest hue arc. Unlike OKLab
84
+ * mixing, distant hues keep their chroma (blue + green gives cyan, not grey).
85
+ *
86
+ * @param {string} a
87
+ * @param {string} b
88
+ * @param {number} [t=0.5]
89
+ * @returns {string}
90
+ */
91
+ export function mixHue(a, b, t = 0.5) {
92
+ const [La, Ca, Ha] = toOklch(a);
93
+ const [Lb, Cb, Hb] = toOklch(b);
94
+ const dH = ((((Hb - Ha) % 1) + 1.5) % 1) - 0.5;
95
+ return fromOklch([lerp(La, Lb, t), lerp(Ca, Cb, t), Ha + dH * t]);
96
+ }
97
+
98
+ /**
99
+ * Shift OKLCH lightness (hue kept) until the color reaches a contrast target
100
+ * against every background.
101
+ *
102
+ * @param {string} hex
103
+ * @param {string | string[]} backgrounds All on the same side of the lightness
104
+ * scale.
105
+ * @param {number} target WCAG ratio: 3 (large text, UI), 4.5 (AA), 7 (AAA).
106
+ * @param {object} [options]
107
+ * @param {boolean} [options.exact=false] Closest passing color to the
108
+ * backgrounds, even if the source already passes. Otherwise the source is
109
+ * returned unchanged when passing.
110
+ * @param {number} [options.chroma=1] Chroma scale, clamped to the sRGB gamut.
111
+ * @returns {string}
112
+ */
113
+ export function fit(
114
+ hex,
115
+ backgrounds,
116
+ target,
117
+ { exact = false, chroma = 1 } = {},
118
+ ) {
119
+ const bgs = [backgrounds].flat();
120
+ const [L0, C0, H] = toOklch(hex);
121
+ const C = C0 * chroma;
122
+ const direction = Math.max(...bgs.map(luminance)) < 0.18 ? 1 : -1;
123
+ const bgLightness = bgs.map((bg) => toOklch(bg)[0]);
124
+ const L = exact ? (direction > 0 ? Math.min : Math.max)(...bgLightness) : L0;
125
+ for (let step = 0; step <= 1000; step++) {
126
+ const candidate = fromOklch([
127
+ Math.min(1, Math.max(0, L + direction * step * 0.001)),
128
+ C,
129
+ H,
130
+ ]);
131
+ if (Math.min(...bgs.map((bg) => contrast(candidate, bg))) >= target)
132
+ return candidate;
133
+ }
134
+ throw new Error(
135
+ `Cannot reach ${target}:1 for ${hex} against ${bgs.join(", ")}`,
136
+ );
137
+ }
138
+
139
+ /**
140
+ * Pick the candidate with the highest contrast against a background.
141
+ *
142
+ * @param {string} bg
143
+ * @param {string[]} candidates
144
+ * @returns {string}
145
+ */
146
+ export const mostContrasted = (bg, candidates) =>
147
+ candidates.reduce((best, c) =>
148
+ contrast(c, bg) > contrast(best, bg) ? c : best,
149
+ );
150
+
151
+ /**
152
+ * Set the alpha channel.
153
+ *
154
+ * @param {string} hex
155
+ * @param {number} opacity From 0 to 1.
156
+ * @returns {string} #RRGGBBAA, or #RRGGBB when opaque.
157
+ */
158
+ export function alpha(hex, opacity) {
159
+ const color = parse(hex);
160
+ color[3] = opacity;
161
+ return format(color);
162
+ }