diff --git a/app.js b/app.js
new file mode 100644
index 0000000..b356f84
--- /dev/null
+++ b/app.js
@@ -0,0 +1,206 @@
+const FONT_CSS = "https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;700";
+const BASE_DPI = 300;
+
+const ART = {
+ front: { w: 1200, h: 340 },
+ back: { w: 3300, h: 2100 }
+};
+
+// [x, y, r, colorIndex] — colorIndex 0 indigo, 1 cyan, 2 core, 3 anomaly ring
+const NODES = [
+ [430, 380, 30, 0], [1080, 210, 24, 1], [1760, 330, 30, 0], [2480, 250, 26, 3],
+ [2890, 720, 30, 0], [2200, 900, 36, 1], [1500, 760, 46, 2], [830, 940, 30, 1],
+ [400, 1180, 24, 0], [1980, 1300, 30, 0], [1180, 1320, 26, 1], [2760, 1230, 22, 1]
+];
+
+const EDGES = [
+ [0, 1], [1, 2], [2, 3], [3, 4], [4, 5], [5, 6], [6, 7], [7, 8], [8, 0],
+ [1, 6], [2, 6], [6, 9], [9, 5], [7, 10], [10, 6], [10, 9], [4, 11], [11, 9], [0, 7]
+];
+
+const PALETTE = { indigo: "#4f46e5", cyan: "#0ea5e9" };
+
+const el = (id) => document.getElementById(id);
+const status = el("status");
+
+const setStatus = (msg, cls = "") => {
+ status.textContent = msg;
+ status.className = `status ${cls}`;
+};
+
+const b64 = (buf) => {
+ const bytes = new Uint8Array(buf);
+ let bin = "";
+ for (let i = 0; i < bytes.length; i += 0x8000) bin += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
+ return btoa(bin);
+};
+
+let fontFacePromise = null;
+
+const loadEmbeddedFont = () => {
+ fontFacePromise ??= (async () => {
+ const css = await fetch(FONT_CSS).then((r) => r.text());
+ const blocks = css.split("@font-face").filter((b) => b.includes("U+0000-00FF"));
+ const faces = await Promise.all(
+ blocks.map(async (block) => {
+ const weight = block.match(/font-weight:\s*(\d+)/)?.[1] || "400";
+ const url = block.match(/url\((https:\/\/[^)]+\.woff2)\)/)?.[1];
+ if (!url) return "";
+ const data = b64(await fetch(url).then((r) => r.arrayBuffer()));
+ return `@font-face{font-family:'Space Grotesk';font-style:normal;font-weight:${weight};src:url(data:font/woff2;base64,${data}) format('woff2');}`;
+ })
+ );
+ return faces.join("");
+ })().catch(() => "");
+ return fontFacePromise;
+};
+
+const theme = (variant) =>
+ variant === "dark"
+ ? { ink: "#ffffff", edge: "#ffffff", edgeOpacity: ".30", word: "#ffffff", sub: "#8ab4f8" }
+ : { ink: "#15151b", edge: "#15151b", edgeOpacity: ".32", word: "#15151b", sub: "#4f46e5" };
+
+const defs = (fontCss) => `
+
+
+
+
+
+
+ `;
+
+const logoMark = (t) => `
+
+
+
+
+
+
+
+
+ `;
+
+const constellation = (t) => {
+ const lines = EDGES.map(([a, b]) => {
+ const [x1, y1] = NODES[a];
+ const [x2, y2] = NODES[b];
+ return ``;
+ }).join("");
+
+ const dots = NODES.map(([x, y, r, c]) => {
+ if (c === 3) return ``;
+ if (c === 2) return ``;
+ return ``;
+ }).join("");
+
+ return `${lines}${dots}`;
+};
+
+const wrap = (w, h, body, fontCss, guides) => ``;
+
+const buildFront = ({ variant, fontCss, guides }) => {
+ const t = theme(variant);
+ const { w, h } = ART.front;
+ const body = `
+ ${logoMark(t)}
+ apophenia.news`;
+ return wrap(w, h, body, fontCss, guides);
+};
+
+const buildBack = ({ variant, fontCss, guides }) => {
+ const t = theme(variant);
+ const { w, h } = ART.back;
+ const body = `
+ ${constellation(t)}
+ finding patterns in the noise
+ apophenia.news`;
+ return wrap(w, h, body, fontCss, guides);
+};
+
+const BUILDERS = { front: buildFront, back: buildBack };
+
+const state = () => ({
+ variant: el("variant").value,
+ guides: el("guides").checked,
+ dpi: +el("dpi").value
+});
+
+const render = () => {
+ const { variant, guides } = state();
+ for (const key of Object.keys(BUILDERS)) {
+ const stage = el(`stage-${key}`);
+ stage.dataset.bg = variant;
+ stage.innerHTML = BUILDERS[key]({ variant, fontCss: "", guides });
+ }
+};
+
+const svgToBlob = (svg) => new Blob([svg], { type: "image/svg+xml;charset=utf-8" });
+
+const rasterize = async (svg, w, h, scale) => {
+ const url = URL.createObjectURL(svgToBlob(svg));
+ try {
+ const img = new Image();
+ img.decoding = "sync";
+ await new Promise((resolve, reject) => {
+ img.onload = resolve;
+ img.onerror = () => reject(new Error("SVG rasterization failed"));
+ img.src = url;
+ });
+ const canvas = document.createElement("canvas");
+ canvas.width = Math.round(w * scale);
+ canvas.height = Math.round(h * scale);
+ const ctx = canvas.getContext("2d");
+ ctx.imageSmoothingQuality = "high";
+ ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
+ return await new Promise((resolve, reject) =>
+ canvas.toBlob((b) => (b ? resolve(b) : reject(new Error("Canvas export failed"))), "image/png")
+ );
+ } finally {
+ URL.revokeObjectURL(url);
+ }
+};
+
+const saveBlob = (blob, name) => {
+ const url = URL.createObjectURL(blob);
+ const a = Object.assign(document.createElement("a"), { href: url, download: name });
+ document.body.append(a);
+ a.click();
+ a.remove();
+ setTimeout(() => URL.revokeObjectURL(url), 2000);
+};
+
+const download = async (btn) => {
+ const { dl: key, fmt } = btn.dataset;
+ const { variant, dpi } = state();
+ const { w, h } = ART[key];
+ btn.disabled = true;
+ setStatus(`Building ${key} ${fmt.toUpperCase()}\u2026`);
+ try {
+ const fontCss = await loadEmbeddedFont();
+ if (!fontCss) setStatus("Font embed unavailable \u2014 text may fall back. Check connection.", "err");
+ const svg = BUILDERS[key]({ variant, fontCss, guides: false });
+ const stem = `apophenia-${key}-${variant}`;
+ if (fmt === "svg") saveBlob(svgToBlob(svg), `${stem}.svg`);
+ else saveBlob(await rasterize(svg, w, h, dpi / BASE_DPI), `${stem}-${dpi}dpi.png`);
+ if (fontCss) setStatus(`Saved ${stem}.${fmt}`, "ok");
+ } catch (err) {
+ setStatus(err.message || "Export failed. Try a lower DPI.", "err");
+ } finally {
+ btn.disabled = false;
+ }
+};
+
+document.querySelectorAll("[data-dl]").forEach((btn) => btn.addEventListener("click", () => download(btn)));
+["variant", "guides", "dpi"].forEach((id) => el(id).addEventListener("change", render));
+
+render();
+loadEmbeddedFont().then((css) =>
+ setStatus(css ? "Fonts embedded. Ready to export." : "Font embed failed \u2014 exports may use a fallback face.", css ? "ok" : "err")
+);