Identicons: hash(script id) -> one Urizen tile (blank/solid tiles skipped by deterministic ink-ratio probe, ruler columns excluded) x 20 tints x 12 border colors ≈ 1M stable combinations; initials remain as the pre-atlas fallback and hovers carry the true identity when the pigeonhole pinches. Shown in browse rows, the detail header, and proof chips. Friendly ids: adjective pool grows to 219 and every noun pool to ~130-150 (pools stay disjoint), and collisions now escalate to a second adjective (sly-ashen-wolf) before any numeric tag: ~30k short names per category, then 6-7M two-word names. Stored ids never re-derive, so existing names are untouched.
- commit
- 74c16539e9856b33a9d908742747eb5d75f9232d
- parent
- fbb5627df1448f5f3d8d5d02d195b6e00c13e696
- author
- Dami <dami.etoile@gmail.com>
| A | web/ | 107 | ++++++++++++++++++++++++++++++ |
| M | web/ | 14 | ++++ |
| M | web/ | 6 | +- |
| M | web/ | 44 | ++++++++++++ |
| M | worker/ | 76 | ++++++++++++++++++++- |
| M | worker/ | 5 | +- |
@@ -0,0 +1,107 @@
1+/**2+ * Script identicons: a stable pictographic identity hashed from the script3+ * id — one Urizen tile, one foreground tint, one border color. The space is4+ * ~4,000 usable tiles x 20 tints x 12 borders ≈ 1M combinations, and when5+ * the pigeonhole eventually pinches, collisions degrade gracefully: the6+ * hover title still carries the true identity.7+ *8+ * Deterministic: same id renders the same icon everywhere, forever.9+ */10+import { TILE, loadUrizen, type UrizenAtlas } from './render/urizen';11+12+/** Foreground tints: spread hues, bright enough for the dark ground. */13+const FG = [14+ 'hsl(20 70% 62%)', 'hsl(38 75% 60%)', 'hsl(55 65% 58%)', 'hsl(75 55% 55%)',15+ 'hsl(100 50% 58%)', 'hsl(130 50% 58%)', 'hsl(160 55% 55%)', 'hsl(180 55% 58%)',16+ 'hsl(200 65% 62%)', 'hsl(220 65% 68%)', 'hsl(245 60% 70%)', 'hsl(265 55% 68%)',17+ 'hsl(285 50% 65%)', 'hsl(310 55% 65%)', 'hsl(330 60% 65%)', 'hsl(350 65% 65%)',18+ 'hsl(0 0% 82%)', 'hsl(30 30% 70%)', 'hsl(90 20% 68%)', 'hsl(210 20% 72%)',19+] as const;20+21+/** Border colors: same wheel, dimmed — reads as a frame, not a shout. */22+const BORDER = [23+ 'hsl(20 45% 38%)', 'hsl(55 40% 35%)', 'hsl(100 35% 35%)', 'hsl(160 35% 34%)',24+ 'hsl(200 40% 38%)', 'hsl(245 35% 42%)', 'hsl(285 30% 40%)', 'hsl(330 40% 40%)',25+ 'hsl(0 0% 45%)', 'hsl(38 50% 40%)', 'hsl(180 30% 36%)', 'hsl(310 30% 38%)',26+] as const;27+28+function fnv(text: string): number {29+ let h = 0x811c9dc5;30+ for (let i = 0; i < text.length; i++) {31+ h ^= text.charCodeAt(i);32+ h = Math.imul(h, 0x01000193);33+ }34+ return h >>> 0;35+}36+37+/** Magenta ruler columns (26-column cadence) are never identicon tiles. */38+const isRulerCol = (col: number): boolean => (col + 1) % 26 === 0;39+40+let atlasPromise: Promise<UrizenAtlas> | null = null;41+function atlas(): Promise<UrizenAtlas> {42+ atlasPromise ??= loadUrizen();43+ return atlasPromise;44+}45+46+/** Pick the tile for a hash: probe until a tile has real ink (not blank,47+ * not a solid wall slab). Deterministic probe sequence, capped. */48+function pickTile(a: UrizenAtlas, h: number): { col: number; row: number } {49+ const total = a.grid.cols * a.grid.rows;50+ for (let k = 0; k < 48; k++) {51+ const idx = (h + k * 7919) % total;52+ const col = idx % a.grid.cols;53+ const row = Math.floor(idx / a.grid.cols);54+ if (isRulerCol(col)) continue;55+ const ink = a.inkRatio(col, row);56+ if (ink >= 0.08 && ink <= 0.85) return { col, row };57+ }58+ return { col: 1, row: 1 }; // deterministic last resort59+}60+61+export interface Identicon {62+ el: HTMLElement;63+ /** The tint, for callers matching adjacent accents to the icon. */64+ fg: string;65+}66+67+/**68+ * Build the identicon element (span.identicon with a canvas inside). The69+ * fallback initials show until the atlas resolves; on atlas failure they70+ * simply remain — the page never breaks on a missing sheet.71+ */72+export function identicon(id: string, fallbackTitle: string, sizePx = 22): Identicon {73+ const h = fnv(id);74+ const fg = FG[h % FG.length]!;75+ const border = BORDER[Math.floor(h / FG.length) % BORDER.length]!;76+77+ const el = document.createElement('span');78+ el.className = 'identicon';79+ el.style.borderColor = border;80+ el.style.width = `${sizePx + 6}px`;81+ el.style.height = `${sizePx + 6}px`;82+ const words = fallbackTitle.split(/[^A-Za-z0-9]+/).filter((w) => w.length > 0);83+ el.textContent =84+ words.length >= 285+ ? (words[0]![0]! + words[1]![0]!).toUpperCase()86+ : (words[0] ?? '??').slice(0, 2).toUpperCase();87+88+ void atlas()89+ .then((a) => {90+ const { col, row } = pickTile(a, Math.floor(h / (FG.length * BORDER.length)));91+ const canvas = document.createElement('canvas');92+ canvas.width = sizePx;93+ canvas.height = sizePx;94+ const ctx = canvas.getContext('2d');95+ if (!ctx) return;96+ a.drawRaw(ctx, col, row, 0, 0, sizePx, fg);97+ el.textContent = '';98+ el.append(canvas);99+ })100+ .catch(() => {101+ /* initials stay */102+ });103+104+ return { el, fg };105+}106+107+export { TILE };@@ -189,3 +189,17 @@ input[type='date'].date-input::-webkit-calendar-picker-indicator {
189189 opacity: 0.35;190190 filter: invert(0.75);191191 }192+193+/* Identicons: one Urizen tile + tint per script, hashed from its id */194+.identicon {195+ display: inline-flex; align-items: center; justify-content: center;196+ border: 1px solid var(--border-bright);197+ background: var(--bg-inset);198+ vertical-align: middle;199+ font-size: 0.62rem; letter-spacing: 0.04em; color: var(--text-dim);200+ flex: none;201+}202+.identicon canvas { display: block; image-rendering: pixelated; }203+td.title-cell .identicon { margin-right: 0.5em; }204+.detail-title { gap: 0.5em; }205+.proof-chip { min-width: 0; height: auto; padding: 0.15em; }@@ -1,6 +1,7 @@
11 /** Browse: public script gallery — ranked list + per-script proof detail. */22 import './browse.css';33 import { renderHighlighted } from '../highlight';4+import { identicon } from '../identicon';45 import type { BrowseWindow, ModelRecord, PublicScriptDetail, PublicScriptListing } from '@tribes/protocol';56 import { social, type BrowseQuery, type BrowseSort } from '../api-social';67 import { h, clear } from '../dom';@@ -247,6 +248,7 @@ function renderList(main: HTMLElement, ctx: AppCtx): void {
247248 h(248249 'td',249250 { class: 'title-cell' },251+ identicon(item.id, item.title).el,250252 h(251253 'a',252254 {@@ -355,7 +357,7 @@ function paintDetail(scroll: HTMLElement, ctx: AppCtx, d: PublicScriptDetail): v
355357 const head = h(356358 'div',357359 { class: 'card detail-card' },358- h('h2', null, d.title),360+ h('h2', { class: 'row detail-title' }, identicon(d.id, d.title, 30).el, d.title),359361 h(360362 'div',361363 { class: 'row detail-meta' },@@ -413,7 +415,7 @@ function paintDetail(scroll: HTMLElement, ctx: AppCtx, d: PublicScriptDetail): v
413415 title: `${ordinal(r.place)} - ${r.title} - ${r.score} pts`,414416 },415417 r.place === 1 ? h('span', { class: 'crown' }, '♛') : null,416- chipInitials(r.title),418+ identicon(r.scriptId, r.title).el,417419 ),418420 ),419421 ),@@ -19,6 +19,20 @@ const MARGIN = 1;
1919 const LUMA_KEY = 45;2020 2121 export interface UrizenAtlas {22+ /** Sheet grid size (includes ruler columns — see usableTile). */23+ grid: { cols: number; rows: number };24+ /** Fraction of opaque pixels in a tile, 0..1 (cached). */25+ inkRatio(col: number, row: number): number;26+ /** Draw a tile by raw grid position (identicons; no manifest involved). */27+ drawRaw(28+ ctx: CanvasRenderingContext2D,29+ col: number,30+ row: number,31+ x: number,32+ y: number,33+ size: number,34+ tint?: string,35+ ): void;2236 /**2337 * Draw manifest tile `name` with its top-left at (x, y), scaled to2438 * size x size px. Unknown names draw a magenta placeholder box and warn once.@@ -96,8 +110,38 @@ export async function loadUrizen(): Promise<UrizenAtlas> {
96110 return tile;97111 }98112 113+ const cols = Math.floor((sheet.width - MARGIN + (STRIDE - TILE)) / STRIDE);114+ const rows = Math.floor((sheet.height - MARGIN + (STRIDE - TILE)) / STRIDE);115+ const inkCache = new Map<number, number>();116+117+ function inkRatio(col: number, row: number): number {118+ const key = row * cols + col;119+ let ratio = inkCache.get(key);120+ if (ratio === undefined) {121+ const data = sctx.getImageData(MARGIN + STRIDE * col, MARGIN + STRIDE * row, TILE, TILE).data;122+ let opaque = 0;123+ for (let i = 3; i < data.length; i += 4) if (data[i]! > 0) opaque++;124+ ratio = opaque / (TILE * TILE);125+ inkCache.set(key, ratio);126+ }127+ return ratio;128+ }129+99130 return {100131 has: (name: string) => name in URIZEN,132+ grid: { cols, rows },133+ inkRatio,134+135+ drawRaw(ctx, col, row, x, y, size, tint) {136+ const prev = ctx.imageSmoothingEnabled;137+ ctx.imageSmoothingEnabled = false;138+ if (tint) {139+ ctx.drawImage(tintedTile(col, row, tint), 0, 0, TILE, TILE, x, y, size, size);140+ } else {141+ ctx.drawImage(sheet, MARGIN + STRIDE * col, MARGIN + STRIDE * row, TILE, TILE, x, y, size, size);142+ }143+ ctx.imageSmoothingEnabled = prev;144+ },101145 102146 draw(ctx, name, x, y, size, tint) {103147 const entry = URIZEN[name];@@ -25,6 +25,27 @@ export const ADJECTIVES = [
2525 'silver', 'sly', 'solemn', 'stark', 'steady', 'stern', 'still', 'stone',2626 'sunny', 'swift', 'tidal', 'true', 'umber', 'vivid', 'wandering', 'wary',2727 'weathered', 'wild', 'winter', 'zealous',28+ // second wave — the pool is part of the combinatorial budget: with the29+ // two-adjective escalation tier the name space passes a million per30+ // category long before numeric tags appear.31+ 'airy', 'arid', 'august', 'autumn', 'blazing', 'blue', 'boreal', 'briny',32+ 'bristling', 'burnished', 'cedar', 'certain', 'cinder', 'civil', 'cloudy',33+ 'coastal', 'cobalt', 'crested', 'crystal', 'daring', 'deft', 'dew', 'dim',34+ 'drifting', 'dun', 'dusty', 'earnest', 'eastern', 'echoing', 'elm', 'errant',35+ 'evening', 'famed', 'feral', 'fern', 'fleet', 'fog', 'fond', 'free', 'gale',36+ 'gaunt', 'glacial', 'gleaming', 'gray', 'green', 'gusty', 'hale', 'harvest',37+ 'hazel', 'heather', 'honest', 'howling', 'hushed', 'icy', 'inland', 'jolly',38+ 'laughing', 'leafy', 'limber', 'lithe', 'loyal', 'marbled', 'meadow',39+ 'midnight', 'mild', 'mirthful', 'molten', 'morning', 'mottled', 'murmuring',40+ 'narrow', 'obsidian', 'ochre', 'olive', 'onyx', 'opal', 'painted', 'peppered',41+ 'pewter', 'pine', 'plucky', 'polar', 'prudent', 'quartz', 'quick', 'raw',42+ 'ringing', 'roaming', 'roan', 'rowdy', 'ruddy', 'rugged', 'russet', 'rust',43+ 'scarlet', 'sharp', 'shining', 'singing', 'sleek', 'slate', 'smoky', 'snowy',44+ 'sooty', 'southern', 'sparing', 'speckled', 'spry', 'staunch', 'storied',45+ 'stormy', 'striped', 'sturdy', 'tawny', 'thorny', 'thundering', 'timid',46+ 'topaz', 'tranquil', 'twilight', 'upland', 'valiant', 'velvet', 'verdant',47+ 'violet', 'wakeful', 'walnut', 'wandering-star', 'warm', 'watchful', 'western',48+ 'whistling', 'willow', 'windy', 'wistful', 'wry',2849 ] as const;2950 3051 const USER_NOUNS = [@@ -36,6 +57,20 @@ const USER_NOUNS = [
3657 'puffin', 'quail', 'raven', 'robin', 'rook', 'sandpiper', 'shrike', 'siskin',3758 'skylark', 'snipe', 'sparrow', 'starling', 'stork', 'swallow', 'swan',3859 'swift-bird', 'tern', 'thrush', 'wagtail', 'warbler', 'wren',60+ 'auk', 'avocet', 'bee-eater', 'bellbird', 'bobolink', 'brant', 'bushtit',61+ 'canary', 'caracara', 'chickadee', 'chough', 'cockatoo', 'coot', 'corncrake',62+ 'courser', 'dipper', 'dotterel', 'drongo', 'eider', 'fieldfare', 'firecrest',63+ 'flamingo', 'fulmar', 'gadwall', 'garganey', 'godwit', 'goldeneye',64+ 'goldfinch', 'greenshank', 'guillemot', 'gyrfalcon', 'hawfinch', 'hobby',65+ 'jacana', 'junco', 'killdeer', 'kingbird', 'kingfisher', 'kittiwake', 'knot',66+ 'lapwing', 'loon', 'lorikeet', 'mallard', 'martin', 'moorhen', 'murrelet',67+ 'nuthatch', 'ouzel', 'ovenbird', 'parula', 'pelican', 'phalarope', 'phoebe',68+ 'pintail', 'pochard', 'ptarmigan', 'redpoll', 'redshank', 'redstart',69+ 'roller', 'ruff', 'sanderling', 'scaup', 'scoter', 'serin', 'shearwater',70+ 'shelduck', 'shoveler', 'skua', 'smew', 'solitaire', 'stint', 'stonechat',71+ 'sunbird', 'tanager', 'tattler', 'teal', 'titmouse', 'towhee', 'turnstone',72+ 'twite', 'veery', 'vireo', 'whimbrel', 'whinchat', 'wigeon', 'woodcock',73+ 'yellowlegs',3974 ] as const;4075 4176 const SCRIPT_NOUNS = [@@ -45,6 +80,18 @@ const SCRIPT_NOUNS = [
4580 'muskox', 'otter', 'pangolin', 'pika', 'polecat', 'porcupine', 'ram',4681 'reindeer', 'sable-beast', 'serval', 'shrew', 'stag', 'stoat', 'tapir',4782 'vole', 'weasel', 'wisent', 'wolf', 'wolverine', 'yak', 'zorilla',83+ 'addax', 'antelope', 'armadillo', 'axolotl', 'banteng', 'bettong', 'binturong',84+ 'buffalo', 'camel', 'capybara', 'chamois', 'cheetah', 'chital', 'colugo',85+ 'cougar', 'dhole', 'dingo', 'dormouse', 'dugong', 'echidna', 'eland',86+ 'fennec', 'fisher-cat', 'gaur', 'gazelle', 'gemsbok', 'genet', 'gerenuk',87+ 'gibbon', 'glider', 'gnu', 'gopher', 'grison', 'hamster', 'hartebeest',88+ 'hog', 'hyrax', 'impala', 'jaguar', 'jerboa', 'kinkajou', 'kudu', 'leopard',89+ 'liger', 'loris', 'manul', 'markhor', 'meerkat', 'mink', 'mongoose', 'mouse',90+ 'muntjac', 'narwhal', 'nilgai', 'numbat', 'nyala', 'ocelot', 'okapi',91+ 'onager', 'oryx', 'panther', 'peccary', 'pronghorn', 'quokka', 'quoll',92+ 'raccoon', 'ratel', 'saiga', 'seal', 'sifaka', 'sloth', 'souslik',93+ 'springbok', 'squirrel', 'takin', 'tamandua', 'tarsier', 'tayra', 'tiger',94+ 'urial', 'vicuna', 'wallaby', 'wapiti', 'wombat', 'zebu',4895 ] as const;4996 5097 const SIM_NOUNS = [@@ -55,6 +102,20 @@ const SIM_NOUNS = [
55102 'outcrop', 'plateau', 'prairie', 'ravine', 'reef', 'ridge', 'rise', 'savanna',56103 'shoal', 'steppe', 'summit', 'taiga', 'tarn', 'thicket', 'tundra', 'vale',57104 'wold',105+ 'arroyo', 'atoll', 'badlands', 'bank', 'bar', 'barranca', 'barrens', 'bight',106+ 'brae', 'brook', 'butte', 'cairn-field', 'caldera', 'cascade', 'cataract',107+ 'channel', 'chasm', 'cirque', 'cliff', 'col', 'combe', 'confluence', 'coomb',108+ 'coulee', 'creek', 'crest', 'crossing', 'defile', 'dell-hollow', 'divide',109+ 'downs', 'drift', 'escarpment', 'estuary', 'eyot', 'fell', 'firth', 'flats',110+ 'floodplain', 'fork', 'gap', 'gully', 'gulch', 'headland', 'headwater',111+ 'hillock', 'hinterland', 'inlet-shore', 'karst', 'kettle', 'key', 'kloof',112+ 'lea', 'ledge', 'levee', 'lough', 'marshland', 'meander', 'mere', 'mudflat',113+ 'narrows', 'notch', 'overlook', 'pass', 'peatland', 'peninsula', 'pinnacle',114+ 'point', 'polder', 'pool', 'promontory', 'quagmire', 'rapids', 'reach',115+ 'riverbend', 'sandbar', 'scarp', 'scree', 'seamount', 'shallows', 'shore',116+ 'sink', 'slough', 'sound', 'spring', 'spur', 'strand', 'strait', 'swale',117+ 'terrace', 'tideland', 'tor', 'trench', 'uplands', 'wash', 'waterfall',118+ 'weir', 'wetland',58119 ] as const;59120 60121 export type FriendlyKind = 'user' | 'script' | 'sim';@@ -78,9 +139,11 @@ function fnv(text: string): number {
78139 }79140 80141 /**81- * Deterministic `adjective-noun` for (kind, seed, attempt). attempt 0 gives82- * the bare name; attempt > 0 mixes the hash and appends `-<attempt+1>` so83- * retries stay readable (`brave-otter`, then `stern-wolf-2`, ...).142+ * Deterministic name for (kind, seed, attempt). attempt 0 gives the short143+ * `adjective-noun`; on collision the name ESCALATES with a second adjective144+ * (`sly-ashen-wolf`) — a two-word space of 2M+ per category — and only145+ * beyond eight two-word attempts does a numeric tag appear. Names lengthen146+ * gracefully long before they turn into serial numbers.84147 */85148 export function friendlyId(kind: FriendlyKind, seed: string, attempt = 0): string {86149 const nouns = NOUNS[kind];@@ -88,7 +151,12 @@ export function friendlyId(kind: FriendlyKind, seed: string, attempt = 0): strin
88151 const adjective = ADJECTIVES[h % ADJECTIVES.length]!;89152 const noun = nouns[Math.floor(h / ADJECTIVES.length) % nouns.length]!;90153 const base = `${adjective}-${noun}`;91- return attempt === 0 ? base : `${base}-${attempt + 1}`;154+ if (attempt === 0) return base;155+ const h2 = fnv(`${kind}:${seed}:extra:${attempt}`);156+ let extra = ADJECTIVES[h2 % ADJECTIVES.length]!;157+ if (extra === adjective) extra = ADJECTIVES[(h2 + 1) % ADJECTIVES.length]!;158+ const doubled = `${extra}-${base}`;159+ return attempt <= 8 ? doubled : `${doubled}-${attempt - 7}`;92160 }93161 94162 /** True when a D1/SQLite error is a UNIQUE-constraint violation. */@@ -33,7 +33,10 @@ describe('friendly ids', () => {
3333 const parts = base.split('-');3434 expect(ADJECTIVES).toContain(parts[0]!);3535 const retry = friendlyId('sim', 'seed-x', 3);36- expect(retry.endsWith('-4')).toBe(true);36+ expect(retry.split('-').length).toBeGreaterThanOrEqual(3); // second adjective, no number yet37+ expect(/\d$/.test(retry)).toBe(false);38+ const deep = friendlyId('sim', 'seed-x', 12);39+ expect(deep.endsWith('-5')).toBe(true); // numeric tags only past the two-word tier3740 });3841 3942 it('uses nouns from the right pool', () => {