diff --git a/README.md b/README.md
index 54c1a9c..3d73881 100644
--- a/README.md
+++ b/README.md
@@ -59,7 +59,7 @@ The static production output is written to `build/`.
## Architecture
```text
-src/lib/shape.ts SHA-256-to-SVG renderer and morphology
+src/lib/shape.ts SHA-256-to-SVG renderers (v1 and v2) and morphology
src/lib/identity.ts DID input and bounded handle resolution
src/lib/export.ts standalone SVG and provenance metadata export
src/lib/oauth-config.ts production/loopback OAuth identifiers and bounded scopes
@@ -78,8 +78,13 @@ api/collectors.ts cached public collector-discovery function
lexicons/click/croft/hasharium/ AT Protocol lexicon sources
```
-The generator version is `sha256-radial-v1`. Its output is a public compatibility contract. Any
-intentional visual algorithm change must use a new version instead of mutating old specimens.
+The default generator version is `sha256-radial-v1`. Its output is a public compatibility contract.
+Any intentional visual algorithm change must use a new version instead of mutating old specimens.
+A second rendition, `sha256-radial-v2`, derives wider variety from the same SHA-256 digest using a
+hash-seeded PRNG: broader symmetry (3–11), more layers (2–6), eight extra palettes, per-petal
+pinch/wobble, and free rotation. The observation page offers a rendition selector; the same DID and
+version always reproduce the same specimen. Social cards at `/api/og` remain pinned to v1 so their
+PNGs stay immutable and cacheable.
## Protocol namespace
diff --git a/src/lib/collection.ts b/src/lib/collection.ts
index 20ead7a..5e4d950 100644
--- a/src/lib/collection.ts
+++ b/src/lib/collection.ts
@@ -1,10 +1,12 @@
import type { Agent } from "@atproto/api";
import {
GENERATOR_VERSION,
+ GENERATOR_VERSIONS,
NSID,
type CollectionEntryRecord,
+ type GeneratorVersion,
} from "./protocol.js";
-import { isDid } from "./shape.js";
+import { isDid, isGeneratorVersion } from "./shape.js";
const MAX_RECORD_PAGES = 10;
@@ -53,7 +55,7 @@ export function parseCollectionEntryRecord(
!isDid(value.subject) ||
!isDateTime(value.createdAt) ||
(value.generatorVersion !== undefined &&
- value.generatorVersion !== GENERATOR_VERSION) ||
+ !isGeneratorVersion(value.generatorVersion)) ||
(value.note !== undefined &&
(typeof value.note !== "string" ||
validateCollectionNote(value.note) !== undefined))
@@ -65,8 +67,8 @@ export function parseCollectionEntryRecord(
$type: NSID.collectionEntry,
subject: value.subject,
createdAt: value.createdAt,
- ...(value.generatorVersion === GENERATOR_VERSION
- ? { generatorVersion: GENERATOR_VERSION }
+ ...(isGeneratorVersion(value.generatorVersion)
+ ? { generatorVersion: value.generatorVersion }
: {}),
...(typeof value.note === "string" ? { note: value.note } : {}),
};
@@ -129,6 +131,7 @@ export async function createCollectionEntry(
agent: Agent,
subject: string,
note = "",
+ generatorVersion: GeneratorVersion = GENERATOR_VERSION,
): Promise {
if (!isDid(subject)) throw new Error("The specimen subject is not a DID.");
const normalizedNote = note.trim();
@@ -138,7 +141,7 @@ export async function createCollectionEntry(
const record: CollectionEntryRecord = {
$type: NSID.collectionEntry,
subject,
- generatorVersion: GENERATOR_VERSION,
+ generatorVersion,
createdAt: new Date().toISOString(),
...(normalizedNote ? { note: normalizedNote } : {}),
};
diff --git a/src/lib/export.ts b/src/lib/export.ts
index c6e9bf3..af9c114 100644
--- a/src/lib/export.ts
+++ b/src/lib/export.ts
@@ -1,4 +1,4 @@
-import { GENERATOR_VERSION, SOURCE_URL } from "./protocol";
+import { SOURCE_URL } from "./protocol";
import type { Specimen } from "./shape";
export interface SpecimenExportMetadata {
@@ -7,7 +7,7 @@ export interface SpecimenExportMetadata {
fingerprint: string;
catalogueNumber: string;
name: string;
- generatorVersion: typeof GENERATOR_VERSION;
+ generatorVersion: Specimen["generatorVersion"];
source: typeof SOURCE_URL;
}
@@ -33,7 +33,7 @@ export function specimenExportMetadata(
fingerprint: specimen.fingerprint,
catalogueNumber: specimen.catalogueNumber,
name: specimen.name,
- generatorVersion: GENERATOR_VERSION,
+ generatorVersion: specimen.generatorVersion,
source: SOURCE_URL,
};
}
diff --git a/src/lib/protocol.test.ts b/src/lib/protocol.test.ts
index 982668d..af487b3 100644
--- a/src/lib/protocol.test.ts
+++ b/src/lib/protocol.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
GENERATOR_VERSION,
+ GENERATOR_VERSIONS,
HASHARIUM_HOST,
NSID,
PLACEHOLDER_DID,
@@ -23,4 +24,12 @@ describe("Hasharium protocol constants", () => {
it("pins a named generator version for durable records", () => {
expect(GENERATOR_VERSION).toMatch(/^sha256-[a-z0-9-]+-v\d+$/);
});
+
+ it("lists every supported generator version as a named rendition", () => {
+ expect(GENERATOR_VERSIONS).toContain("sha256-radial-v1");
+ expect(GENERATOR_VERSIONS).toContain("sha256-radial-v2");
+ for (const version of GENERATOR_VERSIONS) {
+ expect(version).toMatch(/^sha256-[a-z0-9-]+-v\d+$/);
+ }
+ });
});
diff --git a/src/lib/protocol.ts b/src/lib/protocol.ts
index e6ed4a6..9a44513 100644
--- a/src/lib/protocol.ts
+++ b/src/lib/protocol.ts
@@ -1,6 +1,11 @@
export const HASHARIUM_HOST = "hasharium.croft.click";
export const SOURCE_URL = "https://github.com/ewanc26/hasharium";
export const GENERATOR_VERSION = "sha256-radial-v1";
+export const GENERATOR_VERSIONS = [
+ "sha256-radial-v1",
+ "sha256-radial-v2",
+] as const;
+export type GeneratorVersion = (typeof GENERATOR_VERSIONS)[number];
export const PLACEHOLDER_DID = "did:plc:ofrbh253gwicbkc5nktqepol";
export const NSID = {
@@ -15,7 +20,7 @@ export interface CollectionEntryRecord {
$type: typeof NSID.collectionEntry;
subject: string;
createdAt: string;
- generatorVersion?: typeof GENERATOR_VERSION;
+ generatorVersion?: GeneratorVersion;
note?: string;
}
@@ -23,6 +28,6 @@ export interface IntersectionRecord {
$type: typeof NSID.intersection;
with: string;
createdAt: string;
- generatorVersion?: typeof GENERATOR_VERSION;
+ generatorVersion?: GeneratorVersion;
note?: string;
}
diff --git a/src/lib/shape.test.ts b/src/lib/shape.test.ts
index 8000dda..49988f3 100644
--- a/src/lib/shape.test.ts
+++ b/src/lib/shape.test.ts
@@ -1,5 +1,10 @@
import { describe, expect, it } from "vitest";
-import { generateSpecimen, hashIdentity, isDid } from "./shape";
+import {
+ generateSpecimen,
+ generateSpecimenV2,
+ hashIdentity,
+ isDid,
+} from "./shape";
describe("DID validation", () => {
it("accepts common DID methods", () => {
@@ -92,6 +97,47 @@ describe("specimen generation", () => {
expect(specimen.catalogueNumber).toMatch(/^H-[A-F0-9]{4}-[A-F0-9]{4}$/);
});
+ it("exposes a v2 rendition with wider variety and stable output", async () => {
+ const first = await generateSpecimenV2("did:plc:ewvi7nxzyoun6zhxrhs64oiz");
+ const second = await generateSpecimenV2("did:plc:ewvi7nxzyoun6zhxrhs64oiz");
+ expect(second).toEqual(first);
+ expect(first.generatorVersion).toBe("sha256-radial-v2");
+ expect(first.symmetry).toBeGreaterThanOrEqual(3);
+ expect(first.symmetry).toBeLessThanOrEqual(11);
+ expect(first.layers).toBeGreaterThanOrEqual(2);
+ expect(first.layers).toBeLessThanOrEqual(6);
+ expect(first.paths).toHaveLength(first.layers);
+ expect(
+ first.paths.every((path) => path.startsWith("M ") && path.endsWith(" Z")),
+ ).toBe(true);
+ });
+
+ it("gives v2 meaningfully different geometry from v1", async () => {
+ const v1 = await generateSpecimen("did:plc:ewvi7nxzyoun6zhxrhs64oiz");
+ const v2 = await generateSpecimenV2("did:plc:ewvi7nxzyoun6zhxrhs64oiz");
+ expect(v2.fingerprint).toBe(v1.fingerprint);
+ expect(v2.name).not.toBe(v1.name);
+ expect(v2.paths).not.toEqual(v1.paths);
+ });
+
+ it("preserves the v2 golden output for $did", async () => {
+ const expected = {
+ did: "did:plc:ewvi7nxzyoun6zhxrhs64oiz",
+ fingerprint:
+ "099e4ea96cd62c05a232331859d20c97425f25b21f193068b1abf7b763e40ed1",
+ catalogueNumber: "H-099E-4EA9",
+ name: "Singing Facet",
+ symmetry: 11,
+ layers: 3,
+ aperture: 36,
+ material: "glass" as const,
+ temperament: "opening",
+ };
+ const specimen = await generateSpecimenV2(expected.did);
+ expect(specimen).toMatchObject(expected);
+ expect(specimen.generatorVersion).toBe("sha256-radial-v2");
+ });
+
it("trims surrounding whitespace without changing DID character case", async () => {
const trimmed = await generateSpecimen(" did:example:CaseSensitive ");
const exact = await generateSpecimen("did:example:CaseSensitive");
diff --git a/src/lib/shape.ts b/src/lib/shape.ts
index 58541c2..a1555ed 100644
--- a/src/lib/shape.ts
+++ b/src/lib/shape.ts
@@ -13,6 +13,7 @@ export interface Specimen {
palette: [string, string, string];
paths: string[];
rotation: number;
+ generatorVersion: GeneratorVersion;
}
const adjectives = [
@@ -74,6 +75,18 @@ const palettes: Array<[string, string, string]> = [
["#353c63", "#7f83ad", "#c45b51"],
];
+const v2Palettes: Array<[string, string, string]> = [
+ ...palettes,
+ ["#9c3b3b", "#e0a85a", "#2c4a3e"],
+ ["#1f4e5f", "#5fb3a8", "#e8c15a"],
+ ["#5d3a6b", "#c08bbf", "#e0a85a"],
+ ["#3a5a2c", "#8fb35e", "#c96645"],
+ ["#6b3f1f", "#d99a5b", "#3f5d57"],
+ ["#2c3e6b", "#7e93c4", "#d36a43"],
+ ["#7a4a3a", "#c98f6a", "#3a5d4f"],
+ ["#404a2c", "#a3b06a", "#b5643f"],
+];
+
export function isDid(value: string): boolean {
const normalized = value.trim();
return (
@@ -165,5 +178,135 @@ export async function generateSpecimen(did: string): Promise {
makeLayer(bytes, symmetry, index, layers),
),
rotation: (bytes[8] / 255) * 18 - 9,
+ generatorVersion: "sha256-radial-v1",
+ };
+}
+
+export const GENERATOR_VERSIONS = [
+ "sha256-radial-v1",
+ "sha256-radial-v2",
+] as const;
+export type GeneratorVersion = (typeof GENERATOR_VERSIONS)[number];
+
+export function isGeneratorVersion(value: unknown): value is GeneratorVersion {
+ return (
+ typeof value === "string" &&
+ (GENERATOR_VERSIONS as readonly string[]).includes(value)
+ );
+}
+
+const generators: Record Promise> =
+ {
+ "sha256-radial-v1": generateSpecimen,
+ "sha256-radial-v2": generateSpecimenV2,
+ };
+
+export function generateSpecimenForVersion(
+ did: string,
+ version: GeneratorVersion = "sha256-radial-v1",
+): Promise {
+ return (generators[version] ?? generateSpecimen)(did);
+}
+
+class Xorshift128 {
+ private state0: number;
+ private state1: number;
+
+ constructor(seed: Uint8Array) {
+ this.state0 =
+ readUint32(seed, 0) ^ readUint32(seed, 8) ^ readUint32(seed, 16);
+ this.state1 =
+ readUint32(seed, 4) ^ readUint32(seed, 12) ^ readUint32(seed, 20);
+ if (this.state0 === 0 && this.state1 === 0) {
+ this.state0 = 0x9e3779b9;
+ this.state1 = 0x243f6a88;
+ }
+ }
+
+ next(): number {
+ let s1 = this.state0;
+ const s0 = this.state1;
+ this.state0 = s0;
+ s1 ^= s1 << 23;
+ s1 ^= s1 >>> 17;
+ s1 ^= s0;
+ s1 ^= s0 >>> 26;
+ this.state1 = s1;
+ const value = (this.state0 + this.state1) >>> 0;
+ return value / 0x100000000;
+ }
+}
+
+function readUint32(bytes: Uint8Array, offset: number): number {
+ const a = bytes[offset % bytes.length] ?? 0;
+ const b = bytes[(offset + 1) % bytes.length] ?? 0;
+ const c = bytes[(offset + 2) % bytes.length] ?? 0;
+ const d = bytes[(offset + 3) % bytes.length] ?? 0;
+ return ((a << 24) | (b << 16) | (c << 8) | d) >>> 0;
+}
+
+function makeLayerV2(
+ rng: Xorshift128,
+ symmetry: number,
+ layer: number,
+ totalLayers: number,
+): string {
+ const points: Array<[number, number]> = [];
+ const petalsPerAxis = 2 + Math.floor(rng.next() * 3);
+ const pointCount = symmetry * petalsPerAxis;
+ const baseRadius = 116 - layer * (78 / Math.max(1, totalLayers - 1));
+ const phase = rng.next() * ((Math.PI * 2) / symmetry);
+ const pinch = 0.36 + rng.next() * 0.34;
+ const wobble = 0.08 + rng.next() * 0.22;
+
+ for (let index = 0; index < pointCount; index += 1) {
+ const motifIndex = index % petalsPerAxis;
+ const pulse =
+ motifIndex === 0
+ ? 1
+ : Math.max(
+ 0.3,
+ 1 - pinch * (motifIndex / petalsPerAxis) - rng.next() * wobble,
+ );
+ const radius = baseRadius * pulse;
+ const angle = phase + (index / pointCount) * Math.PI * 2;
+ points.push(polarPoint(angle, radius));
+ }
+
+ return smoothClosedPath(points);
+}
+
+export async function generateSpecimenV2(did: string): Promise {
+ const normalized = did.trim();
+ const bytes = await hashIdentity(normalized);
+ const digest = hex(bytes);
+ const rng = new Xorshift128(bytes);
+
+ const symmetry = 3 + Math.floor(rng.next() * 9);
+ const layers = 2 + Math.floor(rng.next() * 5);
+ const aperture = 5 + Math.floor(rng.next() * 40);
+ const material = materials[Math.floor(rng.next() * materials.length)];
+ const palette = v2Palettes[Math.floor(rng.next() * v2Palettes.length)];
+ const name = `${adjectives[Math.floor(rng.next() * adjectives.length)]} ${
+ forms[Math.floor(rng.next() * forms.length)]
+ }`;
+ const catalogueNumber = `H-${digest.slice(0, 4).toUpperCase()}-${digest.slice(4, 8).toUpperCase()}`;
+
+ return {
+ did: normalized,
+ fingerprint: digest,
+ catalogueNumber,
+ name,
+ symmetry,
+ layers,
+ aperture,
+ material,
+ temperament: temperaments[Math.floor(rng.next() * temperaments.length)],
+ palette,
+ paths: Array.from({ length: layers }, (_, index) =>
+ makeLayerV2(rng, symmetry, index, layers),
+ ),
+ rotation: rng.next() * 22 - 11,
+ generatorVersion: "sha256-radial-v2",
};
}
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte
index 6968698..f43dc97 100644
--- a/src/routes/+page.svelte
+++ b/src/routes/+page.svelte
@@ -14,8 +14,14 @@
import { exportSpecimenSvg, specimenExportFilename } from '$lib/export';
import { IdentityResolutionError, resolveIdentity } from '$lib/identity';
import { authState } from '$lib/oauth';
- import { GENERATOR_VERSION, NSID, PLACEHOLDER_DID } from '$lib/protocol';
- import { generateSpecimen, isDid, type Specimen } from '$lib/shape';
+ import { GENERATOR_VERSIONS, NSID, PLACEHOLDER_DID } from '$lib/protocol';
+ import {
+ generateSpecimenForVersion,
+ isDid,
+ isGeneratorVersion,
+ type GeneratorVersion,
+ type Specimen
+ } from '$lib/shape';
const defaultDid = PLACEHOLDER_DID;
const exampleDids = [
@@ -42,6 +48,7 @@
let collectionMessage = $state('');
let pendingRemoteRemovalDid = $state('');
let renderRequest = 0;
+ let generatorVersion = $state('sha256-radial-v1');
let remoteEntry = $derived(
specimen ? remoteEntries.find((entry) => entry.record.subject === specimen?.did) : undefined
);
@@ -65,7 +72,9 @@
});
async function hydrateSaved(dids: string[]) {
- savedSpecimens = await Promise.all(dids.map((did) => generateSpecimen(did)));
+ savedSpecimens = await Promise.all(
+ dids.map((did) => generateSpecimenForVersion(did, generatorVersion))
+ );
}
async function hydrateRemoteCollection(agent: Agent) {
@@ -81,12 +90,14 @@
}
async function initialize() {
+ const sharedVersion = new URL(window.location.href).searchParams.get('version');
+ if (isGeneratorVersion(sharedVersion)) generatorVersion = sharedVersion;
const sharedDid = new URL(window.location.href).searchParams.get('did');
const initialDid = sharedDid && isDid(sharedDid) ? sharedDid : defaultDid;
input = initialDid;
[specimen, examples] = await Promise.all([
- generateSpecimen(initialDid),
- Promise.all(exampleDids.map((did) => generateSpecimen(did)))
+ generateSpecimenForVersion(initialDid, generatorVersion),
+ Promise.all(exampleDids.map((did) => generateSpecimenForVersion(did, generatorVersion)))
]);
const stored = localStorage.getItem('hasharium.study-tray');
@@ -114,11 +125,11 @@
loading = true;
try {
const identity = await resolveIdentity(input);
- const next = await generateSpecimen(identity.did);
+ const next = await generateSpecimenForVersion(identity.did, generatorVersion);
if (request === renderRequest) {
specimen = next;
input = next.did;
- window.history.replaceState(null, '', `/?did=${encodeURIComponent(next.did)}`);
+ window.history.replaceState(null, '', `/?did=${encodeURIComponent(next.did)}&version=${generatorVersion}`);
resolvedHandle = identity.handle ?? '';
pendingRemoteRemovalDid = '';
}
@@ -139,13 +150,27 @@
void renderIdentity();
}
+ async function rerender() {
+ if (!specimen) return;
+ error = '';
+ loading = true;
+ try {
+ const next = await generateSpecimenForVersion(specimen.did, generatorVersion);
+ specimen = next;
+ window.history.replaceState(null, '', `/?did=${encodeURIComponent(next.did)}&version=${generatorVersion}`);
+ await hydrateSaved(savedDids);
+ } finally {
+ loading = false;
+ }
+ }
+
async function selectSpecimen(next: Specimen) {
- specimen = next;
+ specimen = await generateSpecimenForVersion(next.did, generatorVersion);
input = next.did;
resolvedHandle = '';
error = '';
pendingRemoteRemovalDid = '';
- window.history.replaceState(null, '', `/?did=${encodeURIComponent(next.did)}`);
+ window.history.replaceState(null, '', `/?did=${encodeURIComponent(next.did)}&version=${generatorVersion}`);
document.querySelector('#specimen')?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
@@ -167,7 +192,12 @@
pendingRemoteRemovalDid = '';
collectionMessage = 'The PDS confirmed removal from your profile cabinet.';
} else {
- const entry = await createCollectionEntry(current.agent, specimen.did);
+ const entry = await createCollectionEntry(
+ current.agent,
+ specimen.did,
+ '',
+ specimen.generatorVersion
+ );
remoteEntries = [entry, ...remoteEntries];
collectionMessage = 'The PDS confirmed this specimen in your profile cabinet.';
}
@@ -265,6 +295,18 @@
? `Resolved @${resolvedHandle} to ${specimen?.did ?? 'its canonical DID'}.`
: 'DIDs stay local. Handles use Microcosm Slingshot for DID resolution; no sign-in required.')}
+
@@ -391,7 +433,7 @@
PROTOCOL NOTE
{NSID.collectionEntry}
- GENERATOR {GENERATOR_VERSION}
+ GENERATOR {generatorVersion}
diff --git a/src/routes/profile/+page.svelte b/src/routes/profile/+page.svelte
index 03da396..f4d1345 100644
--- a/src/routes/profile/+page.svelte
+++ b/src/routes/profile/+page.svelte
@@ -19,7 +19,11 @@
signOutOfOAuth
} from '$lib/oauth';
import { NSID, PLACEHOLDER_DID } from '$lib/protocol';
- import { generateSpecimen, type Specimen } from '$lib/shape';
+ import {
+ generateSpecimenForVersion,
+ type GeneratorVersion,
+ type Specimen
+ } from '$lib/shape';
interface CabinetEntry {
entry: CollectionEntry;
@@ -64,13 +68,16 @@
try {
const [nextIdentity, ownSpecimen, entries] = await Promise.all([
resolveIdentityProfile(did).catch(() => ({ did })),
- generateSpecimen(did),
+ generateSpecimenForVersion(did, 'sha256-radial-v1'),
listCollectionEntries(agent)
]);
const nextCabinet = await Promise.all(
entries.map(async (entry) => ({
entry,
- specimen: await generateSpecimen(entry.record.subject)
+ specimen: await generateSpecimenForVersion(
+ entry.record.subject,
+ (entry.record.generatorVersion ?? 'sha256-radial-v1') as GeneratorVersion
+ )
}))
);
if (request === loadRequest) {
@@ -124,7 +131,13 @@
throw new Error('That specimen is already present in this profile cabinet.');
}
const entry = await createCollectionEntry(current.agent, resolved.did, fieldNote);
- cabinet = [{ entry, specimen: await generateSpecimen(resolved.did) }, ...cabinet];
+ cabinet = [
+ {
+ entry,
+ specimen: await generateSpecimenForVersion(resolved.did, 'sha256-radial-v1')
+ },
+ ...cabinet
+ ];
collectIdentifier = resolved.did;
fieldNote = '';
message = `Collected ${resolved.did}. The PDS confirmed the record.`;
diff --git a/src/routes/styles.css b/src/routes/styles.css
index d4a36b9..d38ae0a 100644
--- a/src/routes/styles.css
+++ b/src/routes/styles.css
@@ -419,6 +419,34 @@ textarea:focus-visible {
color: #a43e28;
}
+.version-select {
+ display: flex;
+ gap: 11px;
+ align-items: center;
+ margin: 16px 0 0;
+}
+
+.version-select span {
+ font-family: var(--font-mono);
+ font-size: 9px;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ color: #727b76;
+}
+
+.version-select select {
+ appearance: none;
+ min-height: var(--control-size);
+ padding: 0 var(--space-lg) 0 var(--space-md);
+ color: var(--ink);
+ cursor: pointer;
+ background: var(--surface-sunken);
+ border: 1px solid var(--line-strong);
+ border-radius: var(--radius-xs);
+ font-family: var(--font-mono);
+ font-size: 11px;
+}
+
.observation-column {
position: relative;
width: min(100%, 620px);