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 | ||