seedmarkcommit

Commit c808a940bc19

browse files at this commit | history

1diff --git a/.gitignore b/.gitignore
2new file mode 100644
3index 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
14diff --git a/LICENSE b/LICENSE
15new file mode 100644
16index 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.
41diff --git a/README.md b/README.md
42new file mode 100644
43index 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.
96diff --git a/VERIFICATION.md b/VERIFICATION.md
97new file mode 100644
98index 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.
127diff --git a/demo/demo.js b/demo/demo.js
128new file mode 100644
129index 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 }) }) });
248diff --git a/demo/index.html b/demo/index.html
249new file mode 100644
250index 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>
289diff --git a/demo/style.css b/demo/style.css
290new file mode 100644
291index 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}}
296diff --git a/package.json b/package.json
297new file mode 100644
298index 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+}
314diff --git a/qa/playground.test.js b/qa/playground.test.js
315new file mode 100644
316index 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+});
341diff --git a/server.js b/server.js
342new file mode 100644
343index 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+}
371diff --git a/src/index.js b/src/index.js
372new file mode 100644
373index 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+}
468diff --git a/src/render.js b/src/render.js
469new file mode 100644
470index 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+}
674diff --git a/tests/avatar.test.js b/tests/avatar.test.js
675new file mode 100644
676index 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+});
702diff --git a/tests/families.test.js b/tests/families.test.js
703new file mode 100644
704index 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+});
832diff --git a/tests/fixtures.test.js b/tests/fixtures.test.js
833new file mode 100644
834index 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+});
875diff --git a/tests/helpers.js b/tests/helpers.js
876new file mode 100644
877index 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+}
909diff --git a/tests/layers.test.js b/tests/layers.test.js
910new file mode 100644
911index 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+});
936diff --git a/tests/recipe.test.js b/tests/recipe.test.js
937new file mode 100644
938index 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+});
976diff --git a/tests/rng.test.js b/tests/rng.test.js
977new file mode 100644
978index 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+});

About

ownerba1b5beed5b5
visibilitypublic
headc808a940bc19

Maintainers

  • ba1b5beed5b5 owner

Clone

  • https://012.run/npub1hgd4hmk4kkukjx7yf402adl7msw0ssnmqmt0y6sgz0j8yjjcj9nqh9znde/seedmark.git

Refs