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.
- package/.vscode/launch.json +11 -0
- package/.vscodeignore +11 -0
- package/CHANGELOG.md +7 -0
- package/LICENSE.md +19 -0
- package/README.md +239 -0
- package/colors.json +11 -0
- package/icon.png +0 -0
- package/package.json +79 -0
- package/palettes.json +272 -0
- package/reference/theme-colors.json +1011 -0
- package/samples/ansi.sh +6 -0
- package/samples/sample.css +15 -0
- package/samples/sample.diff +7 -0
- package/samples/sample.html +19 -0
- package/samples/sample.json +11 -0
- package/samples/sample.md +19 -0
- package/samples/sample.py +19 -0
- package/samples/sample.ts +35 -0
- package/scripts/build.js +158 -0
- package/scripts/icon.js +13 -0
- package/scripts/icon.svg +24 -0
- package/scripts/reference.js +26 -0
- package/src/accents.js +86 -0
- package/src/color.js +162 -0
- package/src/palettes.js +146 -0
- package/src/syntax.js +281 -0
- package/src/workbench.js +613 -0
- package/themes/bauhaus-dark.json +975 -0
- package/themes/bauhaus-light.json +975 -0
- package/types/accents.d.ts +45 -0
- package/types/color.d.ts +70 -0
- package/types/palettes.d.ts +53 -0
- package/types/syntax.d.ts +57 -0
- package/types/workbench.d.ts +13 -0
package/samples/ansi.sh
ADDED
|
@@ -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,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 & 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,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();
|
package/scripts/build.js
ADDED
|
@@ -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
|
+
);
|
package/scripts/icon.js
ADDED
|
@@ -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)`);
|
package/scripts/icon.svg
ADDED
|
@@ -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
|
+
}
|