| 1 | diff --git a/.gitignore b/.gitignore |
| 2 | new file mode 100644 |
| 3 | index 0000000..f4bd627 |
| 4 | --- /dev/null |
| 5 | +++ b/.gitignore |
| 6 | @@ -0,0 +1,7 @@ |
| 7 | +node_modules/ |
| 8 | +coverage/ |
| 9 | +.DS_Store |
| 10 | +*.log |
| 11 | +.env |
| 12 | +.env.* |
| 13 | +!.env.example |
| 14 | diff --git a/LICENSE b/LICENSE |
| 15 | new file mode 100644 |
| 16 | index 0000000..d3bc0d2 |
| 17 | --- /dev/null |
| 18 | +++ b/LICENSE |
| 19 | @@ -0,0 +1,21 @@ |
| 20 | +MIT License |
| 21 | + |
| 22 | +Copyright (c) 2026 Hermes contributors |
| 23 | + |
| 24 | +Permission is hereby granted, free of charge, to any person obtaining a copy |
| 25 | +of this software and associated documentation files (the "Software"), to deal |
| 26 | +in the Software without restriction, including without limitation the rights |
| 27 | +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell |
| 28 | +copies of the Software, and to permit persons to whom the Software is |
| 29 | +furnished to do so, subject to the following conditions: |
| 30 | + |
| 31 | +The above copyright notice and this permission notice shall be included in all |
| 32 | +copies or substantial portions of the Software. |
| 33 | + |
| 34 | +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
| 35 | +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, |
| 36 | +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE |
| 37 | +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER |
| 38 | +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, |
| 39 | +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE |
| 40 | +SOFTWARE. |
| 41 | diff --git a/README.md b/README.md |
| 42 | new file mode 100644 |
| 43 | index 0000000..b18d29e |
| 44 | --- /dev/null |
| 45 | +++ b/README.md |
| 46 | @@ -0,0 +1,49 @@ |
| 47 | +# seedmark |
| 48 | + |
| 49 | +Dependency-free ESM library for small, deterministic SVG avatars. Give it opaque text; get a mask, strange creature, plant, orbital composition, landscape, geometric glyph, weave, or machine. No initials, fonts, external assets, or DOM required. |
| 50 | + |
| 51 | +```js |
| 52 | +import { avatar, recipe, createRng, FAMILIES, VERSION } from './src/index.js'; |
| 53 | + |
| 54 | +const svg = avatar('any opaque seed — or a full Nostr pubkey', { size: 32 }); |
| 55 | +const visual = recipe('any opaque seed — or a full Nostr pubkey'); |
| 56 | +// visual.family, visual.palette, visual.layout, visual.silhouette, |
| 57 | +// visual.features, visual.texture |
| 58 | +``` |
| 59 | + |
| 60 | +[Open the playground](https://4mzigytzhwbe0sgupjebmx449xjgutyhw8hgjevqpgzxyidk6eseedmark.012.run/) |
| 61 | + |
| 62 | +The playground has a contact sheet, family filters, chat-size previews, square/circle crops, light/dark backgrounds, SVG export and shareable seeds. It does not change tinyrelay's current avatars. |
| 63 | + |
| 64 | +## API |
| 65 | + |
| 66 | +- **`avatar(seed, {size = 64, version = 1} = {})`** returns a standalone SVG string with a fixed `0 0 64 64` viewBox. `size` must be an integer from 1 through 4096; only root width/height change, never geometry. Designed for 24–48 px chat avatars. |
| 67 | +- **`recipe(seed, {version = 1} = {})`** returns deeply frozen, plain JSON-serializable data. Palette is an array of four hex colors: background, light, warm/accent, cool/accent. Some compositions use fewer colors. Recipes never include the seed. |
| 68 | +- **`createRng(seed, namespace = 'default')`** returns a fresh independent object with `next()` in `[0, 1)`, `int(min, max)` with inclusive safe-integer bounds and a safe span, and `pick(nonemptyArray)`. Namespace must be a string. Helpers are noncryptographic and not intended for statistically exact sampling over enormous ranges. |
| 69 | +- **`FAMILIES`** is a frozen, ordered array: `masks`, `creatures`, `botanical`, `orbital`, `landscapes`, `glyphs`, `woven`, `machines`. |
| 70 | +- **`VERSION`** is `1`. Unsupported versions and invalid sizes/ranges throw `RangeError`; nonstring seeds/namespaces throw `TypeError`. |
| 71 | + |
| 72 | +All seeds must be primitive strings. Empty strings, Unicode, case, whitespace, embedded nulls, and arbitrarily long input are accepted as-is. There is no hex validation, trimming, Unicode normalization, or truncation. Runtime is linear in seed length. |
| 73 | + |
| 74 | +## Determinism and versioning |
| 75 | + |
| 76 | +Version 1 length-frames the full JavaScript UTF-16 seed, version, and namespace into a four-word cyrb128-derived hash state, then uses sfc32. No randomness, clock, persistent shared state, or runtime-specific APIs are used. Same exact seed and namespace produce the same sequence regardless of calls or draws elsewhere. |
| 77 | + |
| 78 | +The visual layers use separate public namespaces: `family`, `layout`, `palette`, `silhouette`, `features`, and `texture`. In v1, `createRng(seed, 'family').pick(FAMILIES)` reproduces family selection. Consuming texture draws cannot perturb palette, silhouette, or family selection. Geometry is pure recipe data rendered with bounded internal primitives, never seed interpolation. SVGs have no IDs, definitions, scripts, links, or external references, so inline grids do not collide. |
| 79 | + |
| 80 | +Visual version covers hashing, stream usage, family ordering, palette choices, recipe interpretation, and rendering. Preserve v1 when adding future visual versions: do not silently change existing identities. The tests pin PRNG values, a complete recipe, and SVG hashes for all families. Package releases and visual versions are separate concepts. |
| 81 | + |
| 82 | +**Not cryptographic. Not an authentication, public-key verification, or fingerprint scheme.** Different seeds can produce the same or similar avatar; a matching picture proves nothing about identity. Do not use secret seeds if their derived appearance would expose sensitive information. Sampled variety tests measure different geometry strings, not perceptual uniqueness. |
| 83 | + |
| 84 | +## Development |
| 85 | + |
| 86 | +Requires Node.js 18+ for the native test runner; the library itself is browser-compatible ESM. |
| 87 | + |
| 88 | +```sh |
| 89 | +npm test |
| 90 | +npm start |
| 91 | +``` |
| 92 | + |
| 93 | +No installation or build step is needed. `npm start` uses the repository's separately supplied `server.js` playground; the distributed library includes only source, README, and license. Browser visual QA is separate from the dependency-free tests, which check deterministic streams, validation, SVG structure/safety, layer axes, fixtures, and sampled geometry variety. |
| 94 | + |
| 95 | +MIT — Hermes contributors. |
| 96 | diff --git a/VERIFICATION.md b/VERIFICATION.md |
| 97 | new file mode 100644 |
| 98 | index 0000000..07398fa |
| 99 | --- /dev/null |
| 100 | +++ b/VERIFICATION.md |
| 101 | @@ -0,0 +1,25 @@ |
| 102 | +# Verification |
| 103 | + |
| 104 | +## Automated checks |
| 105 | + |
| 106 | +`npm test`: 21 tests passed, none failed. Covers deterministic RNG streams, full opaque input, argument validation, frozen recipes, all visual families, geometry variation, texture isolation, pinned PRNG/recipe/SVG fixtures, markup safety and playground HTTP routing. |
| 107 | + |
| 108 | +`npm pack --dry-run`: package contains only the two source modules, package metadata, README and license. No dependencies or build step. |
| 109 | + |
| 110 | +## Browser checks |
| 111 | + |
| 112 | +Local Chromium and the anonymous relay-hosted playground passed 23 recorded assertions, covering generation, family filtering, selected-avatar inspection, repeatable chat avatars, empty/arbitrary seeds, theme/crop behavior, mobile overflow, size changes, pagination, seed-link restoration and clipboard SVG/link exports. Browser DOMParser accepted 1,000 generated SVGs across all eight families. Desktop and 390-pixel portrait screenshots were visually inspected. |
| 113 | + |
| 114 | +Two playground defects were reproduced and fixed before publication: dark theme inherited the light theme's foreground, and inline spans prevented circular cropping in chat. Rechecks passed. |
| 115 | + |
| 116 | +The published site's six paths were downloaded and compared with local SHA-256 hashes; JavaScript MIME types and root routing were verified. All observed runtime resources came from the site's own origin. |
| 117 | + |
| 118 | +## Size and sampling |
| 119 | + |
| 120 | +Combined library source: 14,989 bytes, or 5,023 bytes gzipped together. A single Node run generated 10,000 SVGs in about 150 ms on the development host; this is an observation, not a cross-device performance guarantee. Average SVG length was 881 bytes. |
| 121 | + |
| 122 | +The same sample (`sample/0` through `sample/9999`) produced 9,936 distinct SVG strings. Some avatars therefore match exactly, and more may look similar. The generator is not an authentication or key-verification mechanism. Automated geometry variety does not establish recognition accuracy; that needs actual use or a user study. |
| 123 | + |
| 124 | +## Scope |
| 125 | + |
| 126 | +No physical-device or cross-browser matrix was run. Seedmark is a standalone module and playground; it has not been integrated into tinyrelay's chat UI. Version 1 fixes visual output so future releases must preserve it or explicitly introduce a new visual version. |
| 127 | diff --git a/demo/demo.js b/demo/demo.js |
| 128 | new file mode 100644 |
| 129 | index 0000000..66d5657 |
| 130 | --- /dev/null |
| 131 | +++ b/demo/demo.js |
| 132 | @@ -0,0 +1,115 @@ |
| 133 | +import { avatar, recipe, FAMILIES, VERSION } from '../src/index.js'; |
| 134 | + |
| 135 | +const $ = selector => document.querySelector(selector); |
| 136 | +const params = new URL(location.href).searchParams; |
| 137 | +let seed = params.get('seed') ?? 'seedmark'; |
| 138 | +let selected = params.get('selected') ?? seed; |
| 139 | +let batch = 0, samples = [], view = 'gallery'; |
| 140 | +$('#seed').value = seed; |
| 141 | +for (const family of FAMILIES) { |
| 142 | + const option = document.createElement('option'); option.value = family; option.textContent = family; $('#family').append(option); |
| 143 | +} |
| 144 | +function image(text, size) { |
| 145 | + const el = document.createElement('span'); el.className = 'avatar'; |
| 146 | + el.style.width = `${size}px`; el.style.height = `${size}px`; |
| 147 | + el.innerHTML = avatar(text, { size, version: VERSION }); |
| 148 | + el.setAttribute('aria-hidden', 'true'); return el; |
| 149 | +} |
| 150 | +function syncURL() { |
| 151 | + const url = new URL(location.href); url.searchParams.set('seed', seed); |
| 152 | + if (selected !== seed) url.searchParams.set('selected', selected); else url.searchParams.delete('selected'); |
| 153 | + try { history.replaceState(null, '', url); } catch {} |
| 154 | +} |
| 155 | +function colors(value) { |
| 156 | + if (typeof value === 'string') return /^#[0-9a-f]{3,8}$/i.test(value) ? [value] : []; |
| 157 | + if (!value || typeof value !== 'object') return []; |
| 158 | + return Object.values(value).flatMap(colors); |
| 159 | +} |
| 160 | +function select(text) { |
| 161 | + selected = text; const r = recipe(text, { version: VERSION }); |
| 162 | + $('#selected').innerHTML = avatar(text, { size: 164, version: VERSION }); |
| 163 | + $('#selected-seed').textContent = text === '' ? '(empty string)' : text; |
| 164 | + $('#selected-family').textContent = r.family; $('#selected-version').textContent = String(r.version); |
| 165 | + $('#recipe').textContent = JSON.stringify(r, null, 2); |
| 166 | + $('#scale-preview').replaceChildren(...[24, 32, 48, 64].map(size => { |
| 167 | + const item = document.createElement('div'); item.className = 'scale-item'; |
| 168 | + const label = document.createElement('small'); label.textContent = `${size}`; |
| 169 | + item.append(image(text, size), label); return item; |
| 170 | + })); |
| 171 | + $('#palette').replaceChildren(...[...new Set(colors(r.palette))].map(color => { |
| 172 | + const swatch = document.createElement('span'); swatch.style.backgroundColor = color; swatch.title = color; return swatch; |
| 173 | + })); |
| 174 | + for (const button of document.querySelectorAll('.tile')) button.setAttribute('aria-pressed', button.dataset.seed === selected); |
| 175 | + $('#status').textContent = ''; syncURL(); |
| 176 | +} |
| 177 | +function makeSamples() { |
| 178 | + samples = Array.from({ length: 128 }, (_, i) => { |
| 179 | + const text = i === 0 && batch === 0 ? seed : `${seed}/${batch}:${i}`; |
| 180 | + return { seed: text, family: recipe(text, { version: VERSION }).family }; |
| 181 | + }); |
| 182 | +} |
| 183 | +function drawGallery() { |
| 184 | + const family = $('#family').value, size = Number($('#size').value); |
| 185 | + const filtered = samples.filter(s => family === 'all' || s.family === family); |
| 186 | + $('#gallery').style.setProperty('--size', `${size}px`); |
| 187 | + $('#gallery').replaceChildren(...filtered.map(s => { |
| 188 | + const button = document.createElement('button'); button.className = 'tile'; button.dataset.seed = s.seed; |
| 189 | + button.title = `${s.family}: ${s.seed}`; button.setAttribute('aria-label', `Select ${s.family} avatar for ${s.seed || 'empty seed'}`); |
| 190 | + button.setAttribute('aria-pressed', s.seed === selected); |
| 191 | + const code = document.createElement('code'); code.textContent = s.seed || '(empty)'; |
| 192 | + button.append(image(s.seed, size), code); button.onclick = () => select(s.seed); return button; |
| 193 | + })); |
| 194 | + $('#batch-note').textContent = `${filtered.length} avatars / batch ${batch + 1}`; |
| 195 | +} |
| 196 | +const lines = [ |
| 197 | + [0, 'Anyone tried the new avatar module yet?'], |
| 198 | + [1, 'This is me. Apparently I’m a very specific arrangement of shapes.'], |
| 199 | + [2, 'I found you in the gallery before I read your name.'], |
| 200 | + [0, 'That’s the test. Same person, every time.'], |
| 201 | + [3, 'Checking how these hold up at chat size.'], |
| 202 | + [1, 'Try the circle crop too.'], |
| 203 | + [2, 'And light mode against dark mode.'], |
| 204 | + [3, 'The actual seed can be any text.'], |
| 205 | + [0, 'No initials required.'] |
| 206 | +]; |
| 207 | +function drawChat() { |
| 208 | + const names = ['Rowan', 'Alex', 'Kit', 'Sol']; |
| 209 | + const label = document.createElement('p'); label.className = 'chat-label'; label.textContent = 'Example conversation'; |
| 210 | + $('#chat').replaceChildren(label, ...lines.map(([person, text], index) => { |
| 211 | + const identity = samples[person]; const row = document.createElement('article'); row.className = 'message'; |
| 212 | + const button = document.createElement('button'); button.setAttribute('aria-label', `Inspect ${names[person]}'s avatar`); |
| 213 | + button.append(image(identity.seed, 40)); button.onclick = () => select(identity.seed); |
| 214 | + const body = document.createElement('div'), name = document.createElement('h3'), time = document.createElement('time'), p = document.createElement('p'); |
| 215 | + name.textContent = names[person]; time.textContent = `12:${String(index + 10).padStart(2, '0')}`; name.append(time); p.textContent = text; |
| 216 | + body.append(name, p); row.append(button, body); return row; |
| 217 | + })); |
| 218 | +} |
| 219 | +function setView(next) { |
| 220 | + view = next; $('#gallery').hidden = next !== 'gallery'; $('#chat').hidden = next !== 'chat'; |
| 221 | + $('#grid-tab').setAttribute('aria-pressed', next === 'gallery'); $('#chat-tab').setAttribute('aria-pressed', next === 'chat'); |
| 222 | + $('#family').disabled = next === 'chat'; $('#size').disabled = next === 'chat'; |
| 223 | +} |
| 224 | +$('#seed-form').onsubmit = event => { event.preventDefault(); seed = $('#seed').value; batch = 0; makeSamples(); drawGallery(); drawChat(); select(seed); }; |
| 225 | +$('#shuffle').onclick = () => { |
| 226 | + const bytes = crypto.getRandomValues(new Uint8Array(12)); |
| 227 | + $('#seed').value = [...bytes].map(n => n.toString(16).padStart(2, '0')).join(''); |
| 228 | + $('#seed-form').requestSubmit(); |
| 229 | +}; |
| 230 | +$('#family').onchange = drawGallery; $('#size').onchange = drawGallery; |
| 231 | +$('#crop').onchange = () => document.body.classList.toggle('circle', $('#crop').value === 'circle'); |
| 232 | +$('#more').onclick = () => { batch++; makeSamples(); drawGallery(); drawChat(); }; |
| 233 | +$('#grid-tab').onclick = () => setView('gallery'); $('#chat-tab').onclick = () => setView('chat'); |
| 234 | +$('#theme').onclick = () => document.body.classList.toggle('dark'); |
| 235 | +async function copy(text, success) { |
| 236 | + try { await navigator.clipboard.writeText(text); $('#status').textContent = success; } |
| 237 | + catch { $('#status').textContent = 'Clipboard unavailable. Download the SVG, or copy the seed from above.'; } |
| 238 | +} |
| 239 | +$('#copy-svg').onclick = () => copy(avatar(selected, { size: 256, version: VERSION }), 'SVG copied.'); |
| 240 | +$('#copy-link').onclick = () => { syncURL(); copy(location.href, 'Seed link copied.'); }; |
| 241 | +$('#download-svg').onclick = () => { |
| 242 | + const url = URL.createObjectURL(new Blob([avatar(selected, { size: 256, version: VERSION })], { type: 'image/svg+xml' })); |
| 243 | + const a = document.createElement('a'); a.href = url; a.download = 'seedmark.svg'; a.click(); |
| 244 | + setTimeout(() => URL.revokeObjectURL(url), 1000); $('#status').textContent = 'SVG downloaded.'; |
| 245 | +}; |
| 246 | +makeSamples(); drawGallery(); drawChat(); select(selected); |
| 247 | +Object.defineProperty(window, '__seedmark', { value: Object.freeze({ snapshot: () => Object.freeze({ seed, selected, batch, view, family: recipe(selected).family, count: document.querySelectorAll('.tile').length, version: VERSION }) }) }); |
| 248 | diff --git a/demo/index.html b/demo/index.html |
| 249 | new file mode 100644 |
| 250 | index 0000000..db2b37c |
| 251 | --- /dev/null |
| 252 | +++ b/demo/index.html |
| 253 | @@ -0,0 +1,35 @@ |
| 254 | +<!doctype html> |
| 255 | +<html lang="en"> |
| 256 | +<head> |
| 257 | +<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"> |
| 258 | +<meta name="color-scheme" content="light dark"><title>Seedmark</title> |
| 259 | +<link rel="stylesheet" href="/demo/style.css"> |
| 260 | +</head> |
| 261 | +<body> |
| 262 | +<header><a href="/" class="brand">Seedmark<span aria-hidden="true">✳</span></a><nav aria-label="Preview"><button id="grid-tab" aria-pressed="true">Gallery</button><button id="chat-tab" aria-pressed="false">Chat</button></nav><button id="theme" aria-label="Switch color theme">◐</button></header> |
| 263 | +<main> |
| 264 | +<section class="workbench" aria-label="Avatar previews"> |
| 265 | +<form id="seed-form"><label for="seed">Seed</label><input id="seed" name="seed" spellcheck="false" autocomplete="off" value="seedmark"><button type="submit">Generate</button><button type="button" id="shuffle" aria-label="Use a random seed">↻</button></form> |
| 266 | +<div class="filters"><label for="family">Family<select id="family"><option value="all">All families</option></select></label><label for="size">Size<select id="size"><option value="32">32 px</option><option value="48">48 px</option><option value="64" selected>64 px</option><option value="96">96 px</option></select></label><label for="crop">Crop<select id="crop"><option value="square">Square</option><option value="circle">Circle</option></select></label><button id="more">Next batch →</button></div> |
| 267 | +<div id="gallery" aria-label="Generated avatars"></div> |
| 268 | +<section id="chat" aria-label="Example chat" hidden></section> |
| 269 | +<p id="batch-note" role="status"></p> |
| 270 | +</section> |
| 271 | +<aside aria-label="Selected avatar"> |
| 272 | +<div id="selected" class="avatar"></div> |
| 273 | +<code id="selected-seed"></code> |
| 274 | +<div id="scale-preview" aria-label="Selected avatar at chat sizes"></div> |
| 275 | +<dl><div><dt>Family</dt><dd id="selected-family"></dd></div><div><dt>Visual version</dt><dd id="selected-version"></dd></div></dl> |
| 276 | +<div id="palette" aria-label="Palette"></div> |
| 277 | +<div class="actions"><button id="copy-svg">Copy SVG</button><button id="download-svg">Download</button></div> |
| 278 | +<button id="copy-link">Copy seed link</button> |
| 279 | +<details><summary>Recipe</summary><pre id="recipe"></pre></details> |
| 280 | +<details><summary>Use the module</summary><pre><code>import { avatar } from './src/index.js'; |
| 281 | + |
| 282 | +avatar('any seed text', { size: 48 });</code></pre></details> |
| 283 | +<p id="status" role="status" aria-live="polite"></p> |
| 284 | +</aside> |
| 285 | +</main> |
| 286 | +<script type="module" src="/demo/demo.js"></script> |
| 287 | +</body> |
| 288 | +</html> |
| 289 | diff --git a/demo/style.css b/demo/style.css |
| 290 | new file mode 100644 |
| 291 | index 0000000..6fc4cd1 |
| 292 | --- /dev/null |
| 293 | +++ b/demo/style.css |
| 294 | @@ -0,0 +1 @@ |
| 295 | +:root{color-scheme:light;--bg:#f4f2ed;--surface:#fffef9;--ink:#272a27;--muted:#70756f;--line:#dcded5;--accent:#35634a;--hover:#e8ece2;font-family:ui-sans-serif,system-ui,-apple-system,sans-serif;color:var(--ink);background:var(--bg)}*{box-sizing:border-box}body{margin:0;color:var(--ink);background:var(--bg)}body.dark{color-scheme:dark;--bg:#171b1b;--surface:#212727;--ink:#edf0e6;--muted:#a0aaa4;--line:#3d4945;--accent:#b7dba0;--hover:#303c35}button,input,select{font:inherit;color:inherit}button,select{cursor:pointer}button{background:var(--surface);border:1px solid var(--line);border-radius:7px;padding:9px 13px;min-height:40px}button:hover{background:var(--hover)}button:focus-visible,input:focus-visible,select:focus-visible,summary:focus-visible,a:focus-visible{outline:3px solid var(--accent);outline-offset:3px}button[aria-pressed=true]{background:var(--ink);color:var(--bg);border-color:var(--ink)}header{display:flex;align-items:center;gap:32px;padding:23px 32px;border-bottom:1px solid var(--line);background:var(--surface)}.brand{font-size:25px;font-weight:650;letter-spacing:-1px;color:inherit;text-decoration:none}.brand span{margin-left:9px;color:var(--accent)}header nav{display:flex;gap:6px}#theme{margin-left:auto;font-size:20px;width:42px;padding:4px}main{display:grid;grid-template-columns:minmax(0,1fr) 300px;max-width:1600px;margin:auto}.workbench{padding:28px 32px;min-width:0}#seed-form{display:flex;align-items:center;gap:10px}label{font-size:12px;font-weight:600}#seed{min-width:0;flex:1;padding:11px 12px;border-radius:7px;border:1px solid var(--line);background:var(--surface);font-family:ui-monospace,monospace;font-size:12px}#shuffle{font-size:23px;padding:3px 12px}.filters{display:flex;gap:20px;align-items:center;margin:24px 0}.filters label{display:flex;gap:7px;align-items:center;color:var(--muted)}select{max-width:160px;background:var(--surface);border:1px solid var(--line);border-radius:6px;padding:8px;font-size:12px;min-height:38px}#more{margin-left:auto;font-size:12px}#gallery{--size:64px;display:grid;grid-template-columns:repeat(auto-fill,minmax(calc(var(--size) + 28px),1fr));gap:8px}.tile{display:flex;align-items:center;flex-direction:column;justify-content:center;gap:10px;padding:16px 4px 12px;min-width:0;border:1px solid transparent;background:transparent}.tile:hover{background:var(--surface);border-color:var(--line)}.tile[aria-pressed=true]{background:var(--surface);color:var(--ink);border-color:var(--accent);box-shadow:inset 0 0 0 1px var(--accent)}.avatar{display:block;line-height:0;flex-shrink:0}.tile .avatar{width:var(--size);height:var(--size)}.avatar svg{width:100%;height:100%;display:block}.circle .avatar{border-radius:50%;overflow:hidden}.tile code{font-family:ui-monospace,monospace;font-size:9px;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--muted)}#batch-note{color:var(--muted);font-size:11px;margin-top:25px}aside{border-left:1px solid var(--line);padding:30px 24px;background:var(--surface);align-self:start;position:sticky;top:0;min-height:calc(100vh - 89px)}#selected{width:164px;height:164px;margin:0 auto 20px}#selected-seed{display:block;overflow-wrap:anywhere;text-align:center;font-size:11px;line-height:1.6;max-height:110px;overflow:auto}#scale-preview{display:flex;gap:13px;align-items:end;justify-content:center;margin:25px 0}.scale-item{display:grid;justify-items:center;gap:8px}.scale-item small{font-size:9px;color:var(--muted)}dl{margin:22px 0;font-size:12px}dl>div{display:flex;justify-content:space-between;padding:10px 0;border-bottom:1px solid var(--line)}dt{color:var(--muted)}dd{margin:0;text-transform:capitalize}#palette{display:flex;height:24px;border-radius:5px;overflow:hidden;margin:18px 0}#palette span{flex:1}.actions{display:flex;gap:8px}.actions button{flex:1;font-size:12px}#copy-link{width:100%;margin-top:8px;font-size:12px}details{border-top:1px solid var(--line);margin-top:20px;padding-top:14px}summary{font-size:12px;cursor:pointer}pre{font:10px/1.6 ui-monospace,monospace;white-space:pre-wrap;overflow-wrap:anywhere;max-height:300px;overflow:auto;color:var(--muted)}#status{font-size:12px;color:var(--accent);min-height:18px}#chat{background:var(--surface);border:1px solid var(--line);border-radius:10px;padding:14px 20px}.message{display:flex;gap:13px;padding:16px 0;align-items:start}.message .avatar{width:40px;height:40px}.message h3{font-size:13px;margin:1px 0 6px}.message time{color:var(--muted);font-weight:400;margin-left:10px;font-size:10px}.message p{margin:0;font-size:13px;line-height:1.5;color:var(--muted)}.message button{border:0;background:transparent;padding:0;min-height:0}.chat-label{font-size:12px;color:var(--muted);margin:5px 0 15px}[hidden]{display:none!important}@media(max-width:1050px){header{padding:18px 22px}.workbench{padding:22px}.filters{gap:9px;flex-wrap:wrap}main{grid-template-columns:minmax(0,1fr) 260px}aside{padding:25px 18px}.filters label{font-size:11px}.filters select{max-width:135px}}@media(max-width:740px){header{gap:15px;padding:15px}.brand{font-size:22px}header nav{gap:3px}header nav button{padding:8px 11px}main{display:flex;flex-direction:column}.workbench{padding:20px 14px;width:100%}aside{border-left:0;border-top:1px solid var(--line);position:static;width:100%;min-height:0;padding:25px;display:block}#seed-form{gap:6px}#seed-form label{display:none}#seed-form button{padding:10px}#seed-form #shuffle{padding:3px 10px}.filters{gap:10px;margin:16px 0}.filters label{gap:5px}#more{margin-left:0}#gallery{gap:4px;grid-template-columns:repeat(auto-fill,minmax(calc(var(--size) + 14px),1fr))}.tile{padding:12px 2px 8px}#selected-seed{max-width:500px;margin:auto}aside>*:not(#selected){max-width:500px;margin-left:auto;margin-right:auto}#chat{padding:12px}.message p{font-size:12px}.message{gap:10px}#selected{width:144px;height:144px}}@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important}} |
| 296 | diff --git a/package.json b/package.json |
| 297 | new file mode 100644 |
| 298 | index 0000000..14770a4 |
| 299 | --- /dev/null |
| 300 | +++ b/package.json |
| 301 | @@ -0,0 +1,12 @@ |
| 302 | +{ |
| 303 | + "name": "seedmark", |
| 304 | + "version": "1.0.0", |
| 305 | + "description": "Deterministic small SVG avatars from opaque text seeds", |
| 306 | + "type": "module", |
| 307 | + "main": "./src/index.js", |
| 308 | + "exports": "./src/index.js", |
| 309 | + "files": ["src", "README.md", "LICENSE"], |
| 310 | + "scripts": {"test": "node --test", "start": "node server.js"}, |
| 311 | + "license": "MIT", |
| 312 | + "engines": {"node": ">=18"} |
| 313 | +} |
| 314 | diff --git a/qa/playground.test.js b/qa/playground.test.js |
| 315 | new file mode 100644 |
| 316 | index 0000000..3f2943d |
| 317 | --- /dev/null |
| 318 | +++ b/qa/playground.test.js |
| 319 | @@ -0,0 +1,21 @@ |
| 320 | +import test from 'node:test'; |
| 321 | +import assert from 'node:assert/strict'; |
| 322 | +import { existsSync } from 'node:fs'; |
| 323 | + |
| 324 | +test('playground server serves only the demo and public module', async () => { |
| 325 | + assert.ok(existsSync(new URL('../server.js', import.meta.url)), 'Playground server must exist'); |
| 326 | + const { createServer } = await import('../server.js'); |
| 327 | + const server = createServer(); |
| 328 | + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); |
| 329 | + const base = `http://127.0.0.1:${server.address().port}`; |
| 330 | + try { |
| 331 | + const page = await fetch(base + '/'); |
| 332 | + assert.equal(page.status, 200); |
| 333 | + assert.match(page.headers.get('content-type'), /text\/html/); |
| 334 | + const html = await page.text(); |
| 335 | + for (const id of ['seed', 'gallery', 'chat', 'selected', 'copy-svg', 'download-svg']) assert.ok(html.includes(`id="${id}"`), id); |
| 336 | + assert.equal((await fetch(base + '/demo/demo.js')).status, 200); |
| 337 | + assert.equal((await fetch(base + '/demo/style.css')).status, 200); |
| 338 | + for (const path of ['/package.json', '/README.md', '/.git/config', '/missing', '/demo/%2e%2e/server.js']) assert.equal((await fetch(base + path)).status, 404, path); |
| 339 | + } finally { await new Promise(resolve => server.close(resolve)); } |
| 340 | +}); |
| 341 | diff --git a/server.js b/server.js |
| 342 | new file mode 100644 |
| 343 | index 0000000..f3ed7e1 |
| 344 | --- /dev/null |
| 345 | +++ b/server.js |
| 346 | @@ -0,0 +1,24 @@ |
| 347 | +import { createServer as httpServer } from 'node:http'; |
| 348 | +import { readFile } from 'node:fs/promises'; |
| 349 | +import { fileURLToPath } from 'node:url'; |
| 350 | +import { resolve } from 'node:path'; |
| 351 | + |
| 352 | +export function createServer() { |
| 353 | + return httpServer(async (req, res) => { |
| 354 | + const pathname = new URL(req.url, 'http://localhost').pathname; |
| 355 | + const path = pathname === '/' ? '/demo/index.html' : pathname; |
| 356 | + if (!/^\/(demo|src)\/[a-zA-Z0-9_/-]+\.(html|css|js|svg)$/.test(path) || !['GET', 'HEAD'].includes(req.method)) { |
| 357 | + res.writeHead(404); res.end('Not found'); return; |
| 358 | + } |
| 359 | + try { |
| 360 | + const data = await readFile(new URL('.' + path, import.meta.url)); |
| 361 | + const mime = { html:'text/html; charset=utf-8', css:'text/css', js:'text/javascript', svg:'image/svg+xml' }[path.split('.').at(-1)]; |
| 362 | + res.writeHead(200, {'Content-Type':mime,'Cache-Control':'no-cache','X-Content-Type-Options':'nosniff'}); |
| 363 | + res.end(req.method === 'HEAD' ? undefined : data); |
| 364 | + } catch { res.writeHead(404); res.end('Not found'); } |
| 365 | + }); |
| 366 | +} |
| 367 | +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { |
| 368 | + const port = Number(process.env.PORT || 4174); |
| 369 | + createServer().listen(port, '0.0.0.0', () => console.log(`Seedmark: http://localhost:${port}`)); |
| 370 | +} |
| 371 | diff --git a/src/index.js b/src/index.js |
| 372 | new file mode 100644 |
| 373 | index 0000000..790187e |
| 374 | --- /dev/null |
| 375 | +++ b/src/index.js |
| 376 | @@ -0,0 +1,91 @@ |
| 377 | +import {render} from './render.js'; |
| 378 | + |
| 379 | +export const VERSION = 1; |
| 380 | +// Ordering is part of the versioned visual contract. |
| 381 | +export const FAMILIES = Object.freeze(['masks', 'creatures', 'botanical', 'orbital', 'landscapes', 'glyphs', 'woven', 'machines']); |
| 382 | +const PALETTES = [ |
| 383 | + ['#142d36', '#f5dfad', '#ed7351', '#65c4ae'], |
| 384 | + ['#24223c', '#f7dfbf', '#a89cfc', '#f7788b'], |
| 385 | + ['#163b32', '#f4eacb', '#b4cf66', '#f69c64'], |
| 386 | + ['#3a2330', '#ffe5b9', '#ef7689', '#8dceba'], |
| 387 | + ['#1d304c', '#e8eff0', '#f4b855', '#72b9d0'], |
| 388 | + ['#422d26', '#f8e8c8', '#e8a04d', '#72afa0'], |
| 389 | + ['#283526', '#f4edcf', '#d3c65e', '#ed8863'], |
| 390 | + ['#292745', '#efe8f5', '#d89ada', '#8fcabc'], |
| 391 | + ['#18343c', '#f8e4d0', '#f88c66', '#a7cee0'], |
| 392 | + ['#393127', '#f4ebd8', '#adb77a', '#da8c6f'], |
| 393 | + ['#2c2440', '#fbe8c2', '#f2b54f', '#b2a1e2'], |
| 394 | + ['#1b3537', '#e9f0d7', '#79c7b8', '#e5a4aa'] |
| 395 | +]; |
| 396 | +function freeze(value) { |
| 397 | + for (const child of Object.values(value)) if (child && typeof child === 'object') freeze(child); |
| 398 | + return Object.freeze(value); |
| 399 | +} |
| 400 | +export function recipe(seed, {version = VERSION} = {}) { |
| 401 | + if (version !== VERSION) throw new RangeError('unsupported visual version'); |
| 402 | + const stream = name => createRng(seed, name); |
| 403 | + const layout = stream('layout'), silhouette = stream('silhouette'); |
| 404 | + const features = stream('features'), texture = stream('texture'); |
| 405 | + return freeze({ |
| 406 | + version, |
| 407 | + family: stream('family').pick(FAMILIES), |
| 408 | + palette: [...stream('palette').pick(PALETTES)], |
| 409 | + layout: {variant: layout.int(0, 3), flip: layout.int(0, 1), tilt: layout.pick([-12, 0, 12])}, |
| 410 | + silhouette: {variant: silhouette.int(0, 3), width: silhouette.int(26, 38), height: silhouette.int(28, 42)}, |
| 411 | + features: {variant: features.int(0, 3), count: features.int(2, 5), accent: features.int(0, 1)}, |
| 412 | + texture: {variant: texture.int(0, 2), marks: texture.int(2, 4)} |
| 413 | + }); |
| 414 | +} |
| 415 | + |
| 416 | +export function avatar(seed, {size = 64, version = VERSION} = {}) { |
| 417 | + if (!Number.isInteger(size) || size < 1 || size > 4096) throw new RangeError('size must be an integer from 1 to 4096'); |
| 418 | + const r = recipe(seed, {version}); |
| 419 | + return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 64 64"><rect width="64" height="64" fill="${r.palette[0]}"/>${render(r)}</svg>`; |
| 420 | +} |
| 421 | + |
| 422 | +// Version 1: length-framed UTF-16 input, cyrb128 mixing, sfc32 stream. |
| 423 | +// Noncryptographic. Every call owns its own four-word state. |
| 424 | +function hash(text) { |
| 425 | + let a = 1779033703, b = 3144134277, c = 1013904242, d = 2773480762; |
| 426 | + for (let i = 0; i < text.length; i++) { |
| 427 | + const k = text.charCodeAt(i); |
| 428 | + a = b ^ Math.imul(a ^ k, 597399067); |
| 429 | + b = c ^ Math.imul(b ^ k, 2869860233); |
| 430 | + c = d ^ Math.imul(c ^ k, 951274213); |
| 431 | + d = a ^ Math.imul(d ^ k, 2716044179); |
| 432 | + } |
| 433 | + a = Math.imul(c ^ (a >>> 18), 597399067); |
| 434 | + b = Math.imul(d ^ (b >>> 22), 2869860233); |
| 435 | + c = Math.imul(a ^ (c >>> 17), 951274213); |
| 436 | + d = Math.imul(b ^ (d >>> 19), 2716044179); |
| 437 | + return [(a ^ b ^ c ^ d) >>> 0, (b ^ a) >>> 0, (c ^ a) >>> 0, (d ^ a) >>> 0]; |
| 438 | +} |
| 439 | +const frame = (...parts) => parts.map(s => `${s.length}:${s}`).join(''); |
| 440 | +export function createRng(seed, namespace = 'default') { |
| 441 | + if (typeof seed !== 'string') throw new TypeError('seed must be a string'); |
| 442 | + if (typeof namespace !== 'string') throw new TypeError('namespace must be a string'); |
| 443 | + let [a, b, c, d] = hash(frame('seedmark', '1', seed, namespace)); |
| 444 | + const rng = { |
| 445 | + next() { |
| 446 | + const t = ((a + b | 0) + d | 0); |
| 447 | + d = d + 1 | 0; |
| 448 | + a = b ^ b >>> 9; |
| 449 | + b = c + (c << 3) | 0; |
| 450 | + c = (c << 21 | c >>> 11) + t | 0; |
| 451 | + return (t >>> 0) / 4294967296; |
| 452 | + }, |
| 453 | + int(min, max) { |
| 454 | + const span = max - min + 1; |
| 455 | + if (!Number.isSafeInteger(min) || !Number.isSafeInteger(max) || min > max || !Number.isSafeInteger(span)) { |
| 456 | + throw new RangeError('integer bounds must be ordered safe integers with a safe span'); |
| 457 | + } |
| 458 | + return min + Math.floor(rng.next() * span); |
| 459 | + }, |
| 460 | + pick(array) { |
| 461 | + if (!Array.isArray(array)) throw new TypeError('pick expects an array'); |
| 462 | + if (!array.length) throw new RangeError('cannot pick from an empty array'); |
| 463 | + return array[rng.int(0, array.length - 1)]; |
| 464 | + } |
| 465 | + }; |
| 466 | + return rng; |
| 467 | +} |
| 468 | diff --git a/src/render.js b/src/render.js |
| 469 | new file mode 100644 |
| 470 | index 0000000..b78782d |
| 471 | --- /dev/null |
| 472 | +++ b/src/render.js |
| 473 | @@ -0,0 +1,200 @@ |
| 474 | +// Internal geometry only: no seed text reaches markup. |
| 475 | +const circle = (x, y, r, fill) => `<circle cx="${x}" cy="${y}" r="${r}" fill="${fill}"/>`; |
| 476 | +const ellipse = (x, y, rx, ry, fill) => `<ellipse cx="${x}" cy="${y}" rx="${rx}" ry="${ry}" fill="${fill}"/>`; |
| 477 | +const rect = (x, y, w, h, rx, fill) => `<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="${rx}" fill="${fill}"/>`; |
| 478 | +const path = (d, fill, stroke = 'none', width = 0) => `<path d="${d}" fill="${fill}" stroke="${stroke}" stroke-width="${width}" stroke-linecap="round" stroke-linejoin="round"/>`; |
| 479 | +const group = (body, transform) => `<g transform="${transform}">${body}</g>`; |
| 480 | +function masks(r) { |
| 481 | + const [bg, light, hot, cool] = r.palette; |
| 482 | + const {variant: s, width: w, height: h} = r.silhouette; |
| 483 | + const {variant: f, accent} = r.features; |
| 484 | + const x = 32 - w / 2, y = 33 - h / 2; |
| 485 | + let art = [ |
| 486 | + path('M17 23L10 9L28 19M47 23L54 9L36 19', cool), |
| 487 | + rect(20, 8, 24, 16, 4, hot), |
| 488 | + path('M17 23L20 7L32 16L44 7L47 23Z', hot), |
| 489 | + circle(32, 13, 10, cool) + circle(32, 13, 4, bg) |
| 490 | + ][r.layout.variant]; |
| 491 | + art += [ |
| 492 | + path(`M${x} ${y}Q32 ${y-6} ${64-x} ${y}L${64-x-2} 43L32 56L${x+2} 43Z`, light), |
| 493 | + ellipse(32, 33, w / 2, h / 2, light), |
| 494 | + rect(x, y, w, h, 8, light), |
| 495 | + path(`M32 ${y-3}L${64-x} 25L${64-x-4} 47L32 54L${x+4} 47L${x} 25Z`, light) |
| 496 | + ][s]; |
| 497 | + art += path('M32 18L39 25L36 42L32 47Z', accent ? hot : cool); |
| 498 | + art += [ |
| 499 | + ellipse(24, 31, 5, 6, bg) + ellipse(40, 31, 5, 6, bg), |
| 500 | + path('M18 28L29 32L19 35ZM35 32L46 28L45 35Z', bg), |
| 501 | + rect(18, 28, 12, 6, 2, bg) + circle(40, 31, 5, bg), |
| 502 | + path('M18 33Q24 23 29 33M35 33Q40 23 46 33', 'none', bg, 4) |
| 503 | + ][f]; |
| 504 | + art += accent ? rect(26, 43, 12, 4, 2, bg) : path('M27 42L32 48L37 42Z', bg); |
| 505 | + return art; |
| 506 | +} |
| 507 | +function creatures(r) { |
| 508 | + const [bg, light, hot, cool] = r.palette; |
| 509 | + const {variant: s, width: w} = r.silhouette; |
| 510 | + const {variant: f, count, accent} = r.features; |
| 511 | + let art = [ |
| 512 | + path('M22 27Q4 9 14 9Q24 10 27 26M36 26Q45 4 51 13L43 29', hot), |
| 513 | + path('M19 29L8 20L13 39M43 28L55 19L51 40', cool), |
| 514 | + path('M25 25L23 10M39 25L44 12', 'none', light, 5) + circle(23, 10, 5, hot) + circle(44, 12, 5, hot), |
| 515 | + path('M17 25L15 13L28 22L36 11L40 23L50 18L47 30Z', hot) |
| 516 | + ][r.layout.variant]; |
| 517 | + art += path('M22 43L17 52L25 52M40 43L47 52L38 52', 'none', cool, 6); |
| 518 | + art += [ |
| 519 | + ellipse(32, 35, w / 2, 17, cool), |
| 520 | + path('M15 43Q10 19 32 22Q52 10 50 43Q43 55 32 46Q20 55 15 43Z', cool), |
| 521 | + rect(32-w/2, 22, w, 27, 10, cool), |
| 522 | + path('M12 42L23 20Q32 12 42 26L53 43Q33 58 12 42Z', cool) |
| 523 | + ][s]; |
| 524 | + if (f === 0) art += circle(32, 33, 10, light) + circle(32 + accent * 3, 33, 4, bg); |
| 525 | + if (f === 1) art += ellipse(24, 32, 6, 8, light) + ellipse(40, 32, 6, 8, light) + circle(25, 33, 3, bg) + circle(41, 33, 3, bg); |
| 526 | + if (f === 2) art += rect(19, 27, 26, 11, 5, bg) + circle(25, 32, 3, light) + circle(38, 32, 3, light); |
| 527 | + if (f === 3) for (let i = 0; i < count; i++) art += circle(20 + i * 24 / (count - 1), 31 + i % 2 * 4, 4, light) + circle(20 + i * 24 / (count - 1), 32 + i % 2 * 4, 2, bg); |
| 528 | + art += accent ? path('M28 43Q32 49 37 42', 'none', bg, 3) : rect(29, 42, 7, 4, 2, bg); |
| 529 | + return art; |
| 530 | +} |
| 531 | +function botanical(r) { |
| 532 | + const [bg, light, hot, cool] = r.palette; |
| 533 | + const {variant: s, width: w} = r.silhouette; |
| 534 | + const {variant: f, count} = r.features; |
| 535 | + let art = [ |
| 536 | + path('M23 43L25 55L40 55L43 43Z', hot), |
| 537 | + ellipse(32, 48, 18, 7, hot), |
| 538 | + path('M20 52L25 44L39 44L46 52Z', light), |
| 539 | + path('M25 54L32 45L39 54M20 50L32 45L45 49', 'none', hot, 4) |
| 540 | + ][r.layout.variant]; |
| 541 | + art += path('M32 46Q28 30 33 14', 'none', light, 4); |
| 542 | + if (s === 0) art += path(`M31 35Q${32-w/2} 37 12 21Q29 17 31 35ZM32 29Q35 12 51 13Q52 29 32 29Z`, cool); |
| 543 | + if (s === 1) art += path('M31 41Q11 45 12 34Q15 28 30 34M32 28Q48 33 51 23Q51 14 33 20', cool); |
| 544 | + if (s === 2) art += path('M31 39Q8 29 22 13Q35 20 31 39ZM33 37Q55 32 46 19Q32 23 33 37Z', cool); |
| 545 | + if (s === 3) art += path('M30 40L13 29L27 27L15 16L31 23L38 9L40 26L53 19L45 36Z', cool); |
| 546 | + const bloomX = f % 2 ? 41 : 29, bloomY = f < 2 ? 16 : 24; |
| 547 | + if (f === 0) { |
| 548 | + art += circle(bloomX-6, bloomY, 7, hot) + circle(bloomX+6, bloomY, 7, hot) + circle(bloomX, bloomY-6, 7, hot) + circle(bloomX, bloomY+5, 7, hot) + circle(bloomX, bloomY, 4, light); |
| 549 | + } else if (f === 1) art += path('M29 20L30 9L37 14L42 7L47 15L54 11L51 22Q42 32 29 20Z', hot) + circle(42, 21, 4, light); |
| 550 | + else if (f === 2) for (let i = 0; i < count; i++) art += circle(19 + i * 6, 16 + i % 2 * 6, 4, hot); |
| 551 | + else art += ellipse(40, 18, 9, 13, hot) + path('M40 10L40 25', 'none', light, 3); |
| 552 | + return art; |
| 553 | +} |
| 554 | +function orbital(r) { |
| 555 | + const [bg, light, hot, cool] = r.palette; |
| 556 | + const {variant: s, width: w} = r.silhouette; |
| 557 | + const {variant: f, count} = r.features; |
| 558 | + const radius = 11 + (w - 26) / 3; |
| 559 | + let art = [ |
| 560 | + path('M9 39Q1 18 31 12Q58 6 55 30Q49 53 23 52', 'none', cool, 4), |
| 561 | + path('M12 13L49 50M12 50L49 13', 'none', cool, 4), |
| 562 | + path('M9 32A23 23 0 1 1 32 55', 'none', cool, 5), |
| 563 | + rect(12, 12, 40, 40, 12, cool) + rect(17, 17, 30, 30, 8, bg) |
| 564 | + ][r.layout.variant]; |
| 565 | + art += [circle(32, 32, radius, hot), rect(18, 18, 28, 28, 6, hot), path('M32 14L50 32L32 50L14 32Z', hot), ellipse(32, 32, radius, 19, hot)][s]; |
| 566 | + if (f === 0) art += path('M10 37Q32 46 53 25', 'none', light, 5); |
| 567 | + if (f === 1) art += circle(29, 29, 7, light) + circle(33, 26, 7, hot); |
| 568 | + if (f === 2) art += path('M21 25L43 25M21 34L43 34M27 42L37 42', 'none', light, 4); |
| 569 | + if (f === 3) art += circle(32, 32, 7, bg) + circle(32, 32, 3, light); |
| 570 | + for (let i = 0; i < count; i++) art += circle([13, 49, 16, 46, 32][i], [16, 46, 47, 13, 7][i], i % 2 ? 3 : 4, light); |
| 571 | + return art; |
| 572 | +} |
| 573 | +function landscapes(r) { |
| 574 | + const [bg, light, hot, cool] = r.palette; |
| 575 | + const {variant: s, height: h} = r.silhouette; |
| 576 | + const {variant: f, accent} = r.features; |
| 577 | + const horizon = 31 + (h - 28) / 2; |
| 578 | + let art = [circle(22, 19, 9, hot), circle(43, 19, 10, hot), rect(14, 12, 34, 9, 4, hot), circle(32, 21, 15, hot)][r.layout.variant]; |
| 579 | + art += [ |
| 580 | + path(`M7 46L21 22L33 37L43 26L57 46V55H7Z`, cool), |
| 581 | + path(`M7 ${horizon}Q18 21 31 ${horizon}Q46 22 57 35V55H7Z`, cool), |
| 582 | + path('M8 53V25H20V38H30V19H40V32H55V53Z', cool), |
| 583 | + path('M7 36L18 28L26 32L39 23L57 33V55H7Z', cool) |
| 584 | + ][s]; |
| 585 | + if (f === 0) art += path('M31 35L24 45L36 45L28 55H42L45 43L33 43L37 35Z', light); |
| 586 | + if (f === 1) art += path('M9 45Q21 40 32 46T55 46M13 53Q23 48 35 53', 'none', light, 4); |
| 587 | + if (f === 2) art += path('M18 48V35M12 42L18 32L24 42ZM42 50V38M36 43L42 33L48 43Z', light, light, 2); |
| 588 | + if (f === 3) art += rect(26, 36, 15, 17, 2, light) + path('M23 36L33 27L44 36Z', light) + rect(31, 43, 5, 10, 0, bg); |
| 589 | + art += accent ? path('M9 18Q13 13 17 18Q21 13 25 18', 'none', light, 2) : circle(51, 12, 3, light); |
| 590 | + return art; |
| 591 | +} |
| 592 | +function glyphs(r) { |
| 593 | + const [bg, light, hot, cool] = r.palette; |
| 594 | + const {variant: s, width: w} = r.silhouette; |
| 595 | + const {variant: f, accent} = r.features; |
| 596 | + let art = [ |
| 597 | + circle(32, 32, 24, cool) + circle(32, 32, 18, bg), |
| 598 | + rect(10, 10, 44, 44, 10, cool) + rect(16, 16, 32, 32, 5, bg), |
| 599 | + path('M10 26L26 10M38 10L54 26M54 38L38 54M26 54L10 38', 'none', cool, 6), |
| 600 | + path('M10 47V17H24M54 17V47H40', 'none', cool, 6) |
| 601 | + ][r.layout.variant]; |
| 602 | + const a = 32 - w/2 + 4, b = 64 - a; |
| 603 | + art += [ |
| 604 | + path(`M${a} 43L32 17L${b} 43M24 35H40`, 'none', light, 7), |
| 605 | + path(`M${a} 22H${b}L${a} 43H${b}`, 'none', light, 7), |
| 606 | + path(`M${a} 19V44H${b}V19M32 20V39`, 'none', light, 7), |
| 607 | + path('M21 18H37Q49 24 37 32Q50 40 38 46H21Z', light) + rect(27, 24, 9, 5, 2, bg) + rect(27, 36, 10, 5, 2, bg) |
| 608 | + ][s]; |
| 609 | + art += [circle(32, 32, 5, hot), path('M32 24L40 32L32 40L24 32Z', hot), rect(26, 27, 12, 10, 2, hot), path('M22 37L42 27', 'none', hot, 6)][f]; |
| 610 | + art += circle(accent ? 49 : 15, accent ? 15 : 49, 4, light); |
| 611 | + return art; |
| 612 | +} |
| 613 | +function woven(r) { |
| 614 | + const [bg, light, hot, cool] = r.palette; |
| 615 | + const {variant: s, width: w} = r.silhouette; |
| 616 | + const {variant: f, count} = r.features; |
| 617 | + let art = ''; |
| 618 | + const gap = 10 + (w - 26) / 6; |
| 619 | + if (s === 0) { |
| 620 | + for (let i = 0; i < 3; i++) art += rect(13+i*gap, 10, 7, 44, 3, i%2 ? hot : light); |
| 621 | + for (let i = 0; i < 3; i++) art += rect(9, 16+i*gap, 45, 6, 2, cool); |
| 622 | + art += rect(24, 23, 7, 15, 2, hot); |
| 623 | + } |
| 624 | + if (s === 1) for (let i = 0; i < 3; i++) art += path(`M${12+i*12} 12L${20+i*12} 25L${12+i*12} 39L${20+i*12} 52`, 'none', [light, hot, cool][i], 8); |
| 625 | + if (s === 2) { |
| 626 | + art += path('M12 20Q12 11 22 11H43Q52 11 52 21V43Q52 53 42 53H22Q12 53 12 43Z', 'none', light, 6); |
| 627 | + art += path('M21 10V44H43V20H10M54 32H32V54', 'none', hot, 7); |
| 628 | + art += rect(28, 22, 8, 8, 1, cool); |
| 629 | + } |
| 630 | + if (s === 3) for (let i = 0; i < 3; i++) art += path(`M10 ${18+i*13}L22 ${10+i*13}L34 ${18+i*13}L46 ${10+i*13}L54 ${18+i*13}L46 ${26+i*13}L34 ${18+i*13}L22 ${26+i*13}Z`, [light, hot, cool][i]); |
| 631 | + art = group(art, `rotate(${r.layout.variant * 45} 32 32) scale(.88) translate(4.36 4.36)`); |
| 632 | + art += [circle(32, 32, 5, bg) + circle(32, 32, 2, light), rect(26, 26, 12, 12, 3, hot), path('M32 24L40 32L32 40L24 32Z', cool), path('M27 28L37 36M37 28L27 36', 'none', bg, 4)][f]; |
| 633 | + for (let i = 0; i < count; i++) art += circle(18 + i * 7, 56, 2, hot); |
| 634 | + return art; |
| 635 | +} |
| 636 | +function machines(r) { |
| 637 | + const [bg, light, hot, cool] = r.palette; |
| 638 | + const {variant: s, width: w} = r.silhouette; |
| 639 | + const {variant: f, count, accent} = r.features; |
| 640 | + const x = 32-w/2; |
| 641 | + let art = [ |
| 642 | + path('M22 23L17 11M42 23L49 11', 'none', hot, 4) + circle(17, 11, 4, light), |
| 643 | + rect(23, 9, 18, 16, 4, hot) + rect(28, 12, 8, 6, 1, bg), |
| 644 | + path('M12 27L8 39L19 44M51 27L56 39L47 44', 'none', hot, 6), |
| 645 | + circle(22, 49, 8, hot) + circle(43, 49, 8, hot) |
| 646 | + ][r.layout.variant]; |
| 647 | + art += [ |
| 648 | + rect(x, 20, w, 30, 5, light), |
| 649 | + path(`M${x+5} 19H${59-x}L${64-x} 45L41 52H23L${x} 45Z`, light), |
| 650 | + ellipse(32, 35, w/2, 18, light), |
| 651 | + path('M23 17H41L52 29V44L41 52H23L12 44V29Z', light) |
| 652 | + ][s]; |
| 653 | + if (f === 0) art += circle(32, 34, 11, bg) + circle(32, 34, 7, cool) + circle(35, 31, 3, light); |
| 654 | + if (f === 1) art += rect(20, 26, 24, 17, 3, bg) + path('M24 37L29 30L34 36L40 29', 'none', cool, 3); |
| 655 | + if (f === 2) { |
| 656 | + art += rect(19, 25, 26, 15, 2, bg); |
| 657 | + for (let i = 0; i < count; i++) art += rect(22+i*4, 28, 2, 9, 1, cool); |
| 658 | + } |
| 659 | + if (f === 3) art += circle(25, 32, 6, bg) + circle(39, 32, 6, bg) + path('M20 44H44', 'none', hot, 4); |
| 660 | + art += circle(accent ? 43 : 21, 46, 3, hot); |
| 661 | + return art; |
| 662 | +} |
| 663 | + |
| 664 | +const renderers = {masks, creatures, botanical, orbital, landscapes, glyphs, woven, machines}; |
| 665 | +export function render(r) { |
| 666 | + let texture = ''; |
| 667 | + for (let i = 0; i < r.texture.marks; i++) { |
| 668 | + const x = 8 + i * 6; |
| 669 | + if (r.texture.variant === 1) texture += circle(x, 6, 1.5, r.palette[3]); |
| 670 | + if (r.texture.variant === 2) texture += rect(x - 1, 5, 3, 3, 0, r.palette[2]); |
| 671 | + } |
| 672 | + return texture + group(renderers[r.family](r), `translate(32 32) rotate(${r.layout.tilt}) scale(${r.layout.flip ? -1 : 1} 1) translate(-32 -32)`); |
| 673 | +} |
| 674 | diff --git a/tests/avatar.test.js b/tests/avatar.test.js |
| 675 | new file mode 100644 |
| 676 | index 0000000..5ec8eee |
| 677 | --- /dev/null |
| 678 | +++ b/tests/avatar.test.js |
| 679 | @@ -0,0 +1,22 @@ |
| 680 | +import test from 'node:test'; |
| 681 | +import assert from 'node:assert/strict'; |
| 682 | +import * as api from '../src/index.js'; |
| 683 | + |
| 684 | +import {validate} from './helpers.js'; |
| 685 | +test('avatar safely renders standalone size-independent SVG without reflecting opaque seeds', () => { |
| 686 | + assert.equal(typeof api.avatar, 'function'); |
| 687 | + for (const seed of ['', '你好😀', '<script>alert(1)</script>', '\"><svg onload="alert(1)">', 'UNIQUE_UNTRUSTED_SEED']) { |
| 688 | + const svg = api.avatar(seed); |
| 689 | + validate(svg); |
| 690 | + assert.equal(svg, api.avatar(seed)); |
| 691 | + if (seed) assert(!svg.includes(seed)); |
| 692 | + assert.match(svg, /width="64" height="64"/); |
| 693 | + assert.equal(api.avatar(seed, {size: 24}).replace('width="24" height="24"', 'width="64" height="64"'), svg); |
| 694 | + } |
| 695 | +}); |
| 696 | +test('avatar validates sizes and visual versions', () => { |
| 697 | + for (const size of [0, -1, NaN, Infinity, '64', null, {}, 1.5, 4097]) assert.throws(() => api.avatar('s', {size}), RangeError); |
| 698 | + for (const size of [1, 24, 48, 64, 4096]) validate(api.avatar('s', {size})); |
| 699 | + for (const version of [0, 2, '1', null]) assert.throws(() => api.avatar('s', {version}), RangeError); |
| 700 | + for (const seed of [null, 23, undefined, {}]) assert.throws(() => api.avatar(seed), TypeError); |
| 701 | +}); |
| 702 | diff --git a/tests/families.test.js b/tests/families.test.js |
| 703 | new file mode 100644 |
| 704 | index 0000000..b6a72bc |
| 705 | --- /dev/null |
| 706 | +++ b/tests/families.test.js |
| 707 | @@ -0,0 +1,124 @@ |
| 708 | +import test from 'node:test'; |
| 709 | +import assert from 'node:assert/strict'; |
| 710 | +import {avatar, recipe} from '../src/index.js'; |
| 711 | +import {validate} from './helpers.js'; |
| 712 | +test('masks renders a distinct grammar with sampled geometric variety', () => { |
| 713 | + const shapes = new Set(); |
| 714 | + let n = 0; |
| 715 | + for (let i = 0; n < 96 && i < 10000; i++) { |
| 716 | + const seed = `visual-${i}`; |
| 717 | + if (recipe(seed).family !== 'masks') continue; |
| 718 | + const svg = avatar(seed); |
| 719 | + validate(svg); |
| 720 | + assert(svg.length > 450, 'family needs a composed silhouette, not a blank tile'); |
| 721 | + shapes.add(svg.replace(/#[0-9a-f]{6}/g, '#COLOR')); |
| 722 | + n++; |
| 723 | + } |
| 724 | + assert.equal(n, 96); |
| 725 | + assert(shapes.size >= 80, `only ${shapes.size} distinct sampled geometries`); |
| 726 | +}); |
| 727 | +test('creatures renders a distinct grammar with sampled geometric variety', () => { |
| 728 | + const shapes = new Set(); |
| 729 | + let n = 0; |
| 730 | + for (let i = 0; n < 96 && i < 10000; i++) { |
| 731 | + const seed = `visual-${i}`; |
| 732 | + if (recipe(seed).family !== 'creatures') continue; |
| 733 | + const svg = avatar(seed); |
| 734 | + validate(svg); |
| 735 | + assert(svg.length > 450, 'family needs a composed silhouette, not a blank tile'); |
| 736 | + shapes.add(svg.replace(/#[0-9a-f]{6}/g, '#COLOR')); |
| 737 | + n++; |
| 738 | + } |
| 739 | + assert.equal(n, 96); |
| 740 | + assert(shapes.size >= 80, `only ${shapes.size} distinct sampled geometries`); |
| 741 | +}); |
| 742 | +test('botanical renders a distinct grammar with sampled geometric variety', () => { |
| 743 | + const shapes = new Set(); |
| 744 | + let n = 0; |
| 745 | + for (let i = 0; n < 96 && i < 10000; i++) { |
| 746 | + const seed = `visual-${i}`; |
| 747 | + if (recipe(seed).family !== 'botanical') continue; |
| 748 | + const svg = avatar(seed); |
| 749 | + validate(svg); |
| 750 | + assert(svg.length > 450, 'family needs a composed silhouette, not a blank tile'); |
| 751 | + shapes.add(svg.replace(/#[0-9a-f]{6}/g, '#COLOR')); |
| 752 | + n++; |
| 753 | + } |
| 754 | + assert.equal(n, 96); |
| 755 | + assert(shapes.size >= 80, `only ${shapes.size} distinct sampled geometries`); |
| 756 | +}); |
| 757 | +test('orbital renders a distinct grammar with sampled geometric variety', () => { |
| 758 | + const shapes = new Set(); |
| 759 | + let n = 0; |
| 760 | + for (let i = 0; n < 96 && i < 10000; i++) { |
| 761 | + const seed = `visual-${i}`; |
| 762 | + if (recipe(seed).family !== 'orbital') continue; |
| 763 | + const svg = avatar(seed); |
| 764 | + validate(svg); |
| 765 | + assert(svg.length > 450, 'family needs a composed silhouette, not a blank tile'); |
| 766 | + shapes.add(svg.replace(/#[0-9a-f]{6}/g, '#COLOR')); |
| 767 | + n++; |
| 768 | + } |
| 769 | + assert.equal(n, 96); |
| 770 | + assert(shapes.size >= 80, `only ${shapes.size} distinct sampled geometries`); |
| 771 | +}); |
| 772 | +test('landscapes renders a distinct grammar with sampled geometric variety', () => { |
| 773 | + const shapes = new Set(); |
| 774 | + let n = 0; |
| 775 | + for (let i = 0; n < 96 && i < 10000; i++) { |
| 776 | + const seed = `visual-${i}`; |
| 777 | + if (recipe(seed).family !== 'landscapes') continue; |
| 778 | + const svg = avatar(seed); |
| 779 | + validate(svg); |
| 780 | + assert(svg.length > 450, 'family needs a composed silhouette, not a blank tile'); |
| 781 | + shapes.add(svg.replace(/#[0-9a-f]{6}/g, '#COLOR')); |
| 782 | + n++; |
| 783 | + } |
| 784 | + assert.equal(n, 96); |
| 785 | + assert(shapes.size >= 80, `only ${shapes.size} distinct sampled geometries`); |
| 786 | +}); |
| 787 | +test('glyphs renders a distinct grammar with sampled geometric variety', () => { |
| 788 | + const shapes = new Set(); |
| 789 | + let n = 0; |
| 790 | + for (let i = 0; n < 96 && i < 10000; i++) { |
| 791 | + const seed = `visual-${i}`; |
| 792 | + if (recipe(seed).family !== 'glyphs') continue; |
| 793 | + const svg = avatar(seed); |
| 794 | + validate(svg); |
| 795 | + assert(svg.length > 450, 'family needs a composed silhouette, not a blank tile'); |
| 796 | + shapes.add(svg.replace(/#[0-9a-f]{6}/g, '#COLOR')); |
| 797 | + n++; |
| 798 | + } |
| 799 | + assert.equal(n, 96); |
| 800 | + assert(shapes.size >= 80, `only ${shapes.size} distinct sampled geometries`); |
| 801 | +}); |
| 802 | +test('woven renders a distinct grammar with sampled geometric variety', () => { |
| 803 | + const shapes = new Set(); |
| 804 | + let n = 0; |
| 805 | + for (let i = 0; n < 96 && i < 10000; i++) { |
| 806 | + const seed = `visual-${i}`; |
| 807 | + if (recipe(seed).family !== 'woven') continue; |
| 808 | + const svg = avatar(seed); |
| 809 | + validate(svg); |
| 810 | + assert(svg.length > 450, 'family needs a composed silhouette, not a blank tile'); |
| 811 | + shapes.add(svg.replace(/#[0-9a-f]{6}/g, '#COLOR')); |
| 812 | + n++; |
| 813 | + } |
| 814 | + assert.equal(n, 96); |
| 815 | + assert(shapes.size >= 80, `only ${shapes.size} distinct sampled geometries`); |
| 816 | +}); |
| 817 | +test('machines renders a distinct grammar with sampled geometric variety', () => { |
| 818 | + const shapes = new Set(); |
| 819 | + let n = 0; |
| 820 | + for (let i = 0; n < 96 && i < 10000; i++) { |
| 821 | + const seed = `visual-${i}`; |
| 822 | + if (recipe(seed).family !== 'machines') continue; |
| 823 | + const svg = avatar(seed); |
| 824 | + validate(svg); |
| 825 | + assert(svg.length > 450, 'family needs a composed silhouette, not a blank tile'); |
| 826 | + shapes.add(svg.replace(/#[0-9a-f]{6}/g, '#COLOR')); |
| 827 | + n++; |
| 828 | + } |
| 829 | + assert.equal(n, 96); |
| 830 | + assert(shapes.size >= 80, `only ${shapes.size} distinct sampled geometries`); |
| 831 | +}); |
| 832 | diff --git a/tests/fixtures.test.js b/tests/fixtures.test.js |
| 833 | new file mode 100644 |
| 834 | index 0000000..07442dc |
| 835 | --- /dev/null |
| 836 | +++ b/tests/fixtures.test.js |
| 837 | @@ -0,0 +1,37 @@ |
| 838 | +import test from 'node:test'; |
| 839 | +import assert from 'node:assert/strict'; |
| 840 | +import {createHash} from 'node:crypto'; |
| 841 | +import {readFileSync} from 'node:fs'; |
| 842 | +import {avatar, recipe, createRng} from '../src/index.js'; |
| 843 | +// Observed v1 baselines. Do not regenerate silently after a visual change. |
| 844 | +const fixtures = [ |
| 845 | + ['fixture-6', 'masks', 'eec76b565ecb257360802cd4d5eb30913fffe97c06ba6b09a315db886c7603d0'], |
| 846 | + ['fixture-0', 'creatures', '31a91272a95cfd0a50fff7c6bd572ee84fa1af2b1c61392e9333214c10ae5783'], |
| 847 | + ['fixture-25', 'botanical', '10480b88ef9b36468996bd50bb88551b315eff332974b239de93db32f6fd6b0f'], |
| 848 | + ['fixture-1', 'orbital', '7f7a558f2d1ea95b4f4e0a5701c40ce2cbbe1b98e5bb6ff6a71fc57b340434ad'], |
| 849 | + ['fixture-11', 'landscapes', 'd773085a08b6cabc8924f3b2c0244d399f6f643e482e4403f747efddb41de59c'], |
| 850 | + ['fixture-2', 'glyphs', '8dfbbd3b67d1c29f0a4971a837a107d5e13c7a499a8433b7be7a45bc196385be'], |
| 851 | + ['fixture-4', 'woven', 'd1d0eecf83c96e3e573f9170f5bb950f27bdf5b32d334add4b2b0149a633e94c'], |
| 852 | + ['fixture-3', 'machines', '824c60f3de59713b1911793f299f585f85cf1ee190c03e13173f06f21309c719'] |
| 853 | +]; |
| 854 | +test('version 1 SVG bytes stay fixed for every family', () => { |
| 855 | + for (const [seed, family, digest] of fixtures) { |
| 856 | + assert.equal(recipe(seed).family, family); |
| 857 | + assert.equal(createHash('sha256').update(avatar(seed)).digest('hex'), digest); |
| 858 | + } |
| 859 | +}); |
| 860 | +test('version 1 PRNG and complete recipe stay fixed', () => { |
| 861 | + const rng = createRng('seedmark fixture', 'layout'); |
| 862 | + assert.deepEqual(Array.from({length: 8}, () => rng.next()), [0.9326388167683035, 0.5709015629254282, 0.6208453709259629, 0.3309869400691241, 0.9687688224948943, 0.5534903716761619, 0.6713465694338083, 0.20093300566077232]); |
| 863 | + assert.deepEqual(recipe('fixture-6'), { |
| 864 | + version: 1, family: 'masks', palette: ['#422d26', '#f8e8c8', '#e8a04d', '#72afa0'], |
| 865 | + layout: {variant: 0, flip: 1, tilt: -12}, silhouette: {variant: 3, width: 26, height: 39}, |
| 866 | + features: {variant: 3, count: 5, accent: 0}, texture: {variant: 1, marks: 3} |
| 867 | + }); |
| 868 | +}); |
| 869 | +test('public source stays portable and has no ambient entropy or external assets', () => { |
| 870 | + for (const name of ['index.js', 'render.js']) { |
| 871 | + const source = readFileSync(new URL(`../src/${name}`, import.meta.url), 'utf8'); |
| 872 | + assert(!/Math\.random|\bDate\b|\bperformance\b|\bprocess\b|\bdocument\b|\bwindow\b|node:|foreignObject|<script|<image|<text|\bid=/.test(source)); |
| 873 | + } |
| 874 | +}); |
| 875 | diff --git a/tests/helpers.js b/tests/helpers.js |
| 876 | new file mode 100644 |
| 877 | index 0000000..2d87123 |
| 878 | --- /dev/null |
| 879 | +++ b/tests/helpers.js |
| 880 | @@ -0,0 +1,28 @@ |
| 881 | +import assert from 'node:assert/strict'; |
| 882 | +// Small strict validator for the intentionally tiny emitted SVG vocabulary. |
| 883 | +export function validate(svg) { |
| 884 | + const stack = []; |
| 885 | + const tags = new Set(['svg', 'g', 'rect', 'circle', 'ellipse', 'path', 'polygon', 'line']); |
| 886 | + let consumed = ''; |
| 887 | + for (const match of svg.matchAll(/<\/?([a-z]+)([^<>]*?)\/?\s*>/g)) { |
| 888 | + consumed += match[0]; |
| 889 | + assert(tags.has(match[1]), `unexpected tag ${match[1]}`); |
| 890 | + if (match[0].startsWith('</')) assert.equal(stack.pop(), match[1]); |
| 891 | + else { |
| 892 | + const attrs = match[2]; |
| 893 | + const parsed = [...attrs.matchAll(/\s+([a-zA-Z-]+)="([^"<>]*)"/g)]; |
| 894 | + assert.equal(parsed.map(a => a[0]).join(''), attrs); |
| 895 | + assert.equal(new Set(parsed.map(a => a[1])).size, parsed.length); |
| 896 | + for (const [, key, value] of parsed) { |
| 897 | + assert(!/^(?:on|id$|href$|style$)/i.test(key)); |
| 898 | + assert(!/NaN|Infinity|undefined|url\(|[&<>]/.test(value)); |
| 899 | + if (['x', 'y', 'cx', 'cy', 'r', 'rx', 'ry', 'width', 'height', 'stroke-width'].includes(key)) assert(Number.isFinite(Number(value))); |
| 900 | + } |
| 901 | + if (!match[0].endsWith('/>')) stack.push(match[1]); |
| 902 | + } |
| 903 | + } |
| 904 | + assert.equal(stack.length, 0); |
| 905 | + assert.equal(consumed, svg); |
| 906 | + assert.match(svg, /^<svg xmlns="http:\/\/www.w3.org\/2000\/svg"/); |
| 907 | + assert.match(svg, /viewBox="0 0 64 64"/); |
| 908 | +} |
| 909 | diff --git a/tests/layers.test.js b/tests/layers.test.js |
| 910 | new file mode 100644 |
| 911 | index 0000000..eb2e578 |
| 912 | --- /dev/null |
| 913 | +++ b/tests/layers.test.js |
| 914 | @@ -0,0 +1,21 @@ |
| 915 | +import test from 'node:test'; |
| 916 | +import assert from 'node:assert/strict'; |
| 917 | +import {recipe, FAMILIES} from '../src/index.js'; |
| 918 | +import {render} from '../src/render.js'; |
| 919 | +test('every grammar responds to independently varied composition, shape and feature axes', () => { |
| 920 | + for (const family of FAMILIES) { |
| 921 | + const r = JSON.parse(JSON.stringify(recipe('axis-fixture'))); |
| 922 | + r.family = family; |
| 923 | + for (const layer of ['layout', 'silhouette', 'features']) { |
| 924 | + const variants = Array.from({length: 4}, (_, variant) => render({...r, [layer]: {...r[layer], variant}})); |
| 925 | + assert.equal(new Set(variants).size, 4, `${family} ${layer}`); |
| 926 | + } |
| 927 | + } |
| 928 | +}); |
| 929 | +test('texture variants change only the independent texture layer', () => { |
| 930 | + const r = recipe('texture-fixture'); |
| 931 | + const variants = Array.from({length: 3}, (_, variant) => render({...r, texture: {...r.texture, variant}})); |
| 932 | + assert.equal(new Set(variants).size, 3); |
| 933 | + const foreground = svg => svg.slice(svg.indexOf('<g transform=')); |
| 934 | + assert.equal(new Set(variants.map(foreground)).size, 1); |
| 935 | +}); |
| 936 | diff --git a/tests/recipe.test.js b/tests/recipe.test.js |
| 937 | new file mode 100644 |
| 938 | index 0000000..b8445ae |
| 939 | --- /dev/null |
| 940 | +++ b/tests/recipe.test.js |
| 941 | @@ -0,0 +1,34 @@ |
| 942 | +import test from 'node:test'; |
| 943 | +import assert from 'node:assert/strict'; |
| 944 | +import * as api from '../src/index.js'; |
| 945 | +const families = ['masks', 'creatures', 'botanical', 'orbital', 'landscapes', 'glyphs', 'woven', 'machines']; |
| 946 | +const frozen = value => value && typeof value === 'object' && Object.isFrozen(value) && Object.values(value).every(v => v === null || typeof v !== 'object' || frozen(v)); |
| 947 | +test('recipes are deeply frozen plain data with all eight stable families and independent layers', () => { |
| 948 | + assert.equal(typeof api.recipe, 'function'); |
| 949 | + assert.equal(api.VERSION, 1); |
| 950 | + assert.deepEqual(api.FAMILIES, families); |
| 951 | + assert(Object.isFrozen(api.FAMILIES)); |
| 952 | + const seen = new Set(); |
| 953 | + for (let i = 0; i < 512; i++) { |
| 954 | + const seed = `recipe-${i}`; |
| 955 | + const r = api.recipe(seed); |
| 956 | + seen.add(r.family); |
| 957 | + assert.deepEqual(r, api.recipe(seed)); |
| 958 | + assert.deepEqual(r, JSON.parse(JSON.stringify(r))); |
| 959 | + assert(frozen(r)); |
| 960 | + assert.equal(r.version, 1); |
| 961 | + assert.equal(r.family, api.createRng(seed, 'family').pick(families)); |
| 962 | + assert.equal(r.layout.variant, api.createRng(seed, 'layout').int(0, 3)); |
| 963 | + assert.equal(r.silhouette.variant, api.createRng(seed, 'silhouette').int(0, 3)); |
| 964 | + assert.equal(r.features.variant, api.createRng(seed, 'features').int(0, 3)); |
| 965 | + assert.equal(r.texture.variant, api.createRng(seed, 'texture').int(0, 2)); |
| 966 | + assert(r.palette.length >= 2 && r.palette.length <= 4); |
| 967 | + assert(r.palette.every(c => /^#[0-9a-f]{6}$/.test(c))); |
| 968 | + } |
| 969 | + assert.deepEqual(seen, new Set(families)); |
| 970 | + for (const s of ['', '😀\u0000', ' arbitrary text ']) assert(frozen(api.recipe(s))); |
| 971 | +}); |
| 972 | +test('recipe rejects unsupported versions and nonstring seeds', () => { |
| 973 | + for (const version of [0, 2, -1, 1.1, '1', null, NaN]) assert.throws(() => api.recipe('s', {version}), RangeError); |
| 974 | + for (const s of [undefined, 7, {}, null]) assert.throws(() => api.recipe(s), TypeError); |
| 975 | +}); |
| 976 | diff --git a/tests/rng.test.js b/tests/rng.test.js |
| 977 | new file mode 100644 |
| 978 | index 0000000..03337f6 |
| 979 | --- /dev/null |
| 980 | +++ b/tests/rng.test.js |
| 981 | @@ -0,0 +1,32 @@ |
| 982 | +import test from 'node:test'; |
| 983 | +import assert from 'node:assert/strict'; |
| 984 | +import * as api from '../src/index.js'; |
| 985 | +test('integer and pick helpers validate inputs and include both endpoints', () => { |
| 986 | + const rng = api.createRng('range'); |
| 987 | + assert.equal(typeof rng.int, 'function'); |
| 988 | + assert.deepEqual(new Set(Array.from({length: 100}, () => rng.int(-1, 1))), new Set([-1, 0, 1])); |
| 989 | + assert.equal(rng.int(4, 4), 4); |
| 990 | + assert.equal(rng.pick(['only']), 'only'); |
| 991 | + for (const seed of [null, undefined, 12, {}, [], new String('a')]) assert.throws(() => api.createRng(seed), TypeError); |
| 992 | + assert.throws(() => api.createRng('s', 1), TypeError); |
| 993 | + for (const args of [[2, 1], [0.1, 2], [0, Infinity], [-Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER]]) assert.throws(() => rng.int(...args), RangeError); |
| 994 | + assert.throws(() => rng.pick([]), RangeError); |
| 995 | + assert.throws(() => rng.pick('abc'), TypeError); |
| 996 | +}); |
| 997 | +const sequence = (seed, namespace) => { |
| 998 | + const rng = api.createRng(seed, namespace); |
| 999 | + return Array.from({length: 32}, () => rng.next()); |
| 1000 | +}; |
| 1001 | +test('fresh namespace streams are deterministic and independent', () => { |
| 1002 | + assert.equal(typeof api.createRng, 'function'); |
| 1003 | + const a = sequence('opaque key', 'layout'); |
| 1004 | + sequence('opaque key', 'texture'); |
| 1005 | + assert.deepEqual(sequence('opaque key', 'layout'), a); |
| 1006 | + assert.notDeepEqual(sequence('opaque key', 'palette'), a); |
| 1007 | + assert(a.every(n => n >= 0 && n < 1)); |
| 1008 | +}); |
| 1009 | +test('full unnormalized strings affect stream state', () => { |
| 1010 | + const seeds = ['', 'a', 'A', ' a', 'a ', 'é', 'e\u0301', '🌿你好', 'f'.repeat(64), 'f'.repeat(63) + 'e', 'x'.repeat(20000) + 'a', 'x'.repeat(20000) + 'b']; |
| 1011 | + assert.equal(new Set(seeds.map(s => JSON.stringify(sequence(s)))).size, seeds.length); |
| 1012 | + assert.notDeepEqual(sequence('a', 'bc'), sequence('ab', 'c')); |
| 1013 | +}); |