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.
Remove the avatars and read people by name again
28b19c7433fa6f6fe1ce826c443870385d856eb8 · Keenan Nemetz · 7d ago · parent 0fbeca85
Remove the avatars and read people by name again The marks were harder to read than the names they replaced, so they are gone: the account menu is back to the user icon, and the issue and change tables, the profile, the directory and the contributor list all say the account name. This takes out everything that existed to draw a mark: the DiceBear bundle and its licence file, the entry source that built it, the Landscape style, avatar.go and its tests, the avatar template and its three helper functions, the avatar palette in everforest.css and the illustration rules in vole.css, and the app.js block that rendered and re-rendered them on a theme change. The tables keep the layout from the previous change — an author column and an assignee column — because that part reads better with names too. The assignee cells are the @name chips that already linked to the filtered list. `vole workflow run verify` passes, and the licence inventory is back to one entry fewer.
scripts/avatars-entry.js −189
| @@ -1,189 +0,0 @@ | |||
| 1 | /*! Vole avatars: a landscape drawn by DiceBear core 9.3.1. | ||
| 2 | * | ||
| 3 | * DiceBear is MIT (see server/web/static/dicebear.LICENSE); the Landscape style | ||
| 4 | * below is part of Vole and carries the same licence as the rest of the program | ||
| 5 | * (Apache-2.0). | ||
| 6 | * | ||
| 7 | * Built with esbuild. To rebuild after changing the palette or the versions: | ||
| 8 | * | ||
| 9 | * mkdir -p /tmp/dicebear && cd /tmp/dicebear && npm init -y | ||
| 10 | * npm i @dicebear/core@9.3.1 esbuild@0.25.9 | ||
| 11 | * npx esbuild /path/to/scripts/avatars-entry.js \ | ||
| 12 | * --bundle --minify --format=iife \ | ||
| 13 | * --outfile=/path/to/server/web/static/dicebear-avatars.min.js | ||
| 14 | * | ||
| 15 | * Then run `vole workflow run verify` so the licence inventory is checked. */ | ||
| 16 | import { createAvatar } from "@dicebear/core"; | ||
| 17 | |||
| 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. | ||
| 20 | const depth = [0.4, 0.56, 0.68]; | ||
| 21 | |||
| 22 | const shade = (hex, step, ink) => { | ||
| 23 | const a = parseInt(hex, 16); | ||
| 24 | const b = parseInt(ink, 16); | ||
| 25 | const channel = (shift) => { | ||
| 26 | const va = (a >> shift) & 255; | ||
| 27 | const vb = (b >> shift) & 255; | ||
| 28 | return Math.round(va + (vb - va) * step).toString(16).padStart(2, "0"); | ||
| 29 | }; | ||
| 30 | return channel(16) + channel(8) + channel(0); | ||
| 31 | }; | ||
| 32 | |||
| 33 | // DiceBear ships no landscape style, so this one is Vole's. It uses only the | ||
| 34 | // engine's contract — a seeded prng, the declared options, and a body for the | ||
| 35 | // 100×100 box — so seeding, the option handling and the viewBox are still | ||
| 36 | // DiceBear's. | ||
| 37 | const landscape = { | ||
| 38 | meta: { | ||
| 39 | title: "Landscape", | ||
| 40 | source: "https://github.com/rodent-software/vole", | ||
| 41 | creator: "Vole", | ||
| 42 | license: { name: "Apache-2.0", url: "https://www.apache.org/licenses/LICENSE-2.0" }, | ||
| 43 | }, | ||
| 44 | schema: { | ||
| 45 | type: "object", | ||
| 46 | properties: { | ||
| 47 | skyColor: { type: "array", items: { type: "string" }, default: ["6b7561"] }, | ||
| 48 | palette: { type: "array", items: { type: "string" }, default: [] }, | ||
| 49 | ink: { type: "string", default: "272e33" }, | ||
| 50 | discColor: { type: "array", items: { type: "string" }, default: ["f0e6d2"] }, | ||
| 51 | hillCount: { type: "array", items: { type: "integer", minimum: 2, maximum: 3 }, default: [2, 3] }, | ||
| 52 | }, | ||
| 53 | }, | ||
| 54 | create({ prng, options }) { | ||
| 55 | const sky = prng.pick(options.skyColor ?? ["6b7561"], "6b7561"); | ||
| 56 | const ink = options.ink ?? "272e33"; | ||
| 57 | const discs = options.discColor ?? ["f0e6d2"]; | ||
| 58 | const hills = prng.pick(options.hillCount ?? [2, 3], 2); | ||
| 59 | const body = [`<rect width="100" height="100" fill="#${sky}"/>`]; | ||
| 60 | |||
| 61 | // Hills take hues from the rest of the palette, never the sky's own, so every | ||
| 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. | ||
| 64 | const ground = (options.palette ?? []).filter((c) => c && c !== sky); | ||
| 65 | for (let i = ground.length - 1; i > 0; i--) { | ||
| 66 | const j = prng.integer(0, i); | ||
| 67 | [ground[i], ground[j]] = [ground[j], ground[i]]; | ||
| 68 | } | ||
| 69 | |||
| 70 | // The sun or moon sits above the highest ridge: the tallest a hill may rise | ||
| 71 | // is 66, so the highest a ridge can reach is y=34 and the disc never sets | ||
| 72 | // behind it. | ||
| 73 | body.push( | ||
| 74 | `<circle cx="${prng.integer(16, 84)}" cy="${prng.integer(18, 32)}" r="${prng.integer(6, 10)}" fill="#${prng.pick(discs)}"/>`, | ||
| 75 | ); | ||
| 76 | |||
| 77 | // Each hill is a circle whose centre is below the box, so only the ridge | ||
| 78 | // shows. Back to front, each one is a palette hue shaded toward the ink. | ||
| 79 | for (let i = 0; i < hills; i++) { | ||
| 80 | const r = prng.integer(70, 130); | ||
| 81 | const rise = prng.integer(40, 66) - i * prng.integer(5, 10); | ||
| 82 | const hue = ground.length ? ground[i % ground.length] : sky; | ||
| 83 | body.push( | ||
| 84 | `<circle cx="${prng.integer(-20, 120)}" cy="${100 + r - rise}" r="${r}" fill="#${shade(hue, depth[i], ink)}"/>`, | ||
| 85 | ); | ||
| 86 | } | ||
| 87 | return { attributes: { viewBox: "0 0 100 100" }, body: body.join("") }; | ||
| 88 | }, | ||
| 89 | }; | ||
| 90 | |||
| 91 | // getComputedStyle serializes a resolved colour two ways: rgb()/rgba() for a | ||
| 92 | // legacy sRGB value, and color(srgb r g b) with components in 0..1 when the value | ||
| 93 | // came through color-mix(). Reading "the first three numbers" therefore splits | ||
| 94 | // 0.571765 into 0 and 571765 and builds a six-digit-looking string that is not a | ||
| 95 | // colour; the browser then paints the sky black. Parse both forms, and clamp, | ||
| 96 | // because color() components are floats. | ||
| 97 | const toHex = (value) => { | ||
| 98 | const text = value.trim(); | ||
| 99 | const srgb = /^color\(srgb\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)/.exec(text); | ||
| 100 | if (srgb) { | ||
| 101 | return [1, 2, 3] | ||
| 102 | .map((i) => Math.round(Math.min(1, Math.max(0, parseFloat(srgb[i]))) * 255)) | ||
| 103 | .map((n) => n.toString(16).padStart(2, "0")) | ||
| 104 | .join(""); | ||
| 105 | } | ||
| 106 | const legacy = /^rgba?\(([^)]+)\)/.exec(text); | ||
| 107 | if (legacy) { | ||
| 108 | const parts = legacy[1].split(/[\s,/]+/).filter(Boolean).map(Number); | ||
| 109 | if (parts.length >= 3) { | ||
| 110 | return parts | ||
| 111 | .slice(0, 3) | ||
| 112 | .map((n) => Math.round(Math.min(255, Math.max(0, n)))) | ||
| 113 | .map((n) => n.toString(16).padStart(2, "0")) | ||
| 114 | .join(""); | ||
| 115 | } | ||
| 116 | } | ||
| 117 | return ""; | ||
| 118 | }; | ||
| 119 | |||
| 120 | // A custom property can hold a color-mix(), and getComputedStyle reads that back | ||
| 121 | // as text rather than as a colour. One hidden element resolves any of them to the | ||
| 122 | // colour the browser actually paints, so the palette follows the theme exactly. | ||
| 123 | let probe; | ||
| 124 | const resolve = (name) => { | ||
| 125 | if (!probe) { | ||
| 126 | probe = document.createElement("span"); | ||
| 127 | probe.hidden = true; | ||
| 128 | document.body.appendChild(probe); | ||
| 129 | } | ||
| 130 | probe.style.color = `var(${name})`; | ||
| 131 | return toHex(getComputedStyle(probe).color); | ||
| 132 | }; | ||
| 133 | |||
| 134 | // The palette follows the active Everforest variant, so it is resolved once per | ||
| 135 | // theme and read again when the theme changes (app.js redraws the avatars). | ||
| 136 | let cached = { theme: null, palette: [], ink: "", discs: [] }; | ||
| 137 | const themePalette = () => { | ||
| 138 | const theme = document.documentElement.getAttribute("data-theme") || ""; | ||
| 139 | if (cached.theme === theme) return cached; | ||
| 140 | const palette = []; | ||
| 141 | for (let i = 1; i <= 7; i++) { | ||
| 142 | const colour = resolve(`--av-c${i}`); | ||
| 143 | if (colour) palette.push(colour); | ||
| 144 | } | ||
| 145 | cached = { | ||
| 146 | theme, | ||
| 147 | palette, | ||
| 148 | ink: resolve("--av-ink") || "272e33", | ||
| 149 | discs: [resolve("--av-disc-1"), resolve("--av-disc-2"), resolve("--av-disc-3")].filter(Boolean), | ||
| 150 | }; | ||
| 151 | return cached; | ||
| 152 | }; | ||
| 153 | |||
| 154 | // The sky comes from the element, not from this file: vole.css and everforest.css | ||
| 155 | // own the seven grounds, and reading the computed colour keeps the initial the | ||
| 156 | // server renders and the finished landscape on the same sky. | ||
| 157 | const ground = (el) => toHex(getComputedStyle(el).backgroundColor); | ||
| 158 | |||
| 159 | const render = (el) => { | ||
| 160 | if (el.querySelector("svg")) return; | ||
| 161 | const seed = el.getAttribute("data-avatar"); | ||
| 162 | if (!seed) return; | ||
| 163 | const sky = ground(el); | ||
| 164 | const theme = themePalette(); | ||
| 165 | const svg = createAvatar(landscape, { | ||
| 166 | seed, | ||
| 167 | size: 40, | ||
| 168 | // The rounded square is the element's border-radius. DiceBear's clip mask | ||
| 169 | // would be a circle, and it is also the one thing it puts an id on. | ||
| 170 | clip: false, | ||
| 171 | backgroundColor: [], | ||
| 172 | skyColor: sky ? [sky] : undefined, | ||
| 173 | palette: theme.palette, | ||
| 174 | ink: theme.ink, | ||
| 175 | discColor: theme.discs.length ? theme.discs : undefined, | ||
| 176 | hillCount: [2, 3], | ||
| 177 | }).toString(); | ||
| 178 | // The RDF block names the style and its licence. It is identical for every | ||
| 179 | // avatar, so repeating it fifty times on a directory page is weight without | ||
| 180 | // information; the licence is in NOTICE. | ||
| 181 | el.innerHTML = svg.replace(/<metadata[\s\S]*?<\/metadata>/g, ""); | ||
| 182 | }; | ||
| 183 | |||
| 184 | window.VoleAvatars = { | ||
| 185 | render, | ||
| 186 | renderAll(root) { | ||
| 187 | root.querySelectorAll("[data-avatar]").forEach(render); | ||
| 188 | }, | ||
| 189 | }; | ||