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