import { useState, useEffect, useRef, useCallback, useMemo } from "react"; // ═══════════════════════════════════════════════════════════════════════════════ // SHA-256 — Pure JavaScript (FIPS 180-4) // ═══════════════════════════════════════════════════════════════════════════════ const sha256 = (() => { const K = [ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, ]; const rr = (v, n) => ((v >>> n) | (v << (32 - n))) >>> 0; return (msg) => { const bytes = new TextEncoder().encode(msg); const len = bytes.length; const padLen = (((len + 9) >>> 6) + 1) << 6; const buf = new ArrayBuffer(padLen); const dv = new DataView(buf); const u8 = new Uint8Array(buf); u8.set(bytes); u8[len] = 0x80; dv.setUint32(padLen - 4, len * 8, false); const H = [ 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19, ]; const W = new Array(64); for (let off = 0; off < padLen; off += 64) { for (let i = 0; i < 16; i++) W[i] = dv.getUint32(off + i * 4, false); for (let i = 16; i < 64; i++) { const s0 = rr(W[i - 15], 7) ^ rr(W[i - 15], 18) ^ (W[i - 15] >>> 3); const s1 = rr(W[i - 2], 17) ^ rr(W[i - 2], 19) ^ (W[i - 2] >>> 10); W[i] = (W[i - 16] + s0 + W[i - 7] + s1) >>> 0; } let a = H[0], b = H[1], c = H[2], d = H[3]; let e = H[4], f = H[5], g = H[6], hh = H[7]; for (let i = 0; i < 64; i++) { const S1 = rr(e, 6) ^ rr(e, 11) ^ rr(e, 25); const ch = (e & f) ^ (~e & g); const t1 = (hh + S1 + ch + K[i] + W[i]) >>> 0; const S0 = rr(a, 2) ^ rr(a, 13) ^ rr(a, 22); const maj = (a & b) ^ (a & c) ^ (b & c); const t2 = (S0 + maj) >>> 0; hh = g; g = f; f = e; e = (d + t1) >>> 0; d = c; c = b; b = a; a = (t1 + t2) >>> 0; } H[0] = (H[0] + a) >>> 0; H[1] = (H[1] + b) >>> 0; H[2] = (H[2] + c) >>> 0; H[3] = (H[3] + d) >>> 0; H[4] = (H[4] + e) >>> 0; H[5] = (H[5] + f) >>> 0; H[6] = (H[6] + g) >>> 0; H[7] = (H[7] + hh) >>> 0; } return H.map((v) => v.toString(16).padStart(8, "0")).join(""); }; })(); // ═══════════════════════════════════════════════════════════════════════════════ // Avatar generation — ported from Ruby AvatarHelper // ═══════════════════════════════════════════════════════════════════════════════ function generateAvatar(username) { if (!username || !username.trim()) return null; const hash = sha256(username.toLowerCase().trim()); const seed = BigInt("0x" + hash); const baseHue = Number(seed % 360n); const fgHue = (baseHue + 137) % 360; const saturation = 45 + Number((seed >> 8n) % 25n); const bgColor = `hsl(${baseHue}, ${saturation}%, 35%)`; const fgColor = `hsl(${fgHue}, ${saturation}%, 75%)`; const rawBits = Number((seed >> 32n) & 0x7fffn); const cells = []; let bits = rawBits; for (let row = 0; row < 5; row++) { for (let col = 0; col < 3; col++) { if (bits & 1) { cells.push([col, row]); if (col < 2) cells.push([4 - col, row]); } bits >>= 1; } } return { hash, baseHue, fgHue, saturation, bgColor, fgColor, rawBits, cells }; } function cellsFromBits(rawBits) { const cells = []; let bits = rawBits; for (let row = 0; row < 5; row++) { for (let col = 0; col < 3; col++) { if (bits & 1) { cells.push([col, row]); if (col < 2) cells.push([4 - col, row]); } bits >>= 1; } } return cells; } // ═══════════════════════════════════════════════════════════════════════════════ // Shared components // ═══════════════════════════════════════════════════════════════════════════════ const COLORS = { bg: "#0e0f14", surface: "#151720", elevated: "#1b1e2a", border: "rgba(255,255,255,0.06)", borderBright: "rgba(255,255,255,0.12)", text: "#e8e2d6", heading: "#f4f0ea", muted: "#8a8478", code: "#c4b99a", amber: "#e8a44a", coral: "#e06878", teal: "#58b4a0", purple: "#a78bfa", }; function AvatarSVG({ bgColor, fgColor, cells, size = 100, round = true }) { return ( {cells.map(([x, y], i) => ( ))} ); } function Prose({ children, style }) { return (

