Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

Mix the avatar palette from the active theme

214145e539d0f9f6e0420375e3c011994eed9bde · Keenan Nemetz · 7d ago · parent df894c4f

Mix the avatar palette from the active theme

The seven grounds were fixed hexes, so a mark looked the same on every
Everforest variant and did not really belong to any of them. They are now
mixed from the active variant's own accents toward its panel colour, in
everforest.css, and the tone class just picks one. The disc is the
theme's moon or sun colours and the ink the hills shade toward is the
theme's darkest token.

Two shares of accent, because the panels are opposite: a dark theme's
panel is nearly black, so its grounds keep 80% of the accent to stay a
colour, while a light panel is nearly white and keeps 48% so the
landscape is pastel rather than a highlighter.

A custom property holding color-mix() reads back as text, so the bundle
resolves the palette through one hidden probe element and caches it per
theme; app.js already redraws the avatars when data-theme changes, so a
theme switch re-reads it. The fallback letter now takes --av-fg, which is
the theme's foreground, so it stays readable on a light theme's pastel
grounds.

scripts/avatars-entry.js +55−46

@@ -15,24 +15,11 @@
15 15 * Then run `vole workflow run verify` so the licence inventory is checked. */
16 16 import { createAvatar } from "@dicebear/core";
17 17
18 // The disc in the sky, from the Everforest palette. In a dark theme it is the
19 // moon: pale cream, near-white and a cool grey. In a light theme it is the sun:
20 // cream, yellow and the warm orange of a low sunset.
21 const sunColors = ["f0e6d2", "dbbc7f", "e69875"];
22 const moonColors = ["f0e6d2", "e7e8ea", "cdd6d5"];
23
24 // Full daylight would wash out at 20px and read as a pastel dot; this is the
25 // last hour of it. The mark is drawn in the 100-unit box DiceBear gives a style,
26 // and the palette comes from vole.css: the sky is the account's tone, the hills
27 // take other tones from the same seven, and all of them are dark enough to read
28 // at 17px without an outline.
18 // Back to front, each hill is a palette hue shaded a step toward the theme's ink,
19 // so the depth reads without an outline.
29 20 const depth = [0.4, 0.56, 0.68];
30 21
31 // Hills are shaded toward the palette's darkest ground rather than toward black:
32 // it darkens them and ties different hues together, so a clay sky with river
33 // hills still reads as one landscape.
34 const ink = "272e33";
35 const shade = (hex, step) => {
22 const shade = (hex, step, ink) => {
36 23 const a = parseInt(hex, 16);
37 24 const b = parseInt(ink, 16);
38 25 const channel = (shift) => {
@@ -59,19 +46,21 @@ const landscape = {
59 46 properties: {
60 47 skyColor: { type: "array", items: { type: "string" }, default: ["6b7561"] },
61 48 palette: { type: "array", items: { type: "string" }, default: [] },
62 discColor: { type: "array", items: { type: "string" }, default: sunColors },
49 ink: { type: "string", default: "272e33" },
50 discColor: { type: "array", items: { type: "string" }, default: ["f0e6d2"] },
63 51 hillCount: { type: "array", items: { type: "integer", minimum: 2, maximum: 3 }, default: [2, 3] },
64 52 },
65 53 },
66 54 create({ prng, options }) {
67 55 const sky = prng.pick(options.skyColor ?? ["6b7561"], "6b7561");
68 const discs = options.discColor ?? sunColors;
56 const ink = options.ink ?? "272e33";
57 const discs = options.discColor ?? ["f0e6d2"];
69 58 const hills = prng.pick(options.hillCount ?? [2, 3], 2);
70 59 const body = [`<rect width="100" height="100" fill="#${sky}"/>`];
71 60
72 61 // Hills take hues from the rest of the palette, never the sky's own, so every
73 // mark has at least two colours. Shuffling keeps the order stable per seed
74 // while making the pairing depend on the name rather than on the sky.
62 // mark has at least two colours. Shuffling keeps the pairing stable per seed
63 // while making it depend on the name rather than on the sky alone.
75 64 const ground = (options.palette ?? []).filter((c) => c && c !== sky);
76 65 for (let i = ground.length - 1; i > 0; i--) {
77 66 const j = prng.integer(0, i);
@@ -86,25 +75,31 @@ const landscape = {
86 75 );
87 76
88 77 // Each hill is a circle whose centre is below the box, so only the ridge
89 // shows. Back to front, each one is a palette hue shaded a step darker, so
90 // the depth reads without an outline.
78 // shows. Back to front, each one is a palette hue shaded toward the ink.
91 79 for (let i = 0; i < hills; i++) {
92 80 const r = prng.integer(70, 130);
93 81 const rise = prng.integer(40, 66) - i * prng.integer(5, 10);
94 82 const hue = ground.length ? ground[i % ground.length] : sky;
95 83 body.push(
96 `<circle cx="${prng.integer(-20, 120)}" cy="${100 + r - rise}" r="${r}" fill="#${shade(hue, depth[i])}"/>`,
84 `<circle cx="${prng.integer(-20, 120)}" cy="${100 + r - rise}" r="${r}" fill="#${shade(hue, depth[i], ink)}"/>`,
97 85 );
98 86 }
99 87 return { attributes: { viewBox: "0 0 100 100" }, body: body.join("") };
100 88 },
101 89 };
102 90
103 // The ground comes from the element, not from this file: vole.css owns the seven
104 // Everforest grounds, and reading the computed colour keeps the initial the
105 // server renders and the finished landscape on the same sky.
106 const ground = (el) => {
107 const parts = (getComputedStyle(el).backgroundColor.match(/\d+/g) || []).map(Number);
91 // A custom property can hold a color-mix(), and getComputedStyle reads that back
92 // as text rather than as a colour. One hidden element resolves any of them to the
93 // rgb the browser actually paints, so the palette follows the theme exactly.
94 let probe;
95 const resolve = (name) => {
96 if (!probe) {
97 probe = document.createElement("span");
98 probe.hidden = true;
99 document.body.appendChild(probe);
100 }
101 probe.style.color = `var(${name})`;
102 const parts = (getComputedStyle(probe).color.match(/\d+/g) || []).map(Number);
108 103 if (parts.length < 3) return "";
109 104 return parts
110 105 .slice(0, 3)
@@ -112,29 +107,44 @@ const ground = (el) => {
112 107 .join("");
113 108 };
114 109
115 // The seven grounds, read once from the root where vole.css keeps them. Reading
116 // them keeps this file from carrying a second copy of the palette.
117 let palette;
118 const grounds = () => {
119 if (palette) return palette;
120 const css = getComputedStyle(document.documentElement);
121 palette = [];
110 // The palette follows the active Everforest variant, so it is resolved once per
111 // theme and read again when the theme changes (app.js redraws the avatars).
112 let cached = { theme: null, palette: [], ink: "", discs: [] };
113 const themePalette = () => {
114 const theme = document.documentElement.getAttribute("data-theme") || "";
115 if (cached.theme === theme) return cached;
116 const palette = [];
122 117 for (let i = 1; i <= 7; i++) {
123 const value = css.getPropertyValue(`--av-c${i}`).trim();
124 if (/^#[0-9a-f]{6}$/i.test(value)) palette.push(value.slice(1));
118 const colour = resolve(`--av-c${i}`);
119 if (colour) palette.push(colour);
125 120 }
126 return palette;
121 cached = {
122 theme,
123 palette,
124 ink: resolve("--av-ink") || "272e33",
125 discs: [resolve("--av-disc-1"), resolve("--av-disc-2"), resolve("--av-disc-3")].filter(Boolean),
126 };
127 return cached;
127 128 };
128 129
129 // The theme is on <html> and one of the six Everforest variants, because
130 // theme.js resolves "system" to a concrete one before the first paint.
131 const moonlit = () => (document.documentElement.getAttribute("data-theme") || "").includes("dark");
130 // The sky comes from the element, not from this file: vole.css and everforest.css
131 // own the seven grounds, and reading the computed colour keeps the initial the
132 // server renders and the finished landscape on the same sky.
133 const ground = (el) => {
134 const parts = (getComputedStyle(el).backgroundColor.match(/\d+/g) || []).map(Number);
135 if (parts.length < 3) return "";
136 return parts
137 .slice(0, 3)
138 .map((n) => n.toString(16).padStart(2, "0"))
139 .join("");
140 };
132 141
133 142 const render = (el) => {
134 143 if (el.querySelector("svg")) return;
135 144 const seed = el.getAttribute("data-avatar");
136 145 if (!seed) return;
137 146 const sky = ground(el);
147 const theme = themePalette();
138 148 const svg = createAvatar(landscape, {
139 149 seed,
140 150 size: 40,
@@ -143,15 +153,14 @@ const render = (el) => {
143 153 clip: false,
144 154 backgroundColor: [],
145 155 skyColor: sky ? [sky] : undefined,
146 palette: grounds(),
147 discColor: moonlit() ? moonColors : sunColors,
156 palette: theme.palette,
157 ink: theme.ink,
158 discColor: theme.discs.length ? theme.discs : undefined,
148 159 hillCount: [2, 3],
149 160 }).toString();
150 161 // The RDF block names the style and its licence. It is identical for every
151 162 // avatar, so repeating it fifty times on a directory page is weight without
152 // information; the licence is in NOTICE. No viewBox mask either: the square
153 // picture is rounded by the element's border-radius, so a mark carries no id
154 // that fifty neighbours would have to share.
163 // information; the licence is in NOTICE.
155 164 el.innerHTML = svg.replace(/<metadata[\s\S]*?<\/metadata>/g, "");
156 165 };
157 166