{children}

); } function Code({ children }) { return ( {children} ); } function Panel({ children, style }) { return (
{children}
); } function Divider() { return (
); } function SectionLabel({ children }) { return (
{children}
); } function useFadeIn(threshold = 0.15) { const ref = useRef(null); const [visible, setVisible] = useState(false); useEffect(() => { const el = ref.current; if (!el) return; const obs = new IntersectionObserver( ([e]) => { if (e.isIntersecting) { setVisible(true); obs.disconnect(); } }, { threshold } ); obs.observe(el); return () => obs.disconnect(); }, [threshold]); return { ref, visible }; } function Section({ label, title, children }) { const { ref, visible } = useFadeIn(); return (
{label && {label}} {title && (

{title}

)} {children}
); } function SliderControl({ label, value, min, max, step = 1, onChange, suffix = "", highlight }) { return (
{label} {value}{suffix}
onChange(Number(e.target.value))} style={{ width: "100%" }} />
); } function TextInput({ value, onChange, placeholder, style: extraStyle }) { return ( onChange(e.target.value)} placeholder={placeholder} spellCheck={false} autoComplete="off" style={{ background: COLORS.surface, border: `1px solid ${COLORS.border}`, borderRadius: 12, padding: "12px 18px", fontSize: 18, fontFamily: "'Fira Code', monospace", color: COLORS.heading, outline: "none", width: "100%", boxSizing: "border-box", transition: "border-color 0.2s", ...extraStyle, }} onFocus={(e) => (e.target.style.borderColor = COLORS.amber)} onBlur={(e) => (e.target.style.borderColor = COLORS.border)} /> ); } // ═══════════════════════════════════════════════════════════════════════════════ // Hero // ═══════════════════════════════════════════════════════════════════════════════ const GALLERY_NAMES = [ "alice", "bob", "carol", "dave", "eve", "frank", "grace", "heidi", "ivan", "judy", "karl", "luna", "mia", "nash", "oscar", "piper", "quinn", "ruby", "sam", "tara", "uma", "vex", "wade", "xena", ]; function Hero() { const [name, setName] = useState("alice"); const avatar = useMemo(() => generateAvatar(name), [name]); const bgGlow = avatar ? `radial-gradient(ellipse at 50% 40%, ${avatar.bgColor}18 0%, transparent 60%)` : "none"; return (

The Avatar Machine

An explorable explanation of turning usernames into unique visual identities

{avatar && (
)} {avatar && (
)}
{GALLERY_NAMES.slice(0, 16).map((n) => { const av = generateAvatar(n); return av ? ( ) : null; })}
scroll to explore how it works ↓
); } // ═══════════════════════════════════════════════════════════════════════════════ // Section 1 — Hashing: From Name to Number // ═══════════════════════════════════════════════════════════════════════════════ const HEX_COLORS = [ "#4a6fa5", "#5b82b6", "#6c95c7", "#7da8d8", // 0-3: blues "#4aa58a", "#5bb69b", "#6cc7ac", "#7dd8bd", // 4-7: teals "#a5894a", "#b69a5b", "#c7ab6c", "#d8bc7d", // 8-b: golds "#a54a6f", "#b65b80", "#c76c91", "#d87da2", // c-f: roses ]; function HashDisplay({ hash, label }) { return (
{label && (
{label}
)}
{hash.split("").map((char, i) => ( {char} ))}
); } function HashSection() { const [nameA, setNameA] = useState("alice"); const [nameB, setNameB] = useState("alice1"); const avatarA = useMemo(() => generateAvatar(nameA), [nameA]); const avatarB = useMemo(() => generateAvatar(nameB), [nameB]); const diffCount = useMemo(() => { if (!avatarA || !avatarB) return 0; return avatarA.hash.split("").filter((c, i) => c !== avatarB.hash[i]).length; }, [avatarA, avatarB]); return (
Every avatar starts with a simple requirement: given a username, produce something visual that's always the same for that name, but different from every other name. We need a deterministic fingerprint. That's what a hash function gives us. Feed in a name, get back an enormous number — 64 hexadecimal characters worth. Always the same number for the same name.
{avatarA && }
But here's the magical part. Change even a single character and the{" "} entire output transforms. This property — where tiny input changes cause dramatic output changes — is called the avalanche effect.
{avatarA && }
{avatarB && }
{diffCount} of 64 characters changed — {Math.round((diffCount / 64) * 100)}% different
It doesn't matter how similar the inputs are. The outputs are{" "} always wildly different. This is what makes each avatar genuinely unique — even{" "}alice and alice1 get completely different visual fingerprints.
); } // ═══════════════════════════════════════════════════════════════════════════════ // Section 2 — Colors & the Golden Angle // ═══════════════════════════════════════════════════════════════════════════════ function ColorWheel({ baseHue, points, size = 220, interactive, onHueChange }) { const svgRef = useRef(null); const r = size / 2; const dotDist = r * 0.72; const handleWheel = useCallback( (e) => { if (!interactive || !onHueChange) return; const svg = svgRef.current; if (!svg) return; const rect = svg.getBoundingClientRect(); const cx = rect.left + rect.width / 2; const cy = rect.top + rect.height / 2; const dx = e.clientX - cx; const dy = e.clientY - cy; let angle = (Math.atan2(dy, dx) * 180) / Math.PI + 90; if (angle < 0) angle += 360; onHueChange(Math.round(angle) % 360); }, [interactive, onHueChange] ); // Build conic gradient stops for the wheel ring const ringSegments = []; for (let i = 0; i <= 360; i += 5) { ringSegments.push( ); } return ( handleWheel(e)} onMouseMove={(e) => { if (e.buttons === 1) handleWheel(e); }} > {/* Dark center */} {/* Color ring */} {ringSegments} {/* Inner edge */} {/* Angle arcs between points */} {points.length === 2 && ( <> {/* Arc showing the angle */} { const arcR = r * 0.35; const startRad = ((points[0].hue - 90) * Math.PI) / 180; const endRad = ((points[1].hue - 90) * Math.PI) / 180; const sx = r + arcR * Math.cos(startRad); const sy = r + arcR * Math.sin(startRad); const ex = r + arcR * Math.cos(endRad); const ey = r + arcR * Math.sin(endRad); const angleDiff = ((points[1].hue - points[0].hue + 360) % 360); const largeArc = angleDiff > 180 ? 1 : 0; return `M ${sx} ${sy} A ${arcR} ${arcR} 0 ${largeArc} 1 ${ex} ${ey}`; })()} fill="none" stroke={COLORS.amber} strokeWidth={1.5} opacity={0.5} strokeDasharray="4 3" /> )} {/* Connecting lines from center */} {points.map((p, i) => { const rad = ((p.hue - 90) * Math.PI) / 180; const px = r + dotDist * Math.cos(rad); const py = r + dotDist * Math.sin(rad); return ( ); })} {/* Dots */} {points.map((p, i) => { const rad = ((p.hue - 90) * Math.PI) / 180; const px = r + dotDist * Math.cos(rad); const py = r + dotDist * Math.sin(rad); return ( {points.length <= 3 && ( {Math.round(p.hue)}° )} ); })} ); } function AngleComparisonWheels({ baseHue }) { const angles = [90, 120, 137]; const n = 12; return (
{angles.map((angle) => { const pts = Array.from({ length: n }, (_, i) => ({ hue: (baseHue + i * angle) % 360, })); const uniqueHues = new Set(pts.map((p) => Math.round(p.hue))).size; return (
{angle}°
{uniqueHues} unique hues
); })}
); } function SunflowerPattern({ angle, count, size = 260 }) { const cx = size / 2; const cy = size / 2; const maxR = size / 2 - 12; const scale = maxR / Math.sqrt(count); return ( {Array.from({ length: count }, (_, i) => { const theta = (i * angle * Math.PI) / 180; const r = Math.sqrt(i) * scale; const x = cx + r * Math.cos(theta); const y = cy + r * Math.sin(theta); const hue = (i * angle) % 360; return ( ); })} ); } function ColorSection() { const [angle, setAngle] = useState(137); const [numPoints, setNumPoints] = useState(8); const [baseHue, setBaseHue] = useState(42); const [sunflowerAngle, setSunflowerAngle] = useState(137); const [sunflowerCount, setSunflowerCount] = useState(120); const wheelPoints = useMemo(() => { return Array.from({ length: numPoints }, (_, i) => ({ hue: (baseHue + i * angle) % 360, })); }, [baseHue, angle, numPoints]); return (
Our hash gives us a huge number. Now we need to extract{" "} colors from it. The trick is to work in{" "} HSL — hue, saturation, lightness — where hue is simply an angle on a color wheel. We take our number modulo 360 to get a base hue. But we need two{" "} colors — a background and a foreground. How do we pick the second one? We could pick an arbitrary offset. But there's an elegant answer from number theory: the golden angle, approximately 137.5° (we use 137° as an integer approximation).
Click or drag on the wheel to change the starting hue
{angle === 137 && (
The golden angle — maximum spread for any number of colors
)}
{wheelPoints.map((p, i) => (
))}
Try it — drag the angle slider away from 137° and watch the colors cluster. At 90°, four positions repeat. At 120°, three. But at 137°, each new color lands in the largest remaining gap. No matter how many colors you add, they stay evenly distributed.
The same angle with 12 points — which distributes best?
This isn't just math for math's sake. Nature figured it out first. Sunflower seeds, pinecone spirals, leaf arrangements — they all use the golden angle to pack efficiently. Each new seed rotates by ~137.5° from the last, filling space as uniformly as possible.
A sunflower spiral — each dot rotated by the chosen angle from the last
{[90, 120, 137, 150].map((a) => ( ))}
For avatars, the result is simple but powerful: whatever base hue our hash gives us, offsetting by 137° always produces a natural complement. Two colors that contrast{" "} without clashing.
Color pairs for different usernames
{["alice", "bob", "carol", "dave", "eve", "frank", "grace", "heidi"].map((name) => { const av = generateAvatar(name); if (!av) return null; return (
{name}
); })}
); } // ═══════════════════════════════════════════════════════════════════════════════ // Section 3 — Grid, Bits & Symmetry // ═══════════════════════════════════════════════════════════════════════════════ function GridPreview({ cells, bgColor, fgColor, size = 180 }) { return ( {cells.map(([x, y], i) => ( ))} {/* Grid guides */} {[0, 1, 2, 3, 4].map((x) => [0, 1, 2, 3, 4].map((y) => ( )) )} ); } function BitToggle({ index, active, isMirror, onClick }) { return ( ); } function SymmetryDemo() { const [seed, setSeed] = useState(42); const randomGrid = useMemo(() => { // 25 random cells from seed let s = seed; const next = () => { s = (s * 1103515245 + 12345) & 0x7fffffff; return s; }; return Array.from({ length: 25 }, () => next() % 3 === 0); }, [seed]); const symmetricGrid = useMemo(() => { let s = seed; const next = () => { s = (s * 1103515245 + 12345) & 0x7fffffff; return s; }; const grid = Array(25).fill(false); for (let row = 0; row < 5; row++) { for (let col = 0; col < 3; col++) { if (next() % 3 === 0) { grid[row * 5 + col] = true; if (col < 2) grid[row * 5 + (4 - col)] = true; } } } return grid; }, [seed]); const renderGrid = (cells) => ( {cells.map((on, i) => { const x = i % 5; const y = Math.floor(i / 5); return on ? ( ) : null; })} ); return (
Random
{renderGrid(randomGrid)}
vs
Symmetric
{renderGrid(symmetricGrid)}
); } function GridSection() { const [bits, setBits] = useState( () => Array.from({ length: 15 }, (_, i) => Boolean((0b101_011_110_010_100 >> i) & 1)) ); const toggleBit = useCallback((index) => { setBits((prev) => prev.map((b, i) => (i === index ? !b : b))); }, []); const rawBitsNum = useMemo( () => bits.reduce((acc, b, i) => acc | (b ? 1 << i : 0), 0), [bits] ); const cells = useMemo(() => cellsFromBits(rawBitsNum), [rawBitsNum]); // Build 5×5 display grid showing which cells are "original" vs "mirror" const displayGrid = useMemo(() => { const grid = Array.from({ length: 25 }, (_, i) => { const col = i % 5; const row = Math.floor(i / 5); const isCenter = col === 2; const isLeft = col < 2; const isRight = col > 2; const origCol = isRight ? 4 - col : col; const bitIndex = row * 3 + origCol; const isOn = bits[bitIndex]; return { col, row, isOn, isMirror: isRight && !isCenter, bitIndex }; }); return grid; }, [bits]); return (
Colors give us mood. Now we need shape — a pattern that feels distinctive and almost alive. We generate a 5×5 grid where each cell is either on or off. But here's the key insight: the grid is symmetric. The left side mirrors the right. Why does symmetry matter? Our brains are deeply wired to see faces in symmetric arrangements — a phenomenon called pareidolia. It's why you see faces in electrical outlets and car fronts. Symmetric patterns immediately feel more intentional, more identity-like. To build our grid, we extract 15 bits from the hash. These control the left half and center column of our 5×5 grid — that's 3 columns × 5 rows = 15 cells. The right 2 columns are automatically mirrored from the left. Toggle the bits below to see how each one controls the pattern. The faded cells on the right are mirrors — they follow their counterpart on the left automatically.
{/* 5×5 toggle grid */}
{displayGrid.map((cell, i) => ( toggleBit(cell.bitIndex)} /> ))}
← original | mirror →
{/* Arrow */}
{/* Result grid */}
The binary representation of these 15 bits — {rawBitsNum.toString(2).padStart(15, "0")}{" "} — is the pattern's DNA. Two different hashes will almost certainly produce different bits, and therefore different shapes.
); } // ═══════════════════════════════════════════════════════════════════════════════ // Section 4 — Putting It All Together // ═══════════════════════════════════════════════════════════════════════════════ function PipelineStep({ number, label, children, active }) { return (
{number}
{label}
{active && children}
); } function PlaygroundSection() { const [username, setUsername] = useState("yourname"); const [step, setStep] = useState(5); const avatar = useMemo(() => generateAvatar(username), [username]); useEffect(() => { setStep(5); }, [username]); const downloadSVG = useCallback(() => { if (!avatar) return; const rects = avatar.cells .map( ([x, y]) => `` ) .join(""); const svg = ` ${rects} `; try { const blob = new Blob([svg], { type: "image/svg+xml" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `${username.toLowerCase().trim()}-avatar.svg`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } catch { // Sandbox may block downloads — show the SVG source instead const w = window.open("", "_blank"); if (w) { w.document.write( `
${svg.replace(/`
        );
      }
    }
  }, [avatar, username]);

  const downloadPNG = useCallback(() => {
    if (!avatar) return;
    const rects = avatar.cells
      .map(
        ([x, y]) =>
          ``
      )
      .join("");
    const svg = `
  
  
    
    ${rects}
  
`;
    try {
      const img = new Image();
      const blob = new Blob([svg], { type: "image/svg+xml" });
      const url = URL.createObjectURL(blob);
      img.onload = () => {
        const canvas = document.createElement("canvas");
        canvas.width = 400;
        canvas.height = 400;
        const ctx = canvas.getContext("2d");
        ctx.drawImage(img, 0, 0);
        canvas.toBlob((pngBlob) => {
          const pngUrl = URL.createObjectURL(pngBlob);
          const a = document.createElement("a");
          a.href = pngUrl;
          a.download = `${username.toLowerCase().trim()}-avatar.png`;
          document.body.appendChild(a);
          a.click();
          document.body.removeChild(a);
          URL.revokeObjectURL(pngUrl);
        }, "image/png");
        URL.revokeObjectURL(url);
      };
      img.src = url;
    } catch {
      // fallback — ignore
    }
  }, [avatar, username]);

  return (
    
Let's watch the complete pipeline. Type a username and see each stage build the avatar step by step. {avatar && (
{/* Steps */}
= 1}>
{avatar.hash}
= 2}>
{avatar.baseHue}° hue, {avatar.saturation}% sat
= 3}>
bg: {avatar.baseHue}° → fg: {avatar.fgHue}°
= 4}> {avatar.rawBits.toString(2).padStart(15, "0")} = 5}> {avatar.cells.length} cells active
{/* Result */}
)} {avatar && (
{[1, 2, 3, 4, 5].map((s) => (
)} And here's the beauty of it: because every step is deterministic, the same username will always produce the same avatar. No storage needed, no random seed to save — just a pure function from name to face.
A gallery of generated avatars — every one unique, every one reproducible
{GALLERY_NAMES.map((name) => { const av = generateAvatar(name); return av ? (
{name}
) : null; })}
); } // ═══════════════════════════════════════════════════════════════════════════════ // Footer // ═══════════════════════════════════════════════════════════════════════════════ function Footer() { return (

Built for{" "} artifact.land

Avatar algorithm by{" "} AvatarHelper — SHA-256 → golden angle colors → symmetric 5×5 grid

); } // ═══════════════════════════════════════════════════════════════════════════════ // Global Styles // ═══════════════════════════════════════════════════════════════════════════════ const GLOBAL_CSS = ` * { box-sizing: border-box; margin: 0; padding: 0; } body { background: ${COLORS.bg}; color: ${COLORS.text}; font-family: 'Inter', sans-serif; -webkit-font-smoothing: antialiased; overflow-x: hidden; } ::selection { background: ${COLORS.amber}40; color: ${COLORS.heading}; } input[type="range"] { -webkit-appearance: none; appearance: none; background: transparent; cursor: pointer; height: 24px; } input[type="range"]::-webkit-slider-runnable-track { height: 4px; background: rgba(255,255,255,0.08); border-radius: 2px; } input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; width: 20px; height: 20px; border-radius: 50%; background: ${COLORS.amber}; margin-top: -8px; box-shadow: 0 0 10px ${COLORS.amber}40; transition: box-shadow 0.2s; } input[type="range"]::-webkit-slider-thumb:hover { box-shadow: 0 0 16px ${COLORS.amber}60; } input[type="range"]::-moz-range-track { height: 4px; background: rgba(255,255,255,0.08); border-radius: 2px; border: none; } input[type="range"]::-moz-range-thumb { width: 20px; height: 20px; border-radius: 50%; background: ${COLORS.amber}; border: none; box-shadow: 0 0 10px ${COLORS.amber}40; } button:hover { filter: brightness(1.1); } em { color: ${COLORS.heading}; font-style: italic; } strong { color: ${COLORS.heading}; font-weight: 600; } @keyframes gentle-bounce { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(6px); } } @media (max-width: 640px) { section { padding-left: 16px !important; padding-right: 16px !important; } } `; // ═══════════════════════════════════════════════════════════════════════════════ // App // ═══════════════════════════════════════════════════════════════════════════════ export default function App() { useEffect(() => { // Inject fonts const link = document.createElement("link"); link.rel = "stylesheet"; link.href = "https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,600;9..144,700&family=Inter:wght@400;500;600&family=Fira+Code:wght@400;500&display=swap"; document.head.appendChild(link); // Inject global styles const style = document.createElement("style"); style.textContent = GLOBAL_CSS; document.head.appendChild(style); return () => { document.head.removeChild(link); document.head.removeChild(style); }; }, []); return (
); }