sirius/js/studio.js
Silent Mode 90416eb87e feat(studio): a name can hold a site, not just a front page
Everything a site needs past the first screen — an about page, a blog, a
contact page — had to live on that one page or not exist. The folder on Sia
could always hold more; the studio could only ever edit index.html.

Pages are now a list. Each one is a folder with its own index.html, so it
answers to the address a visitor would guess (/about/), and the sidebar
shows that address rather than the file behind it. The title is editable and
the address is not: a published URL that moves is a link somebody else wrote
down and can no longer follow.

Draft and Publish both walk the whole list. Pages that link to each other
are only coherent if they go up together, so there is one button and it
sends everything, writes a sitemap when there is more than one page, and
sweeps deleted pages off Sia in the same pass instead of leaving them
serving. A site saved before this opens as a single home page and gains the
rest when asked.

Two things that would otherwise be quietly wrong: assets exported from a
sub-folder page now carry the ../ that makes them resolve, and loading a
page into the editor no longer registers as an edit.
2026-09-21 01:19:40 +02:00

1501 lines
104 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Sirius Studio — a site builder for BCNR names.
//
// GrapesJS (BSD-3, vendored under ./vendor/grapesjs/) edits the page. On
// Publish the exported HTML+CSS becomes index.html in the name's own folder
// on Sia — bns/<name>/ — uploaded through the gateway's /api/site route,
// every request signed with the wallet key that holds the name's NFT. The
// editor's project data is saved next to it (_studio.json) so the site can
// be reopened and edited later. If the name's on-chain s3 record does not
// point at that folder yet, Publish also sends one UPD that sets it.
//
// The gateway never holds a key: it verifies each upload's signature
// against the current NFT owner and refuses writes outside bns/<name>/.
import * as BNS from "https://silentmode.st/js/bns-register.js?v=20260920usd";
import { initAssistant } from "./studio-ai.js?v=20260920ai";
const API = "https://silentmode.st";
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
const name = (new URLSearchParams(location.search).get("name") || "").toLowerCase().trim();
const folder = name ? `bns/${name}/` : null;
const readUrl = (path) => `${API}/api/site/${encodeURIComponent(name)}/${path}`;
const siteUrl = () => `${API}/bns/${encodeURIComponent(name)}/`;
let wallet = null;
let entry = null; // { records, category, ... } from /api/name
let editor = null;
let pages = []; // [{ path, title, project, importedCss, extraHeadLinks, importedHtmlAttrs, dirty }]
let current = 0; // index in pages of the page on the canvas
let removed = []; // paths deleted since the last publish — swept off Sia on the next one
let loading = false; // true while a page is being poured into the editor, so it does not count as an edit
let dirty = false; // the page on the canvas has unsaved edits (mirrors pages[current].dirty)
function status(text, cls = "") { const el = $("status"); el.textContent = text; el.className = "st " + cls; }
// ---------- session ----------
async function restoreWallet() {
if (window.siriusWallet) return window.siriusWallet;
const S = window.siriusSession;
if (!S?.restore) return null;
try {
const s = await S.restore();
if (!s?.mnemonic) return null;
return await BNS.BuiltInWallet.fromMnemonic(s.mnemonic, BNS.CHIPNET_PREFIX, s.accountPath || undefined);
} catch { return null; }
}
// ---------- signed uploads ----------
const hex = (buf) => [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
async function signedHeaders(path, bodyBytes) {
const ts = String(Date.now());
const bodyHash = hex(await crypto.subtle.digest("SHA-256", bodyBytes));
const msg = `BNS-SITE1\n${name}\n${path}\n${bodyHash}\n${ts}`;
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(msg)));
const sig = wallet.signMessage(digest);
return { "x-bns-ts": ts, "x-bns-sig": btoa(String.fromCharCode(...sig)) };
}
async function putFile(path, body, contentType) {
const bytes = body instanceof Uint8Array ? body : new Uint8Array(await new Blob([body]).arrayBuffer());
const headers = { "content-type": contentType, ...(await signedHeaders(path, bytes)) };
const r = await fetch(readUrl(path), { method: "PUT", headers, body: bytes });
const j = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(`${path}: ${j.error || r.status}`);
return j;
}
async function delFile(path) {
const headers = await signedHeaders(path, new Uint8Array(0)); // the gateway signs DELETE over an empty body
const r = await fetch(readUrl(path), { method: "DELETE", headers });
const j = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(`${path}: ${j.error || r.status}`);
return j;
}
async function listFiles(live = false) {
const r = await fetch(`${API}/api/site/${encodeURIComponent(name)}${live ? "?src=live" : ""}`, { cache: "no-store" });
if (!r.ok) return { files: [], prefix: null, source: null };
const j = await r.json().catch(() => ({}));
return { files: j.files || [], prefix: j.prefix ?? null, source: j.source ?? null };
}
// ---------- import the page the name serves right now ----------
// Sites are hosted three ways: the Studio folder bns/<name>/, a CLI-published
// bucket the `s3` record points at (bns/<label>/ by convention), or inline
// HTML in the `h` record. The editor must start from whichever one is live,
// not from an empty Studio folder. Relative URLs are made absolute against
// the public site URL so the canvas shows the real images and links, and
// stylesheets are inlined so the imported page keeps its look.
let extraHeadLinks = []; // cross-origin stylesheets we could not inline (fonts CDNs)
// Rules the editor's CSS parser drops or cannot target — :root variables,
// html/body background and fonts, @import. Kept verbatim: injected into the
// canvas so the page looks like the live one, saved with the draft, and
// emitted ahead of the editor's CSS on export.
let importedCss = "";
// Attributes of the source page's <html> (lang, data-theme, class): themed
// sites key their variables on them, so they go on the canvas root and on
// the exported page.
let importedHtmlAttrs = {};
function splitRootCss(css) {
const root = [], rest = [];
// Whole selectors that only address the document root — including themed
// variants such as html[data-theme="dark"] or :root:not([data-theme="light"])
// — but never descendants (body .hero stays with the editor).
const isRootSel = (sel) => /^(:root|html|body)[^\s,>+~]*(\s*,\s*(:root|html|body)[^\s,>+~]*)*$/i.test(sel.trim());
let sheet;
try { sheet = new CSSStyleSheet(); sheet.replaceSync(css); } catch { return { root: css, rest: "" }; }
const walk = (rules, into) => {
for (const r of rules) {
if (r.type === CSSRule.STYLE_RULE) (isRootSel(r.selectorText) ? into.root : into.rest).push(r.cssText);
else if (r.type === CSSRule.MEDIA_RULE) {
const sub = { root: [], rest: [] }; walk(r.cssRules, sub);
if (sub.root.length) into.root.push(`@media ${r.conditionText}{${sub.root.join("\n")}}`);
if (sub.rest.length) into.rest.push(`@media ${r.conditionText}{${sub.rest.join("\n")}}`);
} else if (r.type === CSSRule.IMPORT_RULE) into.root.push(r.cssText);
else into.rest.push(r.cssText);
}
};
walk(sheet.cssRules, { root, rest });
return { root: root.join("\n"), rest: rest.join("\n") };
}
function injectImportedCss() {
try {
const doc = editor.Canvas.getDocument(); if (!doc) return;
let st = doc.getElementById("sirius-imported");
if (!st) { st = doc.createElement("style"); st.id = "sirius-imported"; }
// The editor's base sheet paints <body> white, which hides a page whose
// background lives on <html> (common in app bundles). Body stays
// transparent in the canvas; the export never had that rule anyway.
st.textContent = "html body{background-color:transparent}\n" + importedCss.replace(/(^|[,{}\s])body(?=[\s,{.:\[#>])/g, "$1html body");
doc.head.appendChild(st);
for (const [k, v] of Object.entries(importedHtmlAttrs)) doc.documentElement.setAttribute(k, v);
for (const href of extraHeadLinks) if (!doc.head.querySelector(`link[href="${href}"]`)) { const l = doc.createElement("link"); l.rel = "stylesheet"; l.href = href; doc.head.appendChild(l); }
} catch {}
}
function liveSource() {
const rec = entry?.records || {};
const s3 = typeof rec.s3 === "string" ? rec.s3.trim().replace(/\/?$/, "/") : "";
if (s3 && s3 !== folder) return { kind: "s3", label: s3 };
if (!s3 && typeof rec.h === "string" && rec.h) return { kind: "inline", label: "the inline h record" };
if (s3 === folder) return { kind: "studio", label: folder };
return null;
}
async function fetchLiveHtml() {
const src = liveSource();
if (!src) return null;
const url = src.kind === "studio" ? readUrl("index.html") : readUrl("index.html") + "?src=live";
const r = await fetch(url, { cache: "no-store" });
if (!r.ok) return null;
return { html: await r.text(), base: siteUrl(), src };
}
async function importHtml(html, base) {
const doc = new DOMParser().parseFromString(html, "text/html");
doc.querySelectorAll("script, base, noscript").forEach((n) => n.remove());
const isRel = (u) => !!u && !/^(https?:|data:|blob:|mailto:|tel:|#|\/\/|javascript:)/i.test(u.trim());
const abs = (u) => { try { return new URL(u.trim(), base).href; } catch { return u; } };
doc.querySelectorAll("[src]").forEach((el) => { const v = el.getAttribute("src"); if (isRel(v)) el.setAttribute("src", abs(v)); });
doc.querySelectorAll("[poster]").forEach((el) => { const v = el.getAttribute("poster"); if (isRel(v)) el.setAttribute("poster", abs(v)); });
doc.querySelectorAll("a[href]").forEach((el) => { const v = el.getAttribute("href"); if (isRel(v)) el.setAttribute("href", abs(v)); });
doc.querySelectorAll("[srcset]").forEach((el) => {
el.setAttribute("srcset", el.getAttribute("srcset").split(",").map((part) => { const [u, d] = part.trim().split(/\s+/); return (isRel(u) ? abs(u) : u) + (d ? " " + d : ""); }).join(", "));
});
let css = "";
extraHeadLinks = [];
for (const link of [...doc.querySelectorAll('link[rel~="stylesheet"][href]')]) {
const href = link.getAttribute("href");
const url = isRel(href) ? abs(href) : href;
try {
const r = await fetch(url, { cache: "no-store" });
if (!r.ok) throw new Error(String(r.status));
css += `\n/* ${url} */\n` + (await r.text()).replace(/url\(\s*(['"]?)([^'")]+)\1\s*\)/g, (m, q, u) => (isRel(u) ? `url(${q}${new URL(u.trim(), url).href}${q})` : m));
} catch { extraHeadLinks.push(url); }
link.remove();
}
doc.querySelectorAll("style").forEach((st) => { css += "\n" + st.textContent; st.remove(); });
css = css.replace(/url\(\s*(['"]?)([^'")]+)\1\s*\)/g, (m, q, u) => (isRel(u) ? `url(${q}${abs(u)}${q})` : m));
const parts = splitRootCss(css);
importedCss = parts.root;
importedHtmlAttrs = Object.fromEntries([...doc.documentElement.attributes].map((a) => [a.name, a.value]).filter(([k]) => k === "lang" || k === "class" || k.startsWith("data-")));
const bodyClass = doc.body.getAttribute("class");
if (bodyClass) editor.getWrapper().addClass(bodyClass.split(/\s+/).filter(Boolean));
editor.setComponents(doc.body.innerHTML);
editor.setStyle(parts.rest);
injectImportedCss();
setTimeout(injectImportedCss, 50);
editor.once("canvas:frame:load", injectImportedCss);
}
async function importLive({ confirmIfDirty = true } = {}) {
if (confirmIfDirty && dirty && !confirm("Replace your unsaved changes with the page the name serves right now?")) return false;
status("Importing the live site…", "busy");
try {
const live = await fetchLiveHtml();
if (!live) { status("The name serves no page yet", "err"); return false; }
await importHtml(live.html, live.base);
markDirty(true);
$("notice").hidden = true;
status(`Imported the live site from ${live.src.label}`, "ok");
return true;
} catch (e) { status("Import failed: " + (e.message || e), "err"); return false; }
}
function notice(text) { $("notice-text").textContent = text; $("notice").hidden = false; }
// ---------- pages ----------
// A name's folder holds a whole site, not one index.html. Each page is one
// entry here and one file in the bucket. The editor only ever holds the page
// being looked at, so switching parks the current one back into the array
// first; Draft and Publish both walk the whole array, which is what keeps the
// pages of a site from drifting out of step with each other.
//
// The home page is pinned at index 0: it is what the name serves at /, so it
// cannot be deleted and its path cannot move.
const newPage = (path, title) => ({ path, title, project: null, importedCss: "", extraHeadLinks: [], importedHtmlAttrs: {}, dirty: false });
// A title becomes a folder with its own index.html, so the page answers to a
// directory URL (/about/) the way the gateway and every resolver expect.
function slugToPath(s) {
const slug = String(s || "").toLowerCase().trim()
.replace(/[^a-z0-9/\- ]+/g, "").replace(/[\s_]+/g, "-").replace(/-{2,}/g, "-")
.replace(/^[-/]+|[-/]+$/g, "").replace(/\/{2,}/g, "/");
return slug ? `${slug}/index.html` : "";
}
const pageDepth = (path) => (path.match(/\//g) || []).length;
const anyDirty = () => pages.some((p) => p.dirty);
function markDirty(v = true) {
dirty = v;
if (pages[current]) pages[current].dirty = v;
if (!v) for (const p of pages) p.dirty = false;
$("btn-draft").disabled = !anyDirty();
renderPages();
}
function capturePage() {
const p = pages[current];
if (!p || !editor) return;
p.project = editor.getProjectData();
p.importedCss = importedCss;
p.extraHeadLinks = extraHeadLinks;
p.importedHtmlAttrs = importedHtmlAttrs;
}
function applyPage(i) {
const p = pages[i];
if (!p || !editor) return;
current = i;
// Pouring a page in fires a storm of update events; none of them is an edit.
loading = true;
if (p.project) editor.loadProjectData(p.project);
else { editor.setComponents(""); editor.setStyle(""); }
importedCss = p.importedCss || "";
extraHeadLinks = p.extraHeadLinks || [];
importedHtmlAttrs = p.importedHtmlAttrs || {};
injectImportedCss();
const settle = () => { loading = false; dirty = !!p.dirty; $("btn-draft").disabled = !anyDirty(); renderPages(); };
editor.once("canvas:frame:load", () => { injectImportedCss(); settle(); });
setTimeout(settle, 250); // the frame does not always reload; this is the backstop
renderPages();
}
function selectPage(i) {
if (i === current || !pages[i]) return;
capturePage();
applyPage(i);
status(`Editing ${pages[i].path}`);
}
function addPage() {
const title = (prompt("What is the new page called? Its address follows from the name — \"About\" becomes /about/.", "About") || "").trim();
if (!title) return;
const path = slugToPath(title);
if (!path) { status("That name has no letters or digits in it", "err"); return; }
if (pages.some((p) => p.path === path)) { status(`There is already a page at ${path}`, "err"); return; }
capturePage();
removed = removed.filter((x) => x !== path); // re-adding a deleted page cancels its removal
pages.push({ ...newPage(path, title), dirty: true });
applyPage(pages.length - 1);
editor.setComponents(`<section class="pg-start"><h1>${esc(title)}</h1><p>Start building this page, or press Templates to drop a whole layout in.</p></section>`);
editor.setStyle(`body{margin:0;font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;color:#15181d}\n.pg-start{max-width:760px;margin:0 auto;padding:72px 24px}\n.pg-start h1{font-size:40px;letter-spacing:-.02em;margin:0 0 12px}\n.pg-start p{color:#5a6069;font-size:17px;margin:0}`);
markDirty(true);
status(`Added ${path} — edit it, then Publish`);
}
function renamePage(i) {
const p = pages[i];
if (!p) return;
// The title only. A published address that moves is a link somebody else
// already wrote down and is now broken, so paths are fixed once made.
const title = (prompt(`What should this page be called? Its address stays ${p.path}.`, p.title) || "").trim();
if (!title || title === p.title) return;
p.title = title;
p.dirty = true;
if (i === current) dirty = true;
$("btn-draft").disabled = false;
renderPages();
}
function deletePage(i) {
const p = pages[i];
if (!p || i === 0 || pages.length < 2) return;
if (!confirm(`Delete "${p.title}"? The next Publish takes ${p.path} off Sia as well.`)) return;
if (i !== current) capturePage();
pages.splice(i, 1);
removed.push(p.path);
applyPage(i < current ? current - 1 : Math.min(i === current ? i : current, pages.length - 1));
$("btn-draft").disabled = false;
status(`Removed ${p.path} — Publish to take it off Sia`);
}
function renderPages() {
const ul = $("page-list");
if (!ul) return;
ul.textContent = "";
pages.forEach((p, i) => {
const li = document.createElement("li");
li.className = "pg" + (i === current ? " on" : "");
const go = document.createElement("button");
go.type = "button"; go.className = "pg-go"; go.dataset.i = String(i);
if (i === current) go.setAttribute("aria-current", "true");
// Titles and paths are the owner's words, not ours: never translated.
const t = document.createElement("span"); t.className = "pg-t"; t.textContent = p.title; t.setAttribute("data-i18n-skip", "");
// The address a visitor types, not the file behind it: /about/, not
// about/index.html. It is shorter, and it is what the owner has to know.
const path = document.createElement("span"); path.className = "pg-p"; path.textContent = "/" + p.path.replace(/(^|\/)index\.html$/, "$1"); path.setAttribute("data-i18n-skip", "");
go.append(t, path);
li.appendChild(go);
if (p.dirty) { const d = document.createElement("i"); d.className = "pg-dot"; d.title = "Unsaved changes"; li.appendChild(d); }
const acts = document.createElement("span");
acts.className = "pg-acts";
const act = (kind, glyph, label) => {
const b = document.createElement("button");
b.type = "button"; b.className = "pg-act"; b.dataset.act = kind; b.dataset.i = String(i);
b.textContent = glyph; b.title = label; b.setAttribute("aria-label", label);
return b;
};
acts.appendChild(act("rename", "✎", "Rename"));
if (i > 0) acts.appendChild(act("delete", "×", "Delete"));
li.appendChild(acts);
ul.appendChild(li);
});
}
// A page's public address: index.html is the folder itself, so /about/index.html
// is advertised as /about/.
const pageUrl = (path) => `https://${name}/` + path.replace(/(^|\/)index\.html$/, "$1");
function sitemapXml() {
const day = new Date().toISOString().slice(0, 10);
return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n`
+ pages.map((p) => ` <url><loc>${esc(pageUrl(p.path))}</loc><lastmod>${day}</lastmod></url>`).join("\n")
+ `\n</urlset>\n`;
}
function draftJson() {
return JSON.stringify({
v: 2, name, saved_at: new Date().toISOString(), sitemap: pages.length > 1,
pages: pages.map((p) => ({ path: p.path, title: p.title, project: p.project, importedCss: p.importedCss, extraHeadLinks: p.extraHeadLinks, importedHtmlAttrs: p.importedHtmlAttrs })),
});
}
// v1 drafts held one project at the top level. It becomes the home page, so a
// site built before this existed opens unchanged and gains pages when asked.
function loadDraft(j) {
const norm = (p) => ({
path: typeof p?.path === "string" && p.path ? p.path : "index.html",
title: typeof p?.title === "string" && p.title ? p.title : "Home",
project: p?.project || null,
importedCss: typeof p?.importedCss === "string" ? p.importedCss : "",
extraHeadLinks: Array.isArray(p?.extraHeadLinks) ? p.extraHeadLinks : [],
importedHtmlAttrs: p?.importedHtmlAttrs && typeof p.importedHtmlAttrs === "object" ? p.importedHtmlAttrs : {},
dirty: false,
});
pages = Array.isArray(j?.pages) && j.pages.length ? j.pages.map(norm) : [norm(j)];
removed = [];
current = 0;
applyPage(0);
}
$("page-add").addEventListener("click", addPage);
$("page-list").addEventListener("click", (e) => {
const a = e.target.closest(".pg-act");
if (a) { const i = Number(a.dataset.i); a.dataset.act === "rename" ? renamePage(i) : deletePage(i); return; }
const go = e.target.closest(".pg-go");
if (go) selectPage(Number(go.dataset.i));
});
// ---------- templates ----------
// Every entry carries its own card text: `label` and `tag` are what the
// picker shows, `cat` is the filter it answers to. The bodies are written to
// be thrown away — each line is a prompt for the owner to overwrite — so they
// deliberately have no translation entries; the studio's chrome does.
//
// Class names are prefixed per template (.pf-, .bi-, .rs-, …) so a template
// and any sx-* section dropped on top of it never fight over a selector.
// Palettes rotate light-neutral / dark-neutral / warm / acid so the gallery
// never shows four of the same page in a row.
const TEMPLATES = {
blank: {
label: "Blank page", tag: "Start from nothing.", cat: "special",
html: `<section style="padding:60px 20px;text-align:center"><h1>${esc(name)}</h1><p>Start building.</p></section>`,
css: `body{font-family:system-ui,sans-serif;margin:0;color:#111}`,
},
landing: {
label: "Landing page", tag: "Hero, three features, call to action.", cat: "landing",
html: `
<header class="hero">
<h1>Hello from ${esc(name)}</h1>
<p>A name on the Bitcoin Cash chain, a site on Sia. No host, no renewal, no permission.</p>
<a class="cta" href="#more">Learn more</a>
</header>
<section id="more" class="features">
<div class="f"><h3>Yours</h3><p>The certificate sits in your wallet. Nobody can take it back.</p></div>
<div class="f"><h3>Fast</h3><p>Served from Sia through any BCNR resolver or the public gateway.</p></div>
<div class="f"><h3>Simple</h3><p>Edit this page in Sirius Studio and publish in one click.</p></div>
</section>
<footer class="foot">© ${new Date().getFullYear()} ${esc(name)} · built with Sirius Studio</footer>`,
css: `
body{margin:0;font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;color:#12161f;background:#fff}
.hero{padding:96px 24px 72px;text-align:center;background:linear-gradient(180deg,#0b0e14,#141a24);color:#f1f4fa}
.hero h1{font-size:44px;margin:0 0 12px;letter-spacing:-.01em}
.hero p{font-size:18px;color:#b8c2d4;max-width:560px;margin:0 auto 26px}
.cta{display:inline-block;background:#d6ff3d;color:#0b0e14;font-weight:700;padding:12px 22px;border-radius:10px;text-decoration:none}
.features{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:20px;max-width:960px;margin:0 auto;padding:56px 24px}
.f{background:#f5f7fb;border-radius:14px;padding:22px}
.f h3{margin:0 0 8px}
.f p{margin:0;color:#4a5568}
.foot{text-align:center;padding:28px;color:#6a7488;font-size:13px;border-top:1px solid #e6e9f0}`,
},
profile: {
label: "Profile / link hub", tag: "Avatar, bio and a stack of links.", cat: "special",
html: `
<main class="card">
<div class="avatar">★</div>
<h1>${esc(name)}</h1>
<p class="bio">One line about you. Edit me.</p>
<a class="link" href="https://">Website</a>
<a class="link" href="https://">Nostr</a>
<a class="link" href="https://">Bitcoin Cash tips</a>
<a class="link" href="mailto:">Email</a>
</main>`,
css: `
body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:radial-gradient(circle at 50% 0,#1a2233,#050810);font-family:system-ui,sans-serif;color:#f1f4fa}
.card{width:min(420px,92vw);text-align:center;padding:36px 24px}
.avatar{width:88px;height:88px;border-radius:50%;background:#d6ff3d;color:#0b0e14;font-size:40px;line-height:88px;margin:0 auto 14px}
h1{margin:0 0 6px;font-size:26px}
.bio{color:#b8c2d4;margin:0 0 22px}
.link{display:block;background:#141a24;border:1px solid rgba(255,255,255,.1);color:#f1f4fa;text-decoration:none;padding:14px;border-radius:12px;margin:10px 0;font-weight:600}
.link:hover{border-color:#d6ff3d}`,
},
business: {
label: "Business one-pager", tag: "About, services, hours, contact.", cat: "business",
html: `
<nav class="nav"><b>${esc(name)}</b><span><a href="#about">About</a><a href="#services">Services</a><a href="#contact">Contact</a></span></nav>
<header class="top"><h1>We are open</h1><p>Say what you do in one sentence.</p></header>
<section id="about" class="sec"><h2>About</h2><p>Two or three sentences about the business, the people and the place.</p></section>
<section id="services" class="sec alt"><h2>Services</h2>
<ul class="grid"><li><b>Service one</b><span>Short description.</span></li><li><b>Service two</b><span>Short description.</span></li><li><b>Service three</b><span>Short description.</span></li></ul></section>
<section id="contact" class="sec"><h2>Contact</h2><p>Street 1, City · MonFri 918 · <a href="mailto:">hello@example</a></p></section>
<footer class="foot">© ${new Date().getFullYear()} ${esc(name)}</footer>`,
css: `
body{margin:0;font-family:Georgia,serif;color:#2b1d12;background:#fff8f0}
.nav{display:flex;justify-content:space-between;align-items:center;padding:16px 28px;border-bottom:1px solid #eadfd0;font-family:system-ui,sans-serif}
.nav a{margin-left:18px;color:#7a3b00;text-decoration:none}
.top{text-align:center;padding:80px 24px;background:#7a3b00;color:#fff4e6}
.top h1{font-size:42px;margin:0 0 10px}
.sec{max-width:820px;margin:0 auto;padding:48px 24px}
.sec.alt{max-width:none;background:#fff1e0}
.sec.alt h2,.sec.alt ul{max-width:820px;margin-left:auto;margin-right:auto}
.grid{list-style:none;padding:0;display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:16px}
.grid li{background:#fff;border-radius:12px;padding:18px;box-shadow:0 2px 10px rgba(0,0,0,.05)}
.grid b{display:block;margin-bottom:6px}
.foot{text-align:center;padding:24px;color:#8a6d55;font-size:13px}`,
},
// --- light neutral ---
portfolio: {
label: "Portfolio", tag: "A grid of work, one line about you.", cat: "landing",
html: `
<header class="pf-top">
<div class="pf-in">
<b>${esc(name)}</b>
<nav><a href="#work">Work</a><a href="#about">About</a><a href="mailto:">Hire me</a></nav>
</div>
</header>
<section class="pf-hero">
<h1>I design things that<br>hold up under use.</h1>
<p>Two sentences on what you make and who you make it for. Leave out the adjectives you would not say out loud.</p>
</section>
<section id="work" class="pf-work">
<article class="pf-item"><div class="pf-shot"></div><h3>Project one</h3><p>What it was, what you did, what changed.</p></article>
<article class="pf-item"><div class="pf-shot"></div><h3>Project two</h3><p>What it was, what you did, what changed.</p></article>
<article class="pf-item"><div class="pf-shot"></div><h3>Project three</h3><p>What it was, what you did, what changed.</p></article>
<article class="pf-item"><div class="pf-shot"></div><h3>Project four</h3><p>What it was, what you did, what changed.</p></article>
</section>
<section id="about" class="pf-about">
<h2>About</h2>
<p>Where you are, what you use, what you are looking for next. A working email beats a contact form.</p>
<a class="pf-mail" href="mailto:">hello@example</a>
</section>
<footer class="pf-foot">© ${new Date().getFullYear()} ${esc(name)}</footer>`,
css: `
body{margin:0;font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;color:#15181d;background:#fafafa;line-height:1.6}
.pf-top{border-bottom:1px solid #e6e7ea;background:#fff}
.pf-in{max-width:1040px;margin:0 auto;padding:18px 24px;display:flex;justify-content:space-between;align-items:center}
.pf-in b{font-size:16px;letter-spacing:-.01em}
.pf-in a{margin-left:20px;color:#5a6069;text-decoration:none;font-size:14px}
.pf-in a:hover{color:#15181d}
.pf-hero{max-width:1040px;margin:0 auto;padding:88px 24px 56px}
.pf-hero h1{font-size:52px;line-height:1.08;letter-spacing:-.025em;margin:0 0 20px;font-weight:600}
.pf-hero p{max-width:560px;color:#5a6069;font-size:18px;margin:0}
.pf-work{max-width:1040px;margin:0 auto;padding:0 24px 64px;display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:36px 28px}
.pf-shot{aspect-ratio:4/3;border-radius:12px;background:linear-gradient(135deg,#eceef1,#dfe2e7);margin-bottom:16px}
.pf-item h3{margin:0 0 6px;font-size:18px;font-weight:600}
.pf-item p{margin:0;color:#5a6069;font-size:15px}
.pf-about{border-top:1px solid #e6e7ea;background:#fff}
.pf-about h2{font-size:15px;text-transform:uppercase;letter-spacing:.12em;color:#8b9099;margin:0 0 14px}
.pf-about,.pf-about>*{max-width:1040px;margin-left:auto;margin-right:auto}
.pf-about{padding:56px 24px}
.pf-about p{max-width:620px;margin:0 0 18px;font-size:17px}
.pf-mail{color:#15181d;font-weight:600;border-bottom:2px solid #15181d;text-decoration:none;padding-bottom:1px}
.pf-foot{max-width:1040px;margin:0 auto;padding:28px 24px;color:#8b9099;font-size:13px}
@media(max-width:600px){.pf-hero h1{font-size:34px}.pf-in nav a{margin-left:12px}}`,
},
"blog-post": {
label: "Blog post", tag: "One article — title, byline, body, replies.", cat: "blog",
html: `
<header class="bp-top"><a href="/">${esc(name)}</a><a href="/archive/">Archive</a></header>
<article class="bp">
<p class="bp-kicker">Essays</p>
<h1>The title goes here, and it can run to two lines</h1>
<p class="bp-meta">By an author · ${new Date().toLocaleDateString("en-GB", { day: "numeric", month: "long", year: "numeric" })} · 6 min</p>
<p class="bp-lead">The opening paragraph does the work of a subtitle. Say what the reader gets if they stay.</p>
<p>A normal paragraph. Long-form type wants a measure of around 70 characters and generous leading — that is what the CSS below is doing, and it is the one thing worth keeping when you rewrite this.</p>
<h2>A section heading</h2>
<p>Another paragraph. Links look <a href="#">like this</a>, and a phrase can be <em>emphasised</em> or made <strong>strong</strong> without changing the rhythm of the line.</p>
<blockquote>A pulled quote earns its indent when it is someone else's sentence, not a louder version of your own.</blockquote>
<p>Close on something concrete. What changed, what you would do differently, where the reader goes next.</p>
</article>
<section class="bp-replies">
<h2>Replies</h2>
<p class="bp-none">No replies yet.</p>
</section>
<footer class="bp-foot">© ${new Date().getFullYear()} ${esc(name)}</footer>`,
css: `
body{margin:0;background:#fffefb;color:#1b1a17;font-family:Georgia,'Times New Roman',serif;line-height:1.7}
.bp-top{display:flex;gap:20px;max-width:720px;margin:0 auto;padding:22px 24px;font-family:system-ui,sans-serif;font-size:14px}
.bp-top a{color:#6f6a60;text-decoration:none}
.bp-top a:first-child{font-weight:600;color:#1b1a17}
.bp{max-width:680px;margin:0 auto;padding:20px 24px 56px}
.bp-kicker{font-family:system-ui,sans-serif;font-size:12px;letter-spacing:.14em;text-transform:uppercase;color:#b5561f;margin:0 0 14px}
.bp h1{font-size:42px;line-height:1.15;letter-spacing:-.015em;margin:0 0 16px}
.bp-meta{font-family:system-ui,sans-serif;font-size:14px;color:#8a8579;margin:0 0 32px}
.bp-lead{font-size:21px;color:#3d3a34;margin:0 0 28px}
.bp p{font-size:18px;margin:0 0 24px}
.bp h2{font-size:26px;margin:40px 0 14px;letter-spacing:-.01em}
.bp a{color:#b5561f}
.bp blockquote{margin:32px 0;padding:4px 0 4px 24px;border-left:3px solid #e6dfd2;color:#57524a;font-style:italic;font-size:19px}
.bp-replies{max-width:680px;margin:0 auto;padding:32px 24px;border-top:1px solid #e6dfd2}
.bp-replies h2{font-family:system-ui,sans-serif;font-size:14px;letter-spacing:.1em;text-transform:uppercase;color:#8a8579;margin:0 0 12px}
.bp-none{color:#8a8579;font-size:16px;margin:0}
.bp-foot{max-width:680px;margin:0 auto;padding:24px;color:#a8a294;font-size:13px;font-family:system-ui,sans-serif}
@media(max-width:600px){.bp h1{font-size:30px}.bp-lead{font-size:18px}}`,
},
resume: {
label: "Résumé", tag: "One page: skills, history, a way to reach you.", cat: "special",
html: `
<main class="cv">
<header class="cv-head">
<div><h1>Your Name</h1><p class="cv-role">What you do · City</p></div>
<p class="cv-reach"><a href="mailto:">hello@example</a><br><a href="https://">${esc(name)}</a></p>
</header>
<section class="cv-sec">
<h2>Summary</h2>
<p>Three lines at most. What you build, how long you have been doing it, and the kind of problem you want next.</p>
</section>
<section class="cv-sec">
<h2>Skills</h2>
<ul class="cv-tags"><li>One</li><li>Two</li><li>Three</li><li>Four</li><li>Five</li><li>Six</li></ul>
</section>
<section class="cv-sec">
<h2>Experience</h2>
<div class="cv-row"><b>Role</b><span>Company · 2024 — now</span><p>What you owned and what measurably changed because of it.</p></div>
<div class="cv-row"><b>Role</b><span>Company · 2021 — 2024</span><p>What you owned and what measurably changed because of it.</p></div>
<div class="cv-row"><b>Role</b><span>Company · 2018 — 2021</span><p>What you owned and what measurably changed because of it.</p></div>
</section>
<section class="cv-sec">
<h2>Education</h2>
<div class="cv-row"><b>Degree</b><span>Institution · 2018</span></div>
</section>
<p class="cv-print">Ctrl/Cmd + P prints this page straight to a PDF — the print rules below drop the shadow and the margins.</p>
</main>`,
css: `
body{margin:0;background:#f4f5f7;color:#15181d;font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;line-height:1.6}
.cv{max-width:760px;margin:40px auto;background:#fff;padding:48px 52px;border-radius:4px;box-shadow:0 1px 3px rgba(0,0,0,.08)}
.cv-head{display:flex;justify-content:space-between;align-items:flex-start;gap:20px;padding-bottom:24px;border-bottom:2px solid #15181d;margin-bottom:8px}
.cv-head h1{margin:0;font-size:32px;letter-spacing:-.02em}
.cv-role{margin:4px 0 0;color:#5a6069}
.cv-reach{margin:0;text-align:right;font-size:14px;line-height:1.7}
.cv-reach a{color:#15181d}
.cv-sec{padding:24px 0;border-bottom:1px solid #eceef1}
.cv-sec h2{font-size:12px;letter-spacing:.14em;text-transform:uppercase;color:#8b9099;margin:0 0 14px}
.cv-sec>p{margin:0;max-width:60ch}
.cv-tags{list-style:none;display:flex;flex-wrap:wrap;gap:8px;padding:0;margin:0}
.cv-tags li{background:#f0f1f4;border-radius:6px;padding:5px 11px;font-size:14px}
.cv-row{margin-bottom:18px}
.cv-row:last-child{margin-bottom:0}
.cv-row b{font-size:16px}
.cv-row span{display:block;color:#8b9099;font-size:14px;margin-bottom:5px}
.cv-row p{margin:0;color:#41464e;max-width:62ch}
.cv-print{margin:24px 0 0;font-size:14px;color:#8b9099}
@media print{body{background:#fff}.cv{box-shadow:none;margin:0;padding:0}.cv-print{display:none}}
@media(max-width:620px){.cv{margin:0;padding:28px 22px}.cv-head{flex-direction:column}.cv-reach{text-align:left}}`,
},
// --- dark neutral ---
"blog-index": {
label: "Blog", tag: "A list of posts with tags down the side.", cat: "blog",
html: `
<header class="bi-top">
<div class="bi-in"><b>${esc(name)}</b><nav><a href="/">Posts</a><a href="/about/">About</a><a href="/feed.xml">RSS</a></nav></div>
</header>
<div class="bi-body">
<main class="bi-list">
<article class="bi-post"><time>12 September ${new Date().getFullYear()}</time><h2><a href="#">The most recent post</a></h2><p>One or two sentences of the opening, enough to decide whether to click.</p></article>
<article class="bi-post"><time>28 August ${new Date().getFullYear()}</time><h2><a href="#">Something else worth writing down</a></h2><p>One or two sentences of the opening, enough to decide whether to click.</p></article>
<article class="bi-post"><time>03 August ${new Date().getFullYear()}</time><h2><a href="#">A shorter note</a></h2><p>One or two sentences of the opening, enough to decide whether to click.</p></article>
<article class="bi-post"><time>17 July ${new Date().getFullYear()}</time><h2><a href="#">Notes from a thing that broke</a></h2><p>One or two sentences of the opening, enough to decide whether to click.</p></article>
</main>
<aside class="bi-side">
<h3>Tags</h3>
<ul class="bi-tags"><li><a href="#">writing</a></li><li><a href="#">bitcoin cash</a></li><li><a href="#">self-hosting</a></li><li><a href="#">sia</a></li><li><a href="#">notes</a></li></ul>
<h3>Elsewhere</h3>
<ul class="bi-tags"><li><a href="https://">Nostr</a></li><li><a href="mailto:">Email</a></li></ul>
</aside>
</div>
<footer class="bi-foot">© ${new Date().getFullYear()} ${esc(name)} · no trackers, no newsletter pop-up</footer>`,
css: `
body{margin:0;background:#0d1015;color:#e3e6eb;font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;line-height:1.65}
a{color:#e3e6eb}
.bi-top{border-bottom:1px solid #1f242c}
.bi-in{max-width:980px;margin:0 auto;padding:20px 24px;display:flex;justify-content:space-between;align-items:center}
.bi-in b{font-size:16px}
.bi-in a{margin-left:20px;color:#8d96a5;text-decoration:none;font-size:14px}
.bi-in a:hover{color:#fff}
.bi-body{max-width:980px;margin:0 auto;padding:48px 24px;display:grid;grid-template-columns:1fr 220px;gap:56px;align-items:start}
.bi-post{padding-bottom:30px;margin-bottom:30px;border-bottom:1px solid #1f242c}
.bi-post:last-child{border:0;margin:0;padding:0}
.bi-post time{font-size:13px;color:#6d7787;letter-spacing:.02em}
.bi-post h2{margin:6px 0 8px;font-size:24px;letter-spacing:-.015em;line-height:1.25}
.bi-post h2 a{text-decoration:none}
.bi-post h2 a:hover{color:#8ab4ff}
.bi-post p{margin:0;color:#98a1b0}
.bi-side h3{font-size:12px;letter-spacing:.14em;text-transform:uppercase;color:#6d7787;margin:0 0 12px}
.bi-side h3+ul+h3{margin-top:28px}
.bi-tags{list-style:none;padding:0;margin:0;display:flex;flex-wrap:wrap;gap:7px}
.bi-tags a{display:inline-block;background:#161b22;border:1px solid #232933;border-radius:6px;padding:4px 10px;font-size:13px;color:#98a1b0;text-decoration:none}
.bi-tags a:hover{border-color:#3a434f;color:#fff}
.bi-foot{border-top:1px solid #1f242c;padding:24px;text-align:center;color:#6d7787;font-size:13px}
@media(max-width:760px){.bi-body{grid-template-columns:1fr;gap:40px}}`,
},
docs: {
label: "Documentation", tag: "Sidebar contents, one long article.", cat: "special",
html: `
<header class="dc-top"><b>${esc(name)} docs</b><input class="dc-find" type="search" placeholder="Search (wire this up later)"></header>
<div class="dc-body">
<nav class="dc-nav">
<h4>Getting started</h4>
<a class="on" href="#install">Install</a><a href="#config">Configure</a><a href="#first">First run</a>
<h4>Reference</h4>
<a href="#cli">Commands</a><a href="#api">HTTP API</a><a href="#errors">Errors</a>
<h4>Help</h4>
<a href="#faq">FAQ</a>
</nav>
<main class="dc-main">
<h1 id="install">Install</h1>
<p>One paragraph saying what the reader is about to do and how long it takes. Then the command.</p>
<pre class="dc-pre"><code>curl -fsSL https://${esc(name)}/install.sh | sh</code></pre>
<h2 id="config">Configure</h2>
<p>What to edit and where it lives. Say what the defaults are so most readers can stop here.</p>
<table class="dc-tab"><thead><tr><th data-gjs-type="text">Key</th><th data-gjs-type="text">Default</th><th data-gjs-type="text">What it does</th></tr></thead>
<tbody><tr><td data-gjs-type="text">port</td><td data-gjs-type="text">8080</td><td data-gjs-type="text">Where it listens.</td></tr><tr><td data-gjs-type="text">data_dir</td><td data-gjs-type="text">./data</td><td data-gjs-type="text">Where state is kept.</td></tr><tr><td data-gjs-type="text">log_level</td><td data-gjs-type="text">info</td><td data-gjs-type="text">How loud it is.</td></tr></tbody></table>
<h2 id="first">First run</h2>
<p>The happy path, end to end, with the output the reader should actually see.</p>
<div class="dc-note"><b>Note</b> Anything that will bite a reader at 2am goes in a box like this one.</div>
</main>
</div>`,
css: `
body{margin:0;background:#0f1216;color:#dfe3e9;font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;line-height:1.7}
.dc-top{display:flex;justify-content:space-between;align-items:center;gap:16px;padding:14px 22px;border-bottom:1px solid #1d222a;background:#0b0e12;position:sticky;top:0}
.dc-top b{font-size:15px}
.dc-find{background:#151a21;border:1px solid #252b35;border-radius:8px;padding:7px 12px;color:#dfe3e9;font:inherit;font-size:13px;width:min(280px,45vw)}
.dc-body{display:grid;grid-template-columns:230px 1fr;max-width:1120px;margin:0 auto}
.dc-nav{padding:28px 18px;border-right:1px solid #1d222a;position:sticky;top:57px;align-self:start;max-height:calc(100vh - 57px);overflow:auto}
.dc-nav h4{font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:#68727f;margin:22px 0 8px}
.dc-nav h4:first-child{margin-top:0}
.dc-nav a{display:block;padding:5px 10px;border-radius:7px;color:#98a1af;text-decoration:none;font-size:14px}
.dc-nav a:hover{background:#151a21;color:#fff}
.dc-nav a.on{background:#1b2230;color:#8ab4ff}
.dc-main{padding:36px 40px 80px;max-width:760px}
.dc-main h1{font-size:34px;letter-spacing:-.02em;margin:0 0 14px}
.dc-main h2{font-size:23px;letter-spacing:-.01em;margin:44px 0 12px;padding-top:12px;border-top:1px solid #1d222a}
.dc-main p{color:#aeb6c2;margin:0 0 18px}
.dc-pre{background:#0a0d11;border:1px solid #1d222a;border-radius:10px;padding:14px 16px;overflow:auto;font-size:13.5px;color:#a7e3b0}
.dc-tab{width:100%;border-collapse:collapse;font-size:14px;margin:0 0 18px}
.dc-tab th{text-align:left;color:#68727f;font-weight:600;font-size:12px;letter-spacing:.08em;text-transform:uppercase;padding:0 12px 8px 0;border-bottom:1px solid #1d222a}
.dc-tab td{padding:9px 12px 9px 0;border-bottom:1px solid #161b22;color:#aeb6c2}
.dc-note{border-left:3px solid #8ab4ff;background:#131a26;border-radius:0 8px 8px 0;padding:12px 16px;color:#aeb6c2;font-size:14.5px}
.dc-note b{color:#8ab4ff;margin-right:6px}
@media(max-width:820px){.dc-body{grid-template-columns:1fr}.dc-nav{position:static;max-height:none;border-right:0;border-bottom:1px solid #1d222a}.dc-main{padding:28px 22px 64px}}`,
},
agency: {
label: "Agency", tag: "Case studies, services, the people behind them.", cat: "business",
html: `
<header class="ag-top"><div class="ag-in"><b>${esc(name)}</b><nav><a href="#work">Work</a><a href="#services">Services</a><a href="#team">Team</a><a class="ag-cta" href="#contact">Start a project</a></nav></div></header>
<section class="ag-hero">
<h1>We take the awkward half of the problem.</h1>
<p>One sentence on what you do, one on who for. Resist listing technologies here — the work below does that better.</p>
</section>
<section id="work" class="ag-work">
<h2 class="ag-lab">Selected work</h2>
<div class="ag-cases">
<a class="ag-case" href="#"><div class="ag-shot"></div><h3>Client one</h3><p>The thing that was broken, and the number that moved.</p></a>
<a class="ag-case" href="#"><div class="ag-shot"></div><h3>Client two</h3><p>The thing that was broken, and the number that moved.</p></a>
<a class="ag-case" href="#"><div class="ag-shot"></div><h3>Client three</h3><p>The thing that was broken, and the number that moved.</p></a>
</div>
</section>
<section id="services" class="ag-serv">
<h2 class="ag-lab">What we do</h2>
<div class="ag-grid">
<div><b>Strategy</b><p>Deciding what not to build.</p></div>
<div><b>Design</b><p>Interfaces that survive a real user.</p></div>
<div><b>Engineering</b><p>Shipping it, then keeping it up.</p></div>
<div><b>Support</b><p>Being reachable after launch.</p></div>
</div>
</section>
<section id="team" class="ag-team">
<h2 class="ag-lab">Who you would work with</h2>
<div class="ag-people"><div><span class="ag-face"></span><b>A. Person</b><i>Partner</i></div><div><span class="ag-face"></span><b>B. Person</b><i>Design</i></div><div><span class="ag-face"></span><b>C. Person</b><i>Engineering</i></div></div>
</section>
<footer id="contact" class="ag-foot"><h2>Tell us what is stuck.</h2><a class="ag-cta big" href="mailto:">hello@example</a><p>© ${new Date().getFullYear()} ${esc(name)}</p></footer>`,
css: `
body{margin:0;background:#101114;color:#eceef2;font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;line-height:1.6}
.ag-top{border-bottom:1px solid #1e2026}
.ag-in{max-width:1080px;margin:0 auto;padding:18px 24px;display:flex;justify-content:space-between;align-items:center;gap:16px;flex-wrap:wrap}
.ag-in b{font-size:16px}
.ag-in a{margin-left:22px;color:#9aa0ad;text-decoration:none;font-size:14px}
.ag-in a:hover{color:#fff}
.ag-cta{background:#eceef2;color:#101114 !important;padding:8px 16px;border-radius:999px;font-weight:600}
.ag-hero{max-width:1080px;margin:0 auto;padding:96px 24px 72px}
.ag-hero h1{font-size:56px;line-height:1.05;letter-spacing:-.03em;margin:0 0 20px;max-width:14ch}
.ag-hero p{max-width:540px;color:#9aa0ad;font-size:18px;margin:0}
.ag-lab{font-size:12px;letter-spacing:.16em;text-transform:uppercase;color:#6a7280;margin:0 0 24px;font-weight:600}
.ag-work,.ag-serv,.ag-team{max-width:1080px;margin:0 auto;padding:56px 24px;border-top:1px solid #1e2026}
.ag-cases{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:28px}
.ag-case{text-decoration:none;color:inherit}
.ag-shot{aspect-ratio:16/10;border-radius:10px;background:linear-gradient(135deg,#1c1f26,#272b34);margin-bottom:14px}
.ag-case h3{margin:0 0 5px;font-size:18px}
.ag-case p{margin:0;color:#9aa0ad;font-size:15px}
.ag-case:hover .ag-shot{background:linear-gradient(135deg,#242832,#333844)}
.ag-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:28px}
.ag-grid b{display:block;margin-bottom:5px;font-size:17px}
.ag-grid p{margin:0;color:#9aa0ad;font-size:15px}
.ag-people{display:flex;gap:40px;flex-wrap:wrap}
.ag-face{display:block;width:72px;height:72px;border-radius:50%;background:#272b34;margin-bottom:10px}
.ag-people b{display:block;font-size:15px}
.ag-people i{color:#6a7280;font-size:13.5px;font-style:normal}
.ag-foot{border-top:1px solid #1e2026;text-align:center;padding:72px 24px 40px}
.ag-foot h2{font-size:38px;letter-spacing:-.02em;margin:0 0 24px}
.ag-cta.big{display:inline-block;margin-left:0;padding:13px 26px;font-size:16px}
.ag-foot p{color:#6a7280;font-size:13px;margin:40px 0 0}
@media(max-width:640px){.ag-hero{padding:56px 24px 40px}.ag-hero h1{font-size:36px}.ag-foot h2{font-size:26px}.ag-in nav a{margin-left:14px}}`,
},
// --- warm ---
restaurant: {
label: "Restaurant", tag: "Menu, hours, the address, a table.", cat: "business",
html: `
<header class="rs-hero">
<p class="rs-kick">Since ${new Date().getFullYear() - 7}</p>
<h1>${esc(name)}</h1>
<p class="rs-sub">Small kitchen, short menu, everything cooked the day you eat it.</p>
<a class="rs-btn" href="#book">Book a table</a>
</header>
<section class="rs-menu">
<h2>Menu</h2>
<div class="rs-cols">
<div>
<h3>To start</h3>
<div class="rs-item"><span>Bread, olive oil, salt</span><b>4</b></div>
<div class="rs-item"><span>Soup of the day</span><b>7</b></div>
<div class="rs-item"><span>Grilled vegetables, yoghurt</span><b>9</b></div>
<h3>Mains</h3>
<div class="rs-item"><span>Fish of the day</span><b>19</b></div>
<div class="rs-item"><span>Slow lamb, beans</span><b>21</b></div>
<div class="rs-item"><span>Mushroom orzo</span><b>16</b></div>
</div>
<div>
<h3>Sweet</h3>
<div class="rs-item"><span>Yoghurt, honey, walnut</span><b>6</b></div>
<div class="rs-item"><span>Chocolate, olive oil</span><b>7</b></div>
<h3>To drink</h3>
<div class="rs-item"><span>House red, glass</span><b>5</b></div>
<div class="rs-item"><span>House white, glass</span><b>5</b></div>
<div class="rs-item"><span>Coffee</span><b>3</b></div>
</div>
</div>
<p class="rs-fine">Prices include tax. Tell us about allergies when you book — the menu changes weekly.</p>
</section>
<section class="rs-when">
<div><h3>Hours</h3><p>Tuesday Saturday<br>18:00 23:00<br>Sunday lunch 13:00 16:00<br>Closed Monday</p></div>
<div><h3>Where</h3><p>Street 1<br>City<br><a href="https://">Open in maps →</a></p></div>
<div id="book"><h3>Book</h3><p><a class="rs-btn small" href="tel:">Call us</a><br><a href="mailto:">Or send an email</a></p></div>
</section>
<footer class="rs-foot">© ${new Date().getFullYear()} ${esc(name)}</footer>`,
css: `
body{margin:0;background:#fdf6ec;color:#3a2a1c;font-family:Georgia,'Times New Roman',serif;line-height:1.65}
.rs-hero{text-align:center;padding:88px 24px 72px;background:#5c2f16;color:#fdf6ec}
.rs-kick{letter-spacing:.24em;text-transform:uppercase;font-size:11px;margin:0 0 16px;color:#d9a86c;font-family:system-ui,sans-serif}
.rs-hero h1{font-size:54px;margin:0 0 14px;letter-spacing:.01em;font-weight:400}
.rs-sub{max-width:460px;margin:0 auto 28px;color:#e8cfb3;font-size:17px}
.rs-btn{display:inline-block;background:#d9a86c;color:#3a2a1c;padding:12px 26px;border-radius:2px;text-decoration:none;font-family:system-ui,sans-serif;font-weight:600;font-size:14px;letter-spacing:.04em}
.rs-btn.small{padding:9px 18px;font-size:13px;margin-bottom:8px}
.rs-menu{max-width:860px;margin:0 auto;padding:64px 24px 48px}
.rs-menu h2{text-align:center;font-size:13px;letter-spacing:.2em;text-transform:uppercase;color:#9a7550;font-family:system-ui,sans-serif;margin:0 0 36px;font-weight:600}
.rs-cols{display:grid;grid-template-columns:1fr 1fr;gap:20px 56px}
.rs-cols h3{font-size:20px;font-weight:400;margin:28px 0 12px;font-style:italic;color:#5c2f16}
.rs-cols h3:first-child{margin-top:0}
.rs-item{display:flex;justify-content:space-between;align-items:baseline;gap:12px;padding:6px 0;border-bottom:1px dotted #d8c3a8}
.rs-item b{font-weight:400;color:#9a7550}
.rs-fine{text-align:center;color:#9a7550;font-size:14px;margin:36px 0 0;font-family:system-ui,sans-serif}
.rs-when{max-width:860px;margin:0 auto;padding:40px 24px 64px;display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:36px;border-top:1px solid #e6d5bf}
.rs-when h3{font-size:12px;letter-spacing:.16em;text-transform:uppercase;color:#9a7550;font-family:system-ui,sans-serif;margin:0 0 10px}
.rs-when p{margin:0}
.rs-when a{color:#5c2f16}
.rs-foot{text-align:center;padding:24px;background:#5c2f16;color:#c9a883;font-size:13px;font-family:system-ui,sans-serif}
@media(max-width:620px){.rs-hero h1{font-size:38px}.rs-cols{grid-template-columns:1fr;gap:0}}`,
},
event: {
label: "Event", tag: "Date, venue, agenda, speakers, RSVP.", cat: "business",
html: `
<header class="ev-top">
<p class="ev-date">14 November ${new Date().getFullYear()} · City</p>
<h1>A one-day thing<br>worth travelling for</h1>
<p class="ev-sub">Eight talks, one room, no parallel tracks. Lunch is included and the coffee is good.</p>
<a class="ev-btn" href="#rsvp">Reserve a seat</a>
<p class="ev-left">62 of 120 seats left</p>
</header>
<section class="ev-sec">
<h2>Agenda</h2>
<div class="ev-line"><time>09:30</time><div><b>Doors and coffee</b><p>Come early, the good seats go first.</p></div></div>
<div class="ev-line"><time>10:00</time><div><b>Opening talk</b><p>Speaker name — the title of the talk.</p></div></div>
<div class="ev-line"><time>11:15</time><div><b>Second talk</b><p>Speaker name — the title of the talk.</p></div></div>
<div class="ev-line"><time>12:30</time><div><b>Lunch</b><p>Included. Say what you cannot eat when you RSVP.</p></div></div>
<div class="ev-line"><time>14:00</time><div><b>Workshops</b><p>Three rooms, pick one on the day.</p></div></div>
<div class="ev-line"><time>17:00</time><div><b>Close and drinks</b><p>Around the corner, on us.</p></div></div>
</section>
<section class="ev-sec alt">
<h2>Speakers</h2>
<div class="ev-spk"><div><span class="ev-face"></span><b>A. Person</b><i>What they work on</i></div><div><span class="ev-face"></span><b>B. Person</b><i>What they work on</i></div><div><span class="ev-face"></span><b>C. Person</b><i>What they work on</i></div><div><span class="ev-face"></span><b>D. Person</b><i>What they work on</i></div></div>
</section>
<section id="rsvp" class="ev-sec">
<h2>RSVP</h2>
<form class="ev-form" method="post">
<input type="text" name="name" placeholder="Your name" required>
<input type="email" name="email" placeholder="Email" required>
<input type="text" name="diet" placeholder="Anything you cannot eat">
<button type="submit" class="ev-btn" data-gjs-type="text">Hold my seat</button>
</form>
<p class="ev-fine">This form needs somewhere to post to. Until you set one up, put an email address here instead.</p>
</section>
<footer class="ev-foot">${esc(name)} · Venue, Street 1, City · © ${new Date().getFullYear()}</footer>`,
css: `
body{margin:0;background:#fff9f2;color:#2f2418;font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;line-height:1.6}
.ev-top{text-align:center;padding:80px 24px 64px;background:linear-gradient(160deg,#c2410c,#7c2d12);color:#fff4e8}
.ev-date{letter-spacing:.18em;text-transform:uppercase;font-size:12px;margin:0 0 18px;color:#fdba74}
.ev-top h1{font-size:50px;line-height:1.08;letter-spacing:-.02em;margin:0 0 18px;font-weight:700}
.ev-sub{max-width:520px;margin:0 auto 30px;color:#fed7aa;font-size:17px}
.ev-btn{display:inline-block;background:#fff4e8;color:#7c2d12;padding:13px 28px;border-radius:10px;text-decoration:none;font-weight:700;border:0;font:inherit;font-weight:700;cursor:pointer}
.ev-left{margin:14px 0 0;font-size:13px;color:#fdba74}
.ev-sec{max-width:760px;margin:0 auto;padding:56px 24px}
.ev-sec.alt{max-width:none;background:#fdefe0}
.ev-sec.alt>*{max-width:760px;margin-left:auto;margin-right:auto}
.ev-sec h2{font-size:12px;letter-spacing:.16em;text-transform:uppercase;color:#a16207;margin:0 0 24px}
.ev-line{display:grid;grid-template-columns:78px 1fr;gap:18px;padding:14px 0;border-top:1px solid #f0dcc6}
.ev-line time{color:#c2410c;font-weight:700;font-size:15px;font-variant-numeric:tabular-nums}
.ev-line b{display:block;font-size:16px}
.ev-line p{margin:2px 0 0;color:#6b5844;font-size:15px}
.ev-spk{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:28px}
.ev-face{display:block;width:76px;height:76px;border-radius:50%;background:#f3ddc4;margin-bottom:10px}
.ev-spk b{display:block;font-size:15px}
.ev-spk i{font-style:normal;color:#8a7358;font-size:13.5px}
.ev-form{display:grid;gap:10px;max-width:420px}
.ev-form input{padding:12px 14px;border:1px solid #e6d3ba;border-radius:10px;font:inherit;background:#fff}
.ev-form input:focus{outline:2px solid #c2410c;outline-offset:1px}
.ev-form .ev-btn{background:#c2410c;color:#fff4e8;justify-self:start}
.ev-fine{color:#8a7358;font-size:13.5px;margin:14px 0 0;max-width:420px}
.ev-foot{text-align:center;padding:26px;background:#7c2d12;color:#fdba74;font-size:13px}
@media(max-width:620px){.ev-top h1{font-size:34px}.ev-line{grid-template-columns:64px 1fr;gap:12px}}`,
},
newsletter: {
label: "Newsletter", tag: "One promise, one field, the back issues.", cat: "landing",
html: `
<main class="nl">
<header class="nl-head">
<p class="nl-kick">${esc(name)}</p>
<h1>One letter a week about the thing you actually care about.</h1>
<p class="nl-sub">Say what is in it and how often it lands. Then say what you will never do with the address — people read that line.</p>
<form class="nl-form" method="post">
<input type="email" name="email" placeholder="you@example" required>
<button type="submit" data-gjs-type="text">Subscribe</button>
</form>
<p class="nl-fine">No tracking pixels. Unsubscribe from the bottom of any issue.</p>
</header>
<section class="nl-past">
<h2>Past issues</h2>
<a class="nl-item" href="#"><b>#014 — The title of the issue</b><span>12 September ${new Date().getFullYear()}</span><p>The one-line summary that makes the archive worth scrolling.</p></a>
<a class="nl-item" href="#"><b>#013 — Another title</b><span>05 September ${new Date().getFullYear()}</span><p>The one-line summary that makes the archive worth scrolling.</p></a>
<a class="nl-item" href="#"><b>#012 — And another</b><span>29 August ${new Date().getFullYear()}</span><p>The one-line summary that makes the archive worth scrolling.</p></a>
</section>
<footer class="nl-foot">© ${new Date().getFullYear()} ${esc(name)} · <a href="mailto:">write back</a></footer>
</main>`,
css: `
body{margin:0;background:#fbf3e9;color:#2e261c;font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;line-height:1.65}
.nl{max-width:620px;margin:0 auto;padding:0 24px}
.nl-head{padding:88px 0 56px}
.nl-kick{font-family:Georgia,serif;font-style:italic;color:#a8743a;margin:0 0 20px;font-size:17px}
.nl-head h1{font-size:40px;line-height:1.18;letter-spacing:-.02em;margin:0 0 20px;font-weight:700}
.nl-sub{color:#6d5d49;font-size:17px;margin:0 0 28px}
.nl-form{display:flex;gap:8px;flex-wrap:wrap}
.nl-form input{flex:1;min-width:200px;padding:14px 16px;border:1px solid #ddc9ab;border-radius:10px;font:inherit;background:#fff}
.nl-form input:focus{outline:2px solid #a8743a;outline-offset:1px}
.nl-form button{padding:14px 24px;border:0;border-radius:10px;background:#2e261c;color:#fbf3e9;font:inherit;font-weight:600;cursor:pointer}
.nl-form button:hover{background:#4a3d2d}
.nl-fine{color:#8b7c66;font-size:13.5px;margin:14px 0 0}
.nl-past{padding:8px 0 48px;border-top:1px solid #e8d8c0}
.nl-past h2{font-size:12px;letter-spacing:.16em;text-transform:uppercase;color:#a08a6c;margin:32px 0 18px}
.nl-item{display:block;text-decoration:none;color:inherit;padding:16px 0;border-bottom:1px solid #eee1cd}
.nl-item b{display:block;font-size:17px;margin-bottom:2px}
.nl-item span{display:block;font-size:13px;color:#a08a6c;margin-bottom:6px}
.nl-item p{margin:0;color:#6d5d49;font-size:15px}
.nl-item:hover b{color:#a8743a}
.nl-foot{padding:28px 0 40px;color:#a08a6c;font-size:13px}
.nl-foot a{color:#a8743a}
@media(max-width:560px){.nl-head{padding:56px 0 40px}.nl-head h1{font-size:30px}}`,
},
// --- acid ---
"shop-lite": {
label: "Small shop", tag: "A few products, paid to your own address.", cat: "business",
html: `
<header class="sh-top"><b>${esc(name)}</b><span class="sh-pay">Paid in Bitcoin Cash · straight to your wallet</span></header>
<section class="sh-hero"><h1>Four things worth making.</h1><p>Say what you sell and why you make it. One honest paragraph beats a carousel.</p></section>
<section class="sh-grid">
<article class="sh-card"><div class="sh-img"></div><h3>Product one</h3><p>What it is, what it is made of, how big it is.</p><div class="sh-buy"><b>0.05 BCH</b><a class="sh-btn" href="bitcoincash:">Pay</a></div></article>
<article class="sh-card"><div class="sh-img"></div><h3>Product two</h3><p>What it is, what it is made of, how big it is.</p><div class="sh-buy"><b>0.08 BCH</b><a class="sh-btn" href="bitcoincash:">Pay</a></div></article>
<article class="sh-card"><div class="sh-img"></div><h3>Product three</h3><p>What it is, what it is made of, how big it is.</p><div class="sh-buy"><b>0.12 BCH</b><a class="sh-btn" href="bitcoincash:">Pay</a></div></article>
<article class="sh-card"><div class="sh-img"></div><h3>Product four</h3><p>What it is, what it is made of, how big it is.</p><div class="sh-buy"><b>0.20 BCH</b><a class="sh-btn" href="bitcoincash:">Pay</a></div></article>
</section>
<section class="sh-how">
<h2>How ordering works</h2>
<ol><li>Tap Pay — your wallet opens with the address and amount filled in.</li><li>Send the payment, then email the transaction id and where it should go.</li><li>It ships within three working days, tracked.</li></ol>
<p class="sh-note">Put your real cashaddr in each Pay link (<code>bitcoincash:q…?amount=0.05</code>) and a real address to email. No checkout server, no card fees, no chargebacks — and no refunds unless you offer them, so say what you do.</p>
</section>
<footer class="sh-foot">© ${new Date().getFullYear()} ${esc(name)} · <a href="mailto:">orders@example</a></footer>`,
css: `
body{margin:0;background:#0a0c08;color:#eef3e4;font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;line-height:1.6}
.sh-top{display:flex;justify-content:space-between;align-items:center;gap:14px;flex-wrap:wrap;padding:16px 26px;border-bottom:1px solid #1d2417}
.sh-top b{font-size:16px}
.sh-pay{font-size:12.5px;color:#c6ff4a;letter-spacing:.02em}
.sh-hero{max-width:900px;margin:0 auto;padding:72px 24px 44px}
.sh-hero h1{font-size:46px;letter-spacing:-.025em;line-height:1.08;margin:0 0 16px}
.sh-hero p{max-width:520px;color:#9caa8c;font-size:17px;margin:0}
.sh-grid{max-width:900px;margin:0 auto;padding:0 24px 56px;display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:22px}
.sh-card{background:#11150d;border:1px solid #1d2417;border-radius:14px;padding:16px}
.sh-img{aspect-ratio:1;border-radius:10px;background:linear-gradient(135deg,#1a2012,#242c19);margin-bottom:14px}
.sh-card h3{margin:0 0 5px;font-size:17px}
.sh-card p{margin:0 0 14px;color:#9caa8c;font-size:14.5px}
.sh-buy{display:flex;justify-content:space-between;align-items:center;gap:10px}
.sh-buy b{color:#c6ff4a;font-size:15px;font-variant-numeric:tabular-nums}
.sh-btn{background:#c6ff4a;color:#0a0c08;text-decoration:none;font-weight:700;font-size:13.5px;padding:8px 16px;border-radius:8px}
.sh-btn:hover{background:#d9ff7e}
.sh-how{max-width:900px;margin:0 auto;padding:40px 24px 64px;border-top:1px solid #1d2417}
.sh-how h2{font-size:12px;letter-spacing:.16em;text-transform:uppercase;color:#6f7d60;margin:0 0 18px}
.sh-how ol{margin:0 0 20px;padding-left:20px;color:#c3cdb7;max-width:62ch}
.sh-how li{margin-bottom:7px}
.sh-note{color:#7f8d70;font-size:14px;margin:0;max-width:66ch}
.sh-note code{background:#11150d;border:1px solid #1d2417;border-radius:5px;padding:1px 6px;font-size:12.5px;color:#c6ff4a}
.sh-foot{padding:24px;text-align:center;color:#6f7d60;font-size:13px;border-top:1px solid #1d2417}
.sh-foot a{color:#9caa8c}
@media(max-width:560px){.sh-hero{padding:48px 24px 32px}.sh-hero h1{font-size:32px}}`,
},
"coming-soon": {
label: "Coming soon", tag: "A date, a way to be told, nothing else.", cat: "landing",
html: `
<main class="cs">
<p class="cs-kick">${esc(name)}</p>
<h1>Something is being built here.</h1>
<p class="cs-sub">One sentence on what it is. One on when. Vagueness reads as vapour, so give a real month.</p>
<div class="cs-clock">
<div><b>14</b><span>days</span></div>
<div><b>06</b><span>hours</span></div>
<div><b>32</b><span>minutes</span></div>
<div><b>09</b><span>seconds</span></div>
</div>
<p class="cs-when">Opening 14 November ${new Date().getFullYear()} — edit these numbers by hand, or drop in the Countdown widget from the Widgets panel to make them run.</p>
<form class="cs-form" method="post">
<input type="email" name="email" placeholder="you@example">
<button type="submit" data-gjs-type="text">Tell me when</button>
</form>
<p class="cs-or">or follow along: <a href="https://">Nostr</a> · <a href="https://">Mastodon</a> · <a href="mailto:">Email</a></p>
</main>
<footer class="cs-foot">© ${new Date().getFullYear()} ${esc(name)}</footer>`,
css: `
body{margin:0;min-height:100vh;display:flex;flex-direction:column;background:#070a04;color:#e9f2dc;font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;line-height:1.6;background-image:radial-gradient(circle at 50% 0,rgba(198,255,74,.12),transparent 60%)}
.cs{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:72px 24px 48px}
.cs-kick{font-family:ui-monospace,Menlo,monospace;letter-spacing:.2em;text-transform:uppercase;font-size:12px;color:#c6ff4a;margin:0 0 24px}
.cs h1{font-size:46px;line-height:1.1;letter-spacing:-.025em;margin:0 0 16px;max-width:16ch}
.cs-sub{max-width:480px;color:#93a184;font-size:17px;margin:0 0 40px}
.cs-clock{display:flex;gap:14px;flex-wrap:wrap;justify-content:center;margin-bottom:18px}
.cs-clock div{background:rgba(198,255,74,.07);border:1px solid rgba(198,255,74,.22);border-radius:12px;padding:16px 20px;min-width:84px}
.cs-clock b{display:block;font-size:34px;font-variant-numeric:tabular-nums;color:#c6ff4a;line-height:1.1}
.cs-clock span{font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:#7c8a6d}
.cs-when{color:#7c8a6d;font-size:13.5px;max-width:52ch;margin:0 0 40px}
.cs-form{display:flex;gap:8px;flex-wrap:wrap;justify-content:center;width:min(420px,100%)}
.cs-form input{flex:1;min-width:190px;padding:13px 16px;border-radius:10px;border:1px solid rgba(255,255,255,.14);background:#0d1208;color:#e9f2dc;font:inherit}
.cs-form input:focus{outline:2px solid #c6ff4a;outline-offset:1px}
.cs-form button{padding:13px 22px;border:0;border-radius:10px;background:#c6ff4a;color:#070a04;font:inherit;font-weight:700;cursor:pointer}
.cs-or{margin:22px 0 0;color:#7c8a6d;font-size:14px}
.cs-or a{color:#c6ff4a;text-decoration:none}
.cs-or a:hover{text-decoration:underline}
.cs-foot{text-align:center;padding:20px;color:#5d6a50;font-size:12.5px}
@media(max-width:520px){.cs h1{font-size:32px}.cs-clock div{padding:12px 14px;min-width:68px}.cs-clock b{font-size:26px}}`,
},
changelog: {
label: "Changelog", tag: "What shipped, when, and what broke.", cat: "special",
html: `
<header class="cl-top">
<div class="cl-in"><b>${esc(name)}</b><nav><a href="/">Home</a><a href="/docs/">Docs</a><a href="#">Feed</a></nav></div>
</header>
<section class="cl-intro">
<h1>Changelog</h1>
<p>Every release, newest first. Dates are the day the build went out, not the day the work started.</p>
</section>
<main class="cl">
<article class="cl-rel">
<div class="cl-ver"><b>0.4.0</b><time>12 Sep ${new Date().getFullYear()}</time></div>
<div class="cl-body">
<p class="cl-tags"><span class="t add">added</span><span class="t chg">changed</span></p>
<ul><li>The thing people kept asking for, in one line.</li><li>Another thing, phrased as what the user can now do.</li><li>A setting moved. Say where it went.</li></ul>
</div>
</article>
<article class="cl-rel">
<div class="cl-ver"><b>0.3.2</b><time>28 Aug ${new Date().getFullYear()}</time></div>
<div class="cl-body">
<p class="cl-tags"><span class="t fix">fixed</span></p>
<ul><li>The crash on the thing. Say what triggered it — people want to know if it was them.</li><li>A slow query that is no longer slow.</li></ul>
</div>
</article>
<article class="cl-rel">
<div class="cl-ver"><b>0.3.0</b><time>17 Jul ${new Date().getFullYear()}</time></div>
<div class="cl-body">
<p class="cl-tags"><span class="t add">added</span><span class="t brk">breaking</span></p>
<ul><li>The new capability, in the user's words.</li><li><b>Breaking:</b> what you have to change before upgrading, and what happens if you do not.</li></ul>
</div>
</article>
</main>
<footer class="cl-foot">© ${new Date().getFullYear()} ${esc(name)}</footer>`,
css: `
body{margin:0;background:#08090a;color:#e8ecef;font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;line-height:1.65}
.cl-top{border-bottom:1px solid #191c1f}
.cl-in{max-width:820px;margin:0 auto;padding:18px 24px;display:flex;justify-content:space-between;align-items:center}
.cl-in b{font-size:15px}
.cl-in a{margin-left:20px;color:#7e8792;text-decoration:none;font-size:14px}
.cl-in a:hover{color:#fff}
.cl-intro{max-width:820px;margin:0 auto;padding:56px 24px 32px}
.cl-intro h1{font-size:38px;letter-spacing:-.025em;margin:0 0 10px}
.cl-intro p{margin:0;color:#8b949e;max-width:56ch}
.cl{max-width:820px;margin:0 auto;padding:0 24px 40px}
.cl-rel{display:grid;grid-template-columns:130px 1fr;gap:28px;padding:28px 0;border-top:1px solid #191c1f}
.cl-ver b{display:block;font-size:19px;font-variant-numeric:tabular-nums;color:#d4ff4f}
.cl-ver time{display:block;color:#6e7681;font-size:13px;margin-top:3px}
.cl-tags{margin:0 0 10px;display:flex;gap:6px;flex-wrap:wrap}
.t{font-size:11px;letter-spacing:.08em;text-transform:uppercase;font-weight:600;padding:3px 9px;border-radius:999px;border:1px solid}
.t.add{color:#d4ff4f;border-color:rgba(212,255,79,.35);background:rgba(212,255,79,.08)}
.t.fix{color:#7ee3b0;border-color:rgba(126,227,176,.35);background:rgba(126,227,176,.08)}
.t.chg{color:#8ab4ff;border-color:rgba(138,180,255,.35);background:rgba(138,180,255,.08)}
.t.brk{color:#ff8fa3;border-color:rgba(255,143,163,.35);background:rgba(255,143,163,.08)}
.cl-body ul{margin:0;padding-left:20px;color:#b6bec7}
.cl-body li{margin-bottom:7px}
.cl-body b{color:#ff8fa3}
.cl-foot{border-top:1px solid #191c1f;padding:24px;text-align:center;color:#6e7681;font-size:13px}
@media(max-width:620px){.cl-rel{grid-template-columns:1fr;gap:10px}.cl-ver{display:flex;align-items:baseline;gap:12px}.cl-ver time{margin:0}}`,
},
};
// ---------- editor ----------
function plugin(nameOrObj) {
const p = window[nameOrObj];
return p && (p.default || p);
}
function initEditor() {
const plugins = [];
const pluginsOpts = {};
const use = (global, opts = {}) => { const p = plugin(global); if (p) { plugins.push(p); pluginsOpts[p] = opts; } };
use("gjs-blocks-basic", { flexGrid: true, category: "Layout" }); // the bundle registers itself as gjs-blocks-basic
// useCustomTheme:false — the preset's mauve demo palette used to override
// ours and made the panels low-contrast; the theme lives in studio.html.
use("grapesjs-preset-webpage", { modalImportTitle: "Import HTML", blocks: [], useCustomTheme: false, showStylesOnChange: true });
use("grapesjs-plugin-forms", { category: "Forms" });
use("grapesjs-navbar");
use("grapesjs-tabs");
use("grapesjs-tooltip");
use("grapesjs-custom-code");
use("grapesjs-component-countdown");
use("grapesjs-style-bg");
use("grapesjs-touch");
// PostCSS parser: the browser CSSOM path drops shorthands that contain
// var() (background: var(--surface) came back empty), which stripped
// imported pages of their panels and borders.
use("grapesjs-parser-postcss");
editor = grapesjs.init({
container: "#gjs",
height: "100%",
fromElement: false,
storageManager: false,
plugins,
pluginsOpts,
canvas: { styles: [] },
assetManager: {
upload: false,
uploadFile: async (e) => {
const files = e.dataTransfer ? e.dataTransfer.files : e.target.files;
for (const f of files) await uploadAsset(f);
},
},
});
editor.on("update", () => { if (!loading) markDirty(true); });
setupEditing();
try { initAssistant(editor, { esc, name }); } catch (e) { console.warn("assistant unavailable:", e); }
// Ctrl/Cmd+S saves a draft.
document.addEventListener("keydown", (e) => { if ((e.ctrlKey || e.metaKey) && e.key === "s") { e.preventDefault(); saveDraft(); } });
window.addEventListener("beforeunload", (e) => { if (anyDirty()) { e.preventDefault(); e.returnValue = ""; } });
}
// ---------- Tilda-style editing: resize handles, richer text toolbar,
// ready-made sections, style panel on select ----------
function setupEditing() {
// 1. Every box can be dragged to size: right, bottom and corner handles set
// width/height on the element (images keep their own ratio-aware resizer).
const rz = { tl: 0, tc: 0, tr: 0, cl: 0, cr: 1, bl: 0, bc: 1, br: 1, minDim: 16 };
for (const t of ["default", "text", "link", "video", "map", "table", "row", "cell", "svg", "iframe"]) {
if (editor.Components.getType(t)) editor.Components.addType(t, { model: { defaults: { resizable: rz } } });
}
// 2. Text toolbar: size, colour, highlight, alignment, headings.
const rte = editor.RichTextEditor;
const wrapSel = (r, style) => { const t = String(r.selection()); if (t) r.insertHTML(`<span style="${style}">${esc(t)}</span>`); };
rte.add("fontsize", {
icon: `<select title="Text size">${["", "12px", "14px", "16px", "18px", "20px", "24px", "28px", "32px", "40px", "48px", "64px"].map((v) => `<option value="${v}">${v || "Size"}</option>`).join("")}</select>`,
event: "change",
result: (r, action) => { const v = action.btn.querySelector("select").value; if (v) wrapSel(r, `font-size:${v}`); action.btn.querySelector("select").value = ""; },
});
rte.add("forecolor", { icon: `<input type="color" title="Text colour" value="#111111">`, event: "change", result: (r, action) => r.exec("foreColor", action.btn.querySelector("input").value) });
rte.add("hilite", { icon: `<input type="color" title="Highlight" value="#fff59d">`, event: "change", result: (r, action) => r.exec("hiliteColor", action.btn.querySelector("input").value) });
for (const [name, cmd, glyph, title] of [["alignL", "justifyLeft", "≡", "Align left"], ["alignC", "justifyCenter", "☰", "Centre"], ["alignR", "justifyRight", "≡", "Align right"]]) {
rte.add(name, { icon: `<b title="${title}" style="font-style:normal">${glyph}</b>`, result: (r) => r.exec(cmd) });
}
rte.add("heading", {
icon: `<select title="Paragraph style"><option value="">Style</option><option value="h1">Heading 1</option><option value="h2">Heading 2</option><option value="h3">Heading 3</option><option value="p">Paragraph</option><option value="blockquote">Quote</option></select>`,
event: "change",
result: (r, action) => { const v = action.btn.querySelector("select").value; if (v) r.exec("formatBlock", v); action.btn.querySelector("select").value = ""; },
});
// 3. Fonts the exported page can rely on without loading anything.
const ff = editor.StyleManager.getProperty("typography", "font-family");
if (ff) {
const extra = [
["'DM Sans', system-ui, sans-serif", "DM Sans"], ["Fraunces, Georgia, serif", "Fraunces"], ["Ubuntu, system-ui, sans-serif", "Ubuntu"],
["system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif", "System"], ["Georgia, 'Times New Roman', serif", "Georgia"],
["'JetBrains Mono', ui-monospace, Menlo, monospace", "Mono"],
].map(([id, label]) => ({ id, label }));
ff.set("options", [...extra, ...(ff.get("options") || [])]);
}
// 4. Ready-made sections, Tilda style: drop, then edit text and images in place.
const bm = editor.BlockManager;
const sec = (id, label, html, css, icon = SECTION_ICON) => bm.add(`sx-${id}`, { label, category: "Sections", media: icon, content: `<style>${css}</style>${html}` });
sec("hero", "Hero", `<section class="sx-hero"><div class="sx-wrap"><p class="sx-kicker">NEW</p><h1>A headline that earns the scroll</h1><p class="sx-lead">One sentence on what this is and who it is for. Keep it honest and short.</p><a class="sx-btn" href="#">Get started</a></div></section>`,
`.sx-hero{padding:96px 24px;background:#0b0e14;color:#f1f4fa;text-align:center}.sx-wrap{max-width:820px;margin:0 auto}.sx-kicker{letter-spacing:.2em;font-size:12px;color:#d6ff3d;margin:0 0 12px}.sx-hero h1{font-size:44px;line-height:1.1;margin:0 0 16px}.sx-lead{font-size:18px;color:#b8c2d4;margin:0 0 28px}.sx-btn{display:inline-block;background:#d6ff3d;color:#0b0e14;padding:12px 22px;border-radius:10px;text-decoration:none;font-weight:600}`);
sec("features", "Three features", `<section class="sx-feat"><div class="sx-grid3"><div><h3>Fast</h3><p>Say what it does in one line.</p></div><div><h3>Simple</h3><p>Say why it is easy.</p></div><div><h3>Yours</h3><p>Say what the visitor keeps.</p></div></div></section>`,
`.sx-feat{padding:64px 24px;background:#fff;color:#111}.sx-grid3{max-width:1000px;margin:0 auto;display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:28px}.sx-feat h3{margin:0 0 8px;font-size:20px}.sx-feat p{margin:0;color:#555;line-height:1.6}`);
sec("split", "Image + text", `<section class="sx-split"><div class="sx-split-in"><img src="https://silentmode.st/sirius-x/brand/banner.svg" alt=""><div><h2>Show, then tell</h2><p>A picture on one side, the explanation on the other. Swap the image by clicking it.</p><a class="sx-btn2" href="#">Learn more →</a></div></div></section>`,
`.sx-split{padding:64px 24px;background:#f6f7f9;color:#111}.sx-split-in{max-width:1000px;margin:0 auto;display:grid;grid-template-columns:1fr 1fr;gap:40px;align-items:center}.sx-split img{width:100%;border-radius:14px}.sx-split h2{font-size:32px;margin:0 0 12px}.sx-split p{color:#555;line-height:1.6}.sx-btn2{color:#111;font-weight:600;text-decoration:none}@media(max-width:700px){.sx-split-in{grid-template-columns:1fr}}`);
sec("gallery", "Gallery", `<section class="sx-gal"><div class="sx-gal-grid">${Array.from({ length: 6 }, () => `<img src="https://silentmode.st/sirius-x/brand/avatar.svg" alt="">`).join("")}</div></section>`,
`.sx-gal{padding:48px 24px;background:#fff}.sx-gal-grid{max-width:1000px;margin:0 auto;display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:12px}.sx-gal img{width:100%;aspect-ratio:1;object-fit:cover;border-radius:12px;background:#eee}`);
sec("quote", "Testimonial", `<section class="sx-quote"><blockquote>“Put the kindest true thing a customer said right here.”</blockquote><p class="sx-who">— A real person, their role</p></section>`,
`.sx-quote{padding:72px 24px;background:#0b0e14;color:#f1f4fa;text-align:center}.sx-quote blockquote{max-width:760px;margin:0 auto 14px;font-size:26px;line-height:1.35;font-style:italic}.sx-who{color:#b8c2d4;margin:0}`);
sec("pricing", "Pricing", `<section class="sx-price"><div class="sx-grid3"><div class="sx-plan"><h3>Basic</h3><p class="sx-amt">$0</p><ul><li>One page</li><li>Your name</li></ul><a class="sx-btn" href="#">Choose</a></div><div class="sx-plan sx-hot"><h3>Pro</h3><p class="sx-amt">$9</p><ul><li>Everything in Basic</li><li>Priority help</li></ul><a class="sx-btn" href="#">Choose</a></div><div class="sx-plan"><h3>Team</h3><p class="sx-amt">$29</p><ul><li>Everything in Pro</li><li>Five seats</li></ul><a class="sx-btn" href="#">Choose</a></div></div></section>`,
`.sx-price{padding:64px 24px;background:#f6f7f9;color:#111}.sx-price .sx-grid3{max-width:1000px;margin:0 auto;display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:20px}.sx-plan{background:#fff;border:1px solid #e6e8ee;border-radius:16px;padding:28px 24px;text-align:center}.sx-hot{border-color:#111}.sx-amt{font-size:40px;font-weight:700;margin:8px 0 16px}.sx-plan ul{list-style:none;padding:0;margin:0 0 20px;color:#555;line-height:1.9}.sx-price .sx-btn{display:inline-block;background:#111;color:#fff;padding:10px 20px;border-radius:10px;text-decoration:none;font-weight:600}`);
sec("cta", "Call to action", `<section class="sx-cta"><h2>Ready when you are</h2><p>One line that removes the last doubt.</p><a class="sx-btn" href="#">Do the thing →</a></section>`,
`.sx-cta{padding:72px 24px;text-align:center;background:#d6ff3d;color:#0b0e14}.sx-cta h2{font-size:34px;margin:0 0 8px}.sx-cta p{margin:0 0 24px;font-size:17px}.sx-cta .sx-btn{display:inline-block;background:#0b0e14;color:#d6ff3d;padding:12px 22px;border-radius:10px;text-decoration:none;font-weight:600}`);
sec("contact", "Contact form", `<section class="sx-contact"><div class="sx-wrap"><h2>Get in touch</h2><form class="sx-form" method="post"><input type="text" name="name" placeholder="Your name"><input type="email" name="email" placeholder="Email"><textarea name="message" rows="4" placeholder="Message"></textarea><button type="submit" class="sx-btn" data-gjs-type="text">Send</button></form></div></section>`,
`.sx-contact{padding:64px 24px;background:#fff;color:#111}.sx-contact .sx-wrap{max-width:560px;margin:0 auto}.sx-contact h2{margin:0 0 16px}.sx-form{display:grid;gap:10px}.sx-form input,.sx-form textarea{width:100%;padding:12px;border:1px solid #d9dce3;border-radius:10px;font:inherit}.sx-contact .sx-btn{background:#111;color:#fff;border:0;padding:12px 20px;border-radius:10px;font-weight:600;cursor:pointer}`);
sec("footer", "Footer", `<footer class="sx-footer"><div class="sx-wrap sx-foot-in"><span>© <b>your name</b> · on Bitcoin Cash</span><nav><a href="#">About</a><a href="#">Contact</a><a href="#">Privacy</a></nav></div></footer>`,
`.sx-footer{padding:28px 24px;background:#0b0e14;color:#b8c2d4;font-size:14px}.sx-foot-in{max-width:1000px;margin:0 auto;display:flex;justify-content:space-between;gap:14px;flex-wrap:wrap}.sx-footer a{color:#b8c2d4;text-decoration:none;margin-left:16px}.sx-footer b{color:#f1f4fa}`);
// Ten more, added 2026-09: navigation, social proof and the small print
// that every real page ends up needing. Each block owns its own class
// namespace (.sx-nav1, .sx-faq, …) and never reuses a selector another
// block defines, so dropping two of them on one page cannot make the
// first one change shape. `data-gjs-type="text"` marks the elements the
// parser would otherwise treat as opaque — the editor eats the attribute,
// it never reaches the exported page.
sec("nav-simple", "Nav — simple", `<header class="sx-nav1"><div class="sx-nav1-in"><a class="sx-nav1-logo" href="/">your name</a><nav class="sx-nav1-links"><a href="#">Work</a><a href="#">About</a><a href="#">Contact</a></nav></div></header>`,
`.sx-nav1{background:#fff;border-bottom:1px solid #e8eaee}.sx-nav1-in{max-width:1040px;margin:0 auto;padding:16px 24px;display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap}.sx-nav1-logo{color:#111;font-weight:700;font-size:16px;text-decoration:none}.sx-nav1-links a{color:#5a6069;text-decoration:none;font-size:14.5px;margin-left:22px}.sx-nav1-links a:hover{color:#111}@media(max-width:480px){.sx-nav1-links a{margin:0 16px 0 0}}`, NAV_ICON);
sec("nav-cta", "Nav — with button", `<header class="sx-nav2"><div class="sx-nav2-in"><a class="sx-nav2-logo" href="/">your name</a><nav class="sx-nav2-links"><a href="#">Product</a><a href="#">Pricing</a><a href="#">Docs</a><a href="#">Blog</a><a href="#">Support</a></nav><a class="sx-nav2-btn" href="#">Get started</a></div></header>`,
`.sx-nav2{background:#0b0e14;color:#f1f4fa}.sx-nav2-in{max-width:1100px;margin:0 auto;padding:14px 24px;display:flex;align-items:center;gap:18px;flex-wrap:wrap}.sx-nav2-logo{color:#f1f4fa;font-weight:700;font-size:16px;text-decoration:none;margin-right:10px}.sx-nav2-links{flex:1;display:flex;gap:22px;flex-wrap:wrap}.sx-nav2-links a{color:#9aa4b5;text-decoration:none;font-size:14.5px}.sx-nav2-links a:hover{color:#fff}.sx-nav2-btn{background:#d6ff3d;color:#0b0e14;font-weight:700;font-size:14px;padding:9px 18px;border-radius:9px;text-decoration:none;white-space:nowrap}`, NAV_ICON);
sec("video-hero", "Video hero", `<section class="sx-vhero"><video class="sx-vhero-bg" data-gjs-controls="false" data-gjs-autoplay="true" data-gjs-loop="true" muted playsinline></video><div class="sx-vhero-in"><h1>Say it over moving pictures</h1><p>Click the video and set its source in the panel on the right — an MP4 you uploaded works, and so does any public URL. Until then the gradient stands in.</p><a class="sx-vhero-btn" href="#">Watch the film</a></div></section>`,
`.sx-vhero{position:relative;min-height:70vh;display:flex;align-items:center;justify-content:center;text-align:center;padding:80px 24px;overflow:hidden;background:linear-gradient(140deg,#131a2b,#2c1a3a 55%,#0b0e14)}.sx-vhero-bg{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;border:0}.sx-vhero-in{position:relative;max-width:720px;color:#fff;text-shadow:0 2px 24px rgba(0,0,0,.55)}.sx-vhero h1{font-size:46px;line-height:1.1;margin:0 0 14px;letter-spacing:-.02em}.sx-vhero p{font-size:17px;color:rgba(255,255,255,.82);margin:0 0 26px}.sx-vhero-btn{display:inline-block;background:#fff;color:#111;font-weight:700;padding:13px 26px;border-radius:999px;text-decoration:none}@media(max-width:600px){.sx-vhero h1{font-size:30px}}`);
sec("stats", "Big numbers", `<section class="sx-stat"><div class="sx-stat-in"><div><b>12k</b><span data-gjs-type="text">names registered</span></div><div><b>99.9%</b><span data-gjs-type="text">gateway uptime</span></div><div><b>4</b><span data-gjs-type="text">chains supported</span></div><div><b>$0</b><span data-gjs-type="text">renewal fees, ever</span></div></div></section>`,
`.sx-stat{padding:56px 24px;background:#f6f7f9;color:#111}.sx-stat-in{max-width:1000px;margin:0 auto;display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:32px;text-align:center}.sx-stat b{display:block;font-size:44px;font-weight:700;letter-spacing:-.03em;line-height:1.05;font-variant-numeric:tabular-nums}.sx-stat span{display:block;margin-top:6px;color:#6b727c;font-size:14px}`);
sec("steps", "Numbered steps", `<section class="sx-steps"><h2>How it works</h2><div class="sx-steps-in"><div class="sx-step"><span class="sx-step-n" data-gjs-type="text">1</span><h3>Pick a name</h3><p>Search, check it is free, pay once.</p></div><div class="sx-step"><span class="sx-step-n" data-gjs-type="text">2</span><h3>Build the page</h3><p>Drag sections in, rewrite the words.</p></div><div class="sx-step"><span class="sx-step-n" data-gjs-type="text">3</span><h3>Publish</h3><p>One click puts it on Sia, signed by you.</p></div></div></section>`,
`.sx-steps{padding:64px 24px;background:#fff;color:#111}.sx-steps>h2{max-width:1000px;margin:0 auto 32px;font-size:28px;letter-spacing:-.015em}.sx-steps-in{max-width:1000px;margin:0 auto;display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:28px}.sx-step-n{display:inline-flex;align-items:center;justify-content:center;width:36px;height:36px;border-radius:50%;background:#111;color:#fff;font-weight:700;font-size:16px;margin-bottom:12px}.sx-step h3{margin:0 0 6px;font-size:18px}.sx-step p{margin:0;color:#5a6069;line-height:1.6}`);
sec("team", "Team", `<section class="sx-team"><h2>Who you would be working with</h2><div class="sx-team-in"><div class="sx-member"><img src="https://silentmode.st/sirius-x/brand/avatar.svg" alt=""><b>A. Person</b><i data-gjs-type="text">Founder</i><p>One line on what they do and what they did before.</p></div><div class="sx-member"><img src="https://silentmode.st/sirius-x/brand/avatar.svg" alt=""><b>B. Person</b><i data-gjs-type="text">Design</i><p>One line on what they do and what they did before.</p></div><div class="sx-member"><img src="https://silentmode.st/sirius-x/brand/avatar.svg" alt=""><b>C. Person</b><i data-gjs-type="text">Engineering</i><p>One line on what they do and what they did before.</p></div></div></section>`,
`.sx-team{padding:64px 24px;background:#fff;color:#111}.sx-team>h2{max-width:980px;margin:0 auto 34px;font-size:28px;letter-spacing:-.015em}.sx-team-in{max-width:980px;margin:0 auto;display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:34px}.sx-member img{width:96px;height:96px;border-radius:50%;object-fit:cover;background:#eceef1;display:block;margin-bottom:14px}.sx-member b{display:block;font-size:16px}.sx-member i{display:block;font-style:normal;color:#8b9099;font-size:13.5px;margin-bottom:8px}.sx-member p{margin:0;color:#5a6069;font-size:15px;line-height:1.6}`);
sec("logos", "Logo cloud", `<section class="sx-logos"><p class="sx-logos-lab" data-gjs-type="text">Trusted by people who read the source</p><div class="sx-logos-in">${Array.from({ length: 6 }, () => `<img src="https://silentmode.st/sirius-x/brand/avatar.svg" alt="">`).join("")}</div></section>`,
`.sx-logos{padding:48px 24px;background:#fafbfc;border-top:1px solid #eceef1;border-bottom:1px solid #eceef1}.sx-logos-lab{max-width:1000px;margin:0 auto 26px;text-align:center;font-size:12px;letter-spacing:.14em;text-transform:uppercase;color:#98a0ab}.sx-logos-in{max-width:1000px;margin:0 auto;display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:24px 48px}.sx-logos img{height:34px;width:auto;filter:grayscale(1);opacity:.6;transition:filter .18s,opacity .18s}.sx-logos img:hover{filter:none;opacity:1}`);
sec("timeline", "Timeline", `<section class="sx-tl"><h2>How we got here</h2><div class="sx-tl-in"><div class="sx-tl-row"><time data-gjs-type="text">2021</time><div><b>It started as a weekend thing</b><p>What happened, in one or two sentences.</p></div></div><div class="sx-tl-row"><time data-gjs-type="text">2023</time><div><b>The first real users</b><p>What happened, in one or two sentences.</p></div></div><div class="sx-tl-row"><time data-gjs-type="text">2024</time><div><b>It paid for itself</b><p>What happened, in one or two sentences.</p></div></div><div class="sx-tl-row"><time data-gjs-type="text">Today</time><div><b>Where it is now</b><p>What happened, in one or two sentences.</p></div></div></div></section>`,
`.sx-tl{padding:64px 24px;background:#0b0e14;color:#e8ecf2}.sx-tl>h2{max-width:720px;margin:0 auto 34px;font-size:28px;letter-spacing:-.015em}.sx-tl-in{max-width:720px;margin:0 auto;border-left:2px solid #232a36;padding-left:26px}.sx-tl-row{position:relative;padding-bottom:28px}.sx-tl-row:last-child{padding-bottom:0}.sx-tl-row::before{content:"";position:absolute;left:-33px;top:6px;width:12px;height:12px;border-radius:50%;background:#d6ff3d}.sx-tl-row time{display:block;font-size:13px;letter-spacing:.06em;color:#d6ff3d;margin-bottom:4px}.sx-tl-row b{display:block;font-size:17px;margin-bottom:4px}.sx-tl-row p{margin:0;color:#98a3b4;line-height:1.6}`);
sec("faq", "FAQ", `<section class="sx-faq"><h2>Questions people actually ask</h2><div class="sx-faq-in"><details class="sx-faq-i"><summary data-gjs-type="text">What happens if this site goes away?</summary><p>Answer it straight. The shortest true answer builds more trust than the longest reassuring one.</p></details><details class="sx-faq-i"><summary data-gjs-type="text">Do I need a wallet?</summary><p>Answer it straight.</p></details><details class="sx-faq-i"><summary data-gjs-type="text">What does it cost, in total?</summary><p>Answer it straight.</p></details><details class="sx-faq-i"><summary data-gjs-type="text">Can I move it somewhere else later?</summary><p>Answer it straight.</p></details><details class="sx-faq-i"><summary data-gjs-type="text">Who can see what I publish?</summary><p>Answer it straight.</p></details><details class="sx-faq-i"><summary data-gjs-type="text">How do I get help?</summary><p>Answer it straight.</p></details></div></section>`,
`.sx-faq{padding:64px 24px;background:#fff;color:#111}.sx-faq>h2{max-width:720px;margin:0 auto 26px;font-size:28px;letter-spacing:-.015em}.sx-faq-in{max-width:720px;margin:0 auto}.sx-faq-i{border-bottom:1px solid #e8eaee}.sx-faq-i summary{cursor:pointer;padding:16px 28px 16px 0;font-size:17px;font-weight:600;position:relative;list-style:none}.sx-faq-i summary::-webkit-details-marker{display:none}.sx-faq-i summary::after{content:"+";position:absolute;right:4px;top:14px;font-size:20px;font-weight:400;color:#98a0ab}.sx-faq-i[open] summary::after{content:""}.sx-faq-i p{margin:0 0 18px;color:#5a6069;line-height:1.65;max-width:62ch}`);
sec("newsletter", "Signup band", `<section class="sx-news"><div class="sx-news-in"><div><h2>One email a month, nothing else</h2><p>Say what is in it. People give an address to a promise, not to a form.</p></div><form class="sx-news-form" method="post"><input type="email" name="email" placeholder="you@example" required><button type="submit" data-gjs-type="text">Subscribe</button></form></div></section>`,
`.sx-news{padding:52px 24px;background:#d6ff3d;color:#0b0e14}.sx-news-in{max-width:1000px;margin:0 auto;display:flex;align-items:center;justify-content:space-between;gap:32px;flex-wrap:wrap}.sx-news h2{margin:0 0 6px;font-size:26px;letter-spacing:-.015em}.sx-news p{margin:0;font-size:15.5px;opacity:.75;max-width:46ch}.sx-news-form{display:flex;gap:8px;flex-wrap:wrap;flex:1;min-width:280px;max-width:440px}.sx-news-form input{flex:1;min-width:180px;padding:13px 15px;border:1px solid rgba(11,14,20,.22);border-radius:10px;font:inherit;background:#fff}.sx-news-form button{padding:13px 22px;border:0;border-radius:10px;background:#0b0e14;color:#d6ff3d;font:inherit;font-weight:700;cursor:pointer}`);
// Group the plugin blocks under readable categories.
const cat = { navbar: "Sections", tabs: "Widgets", tooltip: "Widgets", "custom-code": "Widgets", countdown: "Widgets" };
bm.getAll().forEach((b) => { const c = cat[b.getId()]; if (c) b.set("category", c); });
// 5. Selecting anything opens the style panel; the layer tree is one click away.
editor.on("component:selected", () => { const btn = editor.Panels.getButton("views", "open-sm"); if (btn && !btn.get("active")) btn.set("active", true); });
}
const NAV_ICON = `<svg viewBox="0 0 24 24" width="36" height="36"><rect x="2" y="6" width="20" height="5" rx="1.5" fill="currentColor" opacity=".9"/><rect x="2" y="14" width="9" height="2" rx="1" fill="currentColor" opacity=".35"/><rect x="13" y="14" width="9" height="2" rx="1" fill="currentColor" opacity=".35"/></svg>`;
const SECTION_ICON = `<svg viewBox="0 0 24 24" width="36" height="36"><rect x="2" y="4" width="20" height="5" rx="1.5" fill="currentColor" opacity=".9"/><rect x="2" y="11" width="20" height="9" rx="1.5" fill="currentColor" opacity=".45"/></svg>`;
function loadTemplate(key) {
const t = TEMPLATES[key] || TEMPLATES.blank;
importedCss = ""; extraHeadLinks = []; importedHtmlAttrs = {}; injectImportedCss();
editor.setComponents(t.html);
editor.setStyle(t.css);
markDirty(true);
}
async function uploadAsset(file) {
const safe = file.name.toLowerCase().replace(/[^a-z0-9._-]/g, "-").replace(/-+/g, "-");
const path = `assets/${Date.now().toString(36)}-${safe}`;
status(`Uploading ${file.name}`, "busy");
try {
const bytes = new Uint8Array(await file.arrayBuffer());
await putFile(path, bytes, file.type || "application/octet-stream");
editor.AssetManager.add({ src: readUrl(path), name: file.name, type: "image" });
status(`Uploaded ${file.name}`, "ok");
} catch (e) { status("Upload failed: " + (e.message || e), "err"); }
}
// Exported page: inline CSS, asset URLs rewritten to be relative to the
// site folder so the page works from Sia, any resolver and the gateway.
// A page in a sub-folder needs as many ../ as it is deep, or every asset it
// shows in the editor turns into a 404 the moment it is published.
function exportHtml(page = pages[current] || { path: "index.html", title: name }) {
const html = editor.getHtml();
const css = editor.getCss();
const up = "../".repeat(pageDepth(page.path));
const rel = (s) => s.split(readUrl("")).join(up);
const links = extraHeadLinks.map((h) => `<link rel="stylesheet" href="${esc(h)}">`).join("\n");
const htmlAttrs = Object.entries({ lang: document.documentElement.lang || "en", ...importedHtmlAttrs }).map(([k, v]) => `${k}="${esc(v)}"`).join(" ");
return `<!doctype html>
<html ${htmlAttrs}>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${esc(page.path === "index.html" ? name : `${page.title} · ${name}`)}</title>
<meta name="generator" content="Sirius Studio">
${links}
<style>
${rel(importedCss)}
${rel(css)}
</style>
</head>
${rel(html)}
</html>
`;
}
// ---------- draft / publish ----------
function pushStep(t) { const d = document.createElement("div"); d.textContent = t; $("pub-steps").appendChild(d); $("pub-steps").scrollTop = 1e6; }
async function saveDraft() {
if (!editor || !wallet) return;
capturePage();
status("Saving draft…", "busy");
try {
await putFile("_studio.json", draftJson(), "application/json");
markDirty(false);
status("Draft saved on Sia", "ok");
} catch (e) { status("Draft not saved: " + (e.message || e), "err"); }
}
async function publish() {
if (!editor || !wallet) return;
$("pub-name").textContent = name; $("pub-steps").innerHTML = ""; $("pub-view").hidden = true; $("pub").hidden = false;
$("pub-intro-multi").hidden = pages.length < 2;
$("btn-publish").disabled = true;
const wasOn = current;
try {
capturePage();
// Every page, every time: a site whose pages link to each other is only
// coherent if they go up together.
for (let i = 0; i < pages.length; i++) {
applyPage(i);
const html = exportHtml(pages[i]);
const res = await putFile(pages[i].path, html, "text/html; charset=utf-8");
pushStep(`uploaded ${pages[i].path} · ${html.length.toLocaleString()} bytes → ${res.sia_key}`);
}
applyPage(wasOn);
if (pages.length > 1) {
await putFile("sitemap.xml", sitemapXml(), "application/xml");
pushStep(`wrote sitemap.xml · ${pages.length} pages`);
}
// Pages dropped since the last publish are swept now, rather than left
// serving something the owner believes they deleted.
for (const path of removed.splice(0)) {
try { await delFile(path); pushStep(`removed ${path}`); }
catch (e) { pushStep(`could not remove ${path}: ${e.message || e}`); }
}
await putFile("_studio.json", draftJson(), "application/json");
pushStep("saved editor project (_studio.json)");
markDirty(false);
// Point the name at the folder if it does not already. Re-read the
// registry first: `entry` from boot can be stale or missing (a name
// indexed after the page opened), and a stale view here means a
// needless on-chain transaction on every publish.
try { const r = await fetch(`${API}/api/name/${encodeURIComponent(name)}`, { cache: "no-store" }); if (r.ok) entry = await r.json(); } catch {}
const rec = entry?.records || {};
const norm = (v) => String(v || "").trim().replace(/\/+$/, "") + "/";
const pointsHere = norm(rec.s3) === folder;
if (!pointsHere && !entry) {
pushStep("the registry has not indexed this name yet — files are up; pointing the name is skipped until it appears (or set Hosting in the dashboard)");
} else if (!pointsHere) {
pushStep(`name points at ${rec.s3 ? `"${rec.s3}"` : "nothing"} — setting s3 = ${folder} on chain`);
const next = { ...rec, s3: folder };
delete next.h; // inline HTML would shadow the Sia site
let el = null;
try {
el = await BNS.connect();
const r = await BNS.setRecordsWithBuiltInWallet(el, { wallet, name, records: next, onProgress: (s) => pushStep(String(s)) });
pushStep(`broadcast ${r.txid || r.txId || ""}`);
entry = { ...(entry || {}), records: next };
} finally { try { el?.close?.(); } catch {} }
pushStep("resolvers switch to the new site within a block");
} else {
pushStep("name already points at this folder — live now");
}
$("pub-view").href = siteUrl(); $("pub-view").hidden = false;
$("btn-view").href = siteUrl(); $("btn-view").hidden = false;
status("Published", "ok");
} catch (e) {
pushStep("error: " + (e.message || e));
status("Publish failed", "err");
} finally { $("btn-publish").disabled = false; }
}
// ---------- boot ----------
(async function boot() {
if (!name) { location.replace("./portal.html#studio"); return; }
$("site-name").innerHTML = `${esc(name.split(".")[0])}.<span class="tld">${esc(name.split(".").slice(1).join("."))}</span>`;
$("picker-name").textContent = name;
document.title = `${name} — Sirius Studio`;
wallet = await restoreWallet();
if (!wallet) { $("gate-link").href = `./portal.html?next=${encodeURIComponent(location.pathname + location.search)}`; $("gate").hidden = false; status("Not signed in", "err"); return; }
status("Loading name…");
try { const r = await fetch(`${API}/api/name/${encodeURIComponent(name)}`, { cache: "no-store" }); entry = r.ok ? await r.json() : null; } catch { entry = null; }
initEditor();
pages = [newPage("index.html", "Home")]; // a site with no draft still has a home page in the list
current = 0;
renderPages();
$("btn-publish").disabled = false;
const src = liveSource();
if (src) { $("btn-view").href = siteUrl(); $("btn-view").hidden = false; $("btn-import").hidden = false; }
// Start from the right thing: the saved Studio draft if there is one and
// it is what the name serves; otherwise the page the name serves right now
// (wherever it lives); otherwise the template picker.
const { files } = await listFiles();
const draft = files.find((f) => f.path === "_studio.json");
const studioIndex = files.find((f) => f.path === "index.html");
if (draft) {
try {
const j = await (await fetch(readUrl("_studio.json"), { cache: "no-store" })).json();
if (j?.project || Array.isArray(j?.pages)) {
loadDraft(j);
status(`Loaded draft from ${j.saved_at ? new Date(j.saved_at).toLocaleString() : "Sia"}`, "ok");
}
if (src && src.kind !== "studio") notice(`This draft is not what visitors see: the name currently serves ${src.label}.`);
else if (studioIndex && j?.saved_at && studioIndex.modified && Date.parse(studioIndex.modified) > Date.parse(j.saved_at) + 60_000) notice("index.html on Sia is newer than this draft — it was published by another tool.");
} catch (e) { status("Could not load the saved project: " + (e.message || e), "err"); showPicker(); }
} else if (src) {
const ok = await importLive({ confirmIfDirty: false });
if (ok) { markDirty(false); if (src.kind !== "studio") notice(`Imported the page the name serves from ${src.label}. Publishing writes to ${folder} and repoints the name there.`); }
else { showPicker(); }
} else {
showPicker(); status("New site");
}
})();
$("btn-import").addEventListener("click", () => importLive());
$("notice-import").addEventListener("click", () => importLive());
$("notice-close").addEventListener("click", () => { $("notice").hidden = true; });
// ---------- template gallery ----------
// One renderer feeds both the first-run picker and the top-bar gallery, and
// the plain <select> behind them, so a new entry in TEMPLATES shows up in all
// three without being listed anywhere else.
const TPL_CATS = [["all", "All"], ["landing", "Landing"], ["blog", "Blog"], ["business", "Business"], ["special", "Special"]];
// The card preview is the template itself in a sandboxed iframe, scaled to a
// quarter. No screenshot pipeline to keep in sync, and a template that breaks
// shows itself broken instead of hiding behind a stale image.
const previewDoc = (t) => `<!doctype html><html><head><meta charset="utf-8"><style>html{background:#fff;overflow:hidden}${t.css}</style></head><body>${t.html}</body></html>`;
function buildCards(gridEl, tabsEl, onPick) {
tabsEl.textContent = "";
for (const [cat, label] of TPL_CATS) {
const b = document.createElement("button");
b.type = "button"; b.className = "gal-tab" + (cat === "all" ? " on" : "");
b.dataset.cat = cat; b.textContent = label;
b.setAttribute("role", "tab"); b.setAttribute("aria-selected", cat === "all" ? "true" : "false");
tabsEl.appendChild(b);
}
gridEl.textContent = "";
for (const [key, t] of Object.entries(TEMPLATES)) {
const card = document.createElement("button");
card.type = "button"; card.className = "gal-card"; card.dataset.tpl = key; card.dataset.cat = t.cat;
const prev = document.createElement("div"); prev.className = "gal-prev";
const fr = document.createElement("iframe");
fr.setAttribute("sandbox", ""); // previews are inert: no scripts, no storage, no navigation
fr.setAttribute("tabindex", "-1"); // the card is the tab stop, not its picture
fr.setAttribute("aria-hidden", "true");
// Not loading="lazy": the cards are built while the sheet is still hidden,
// so every preview counts as off-screen and none of them ever loads. They
// are inert 1-2 KB documents and the grid is only built once.
fr.srcdoc = previewDoc(t);
prev.appendChild(fr);
const b = document.createElement("b"); b.textContent = t.label || key;
const span = document.createElement("span"); span.textContent = t.tag || "";
card.append(prev, b, span);
gridEl.appendChild(card);
}
tabsEl.addEventListener("click", (e) => {
const tab = e.target.closest(".gal-tab"); if (!tab) return;
const cat = tab.dataset.cat;
for (const t of tabsEl.children) { const on = t === tab; t.classList.toggle("on", on); t.setAttribute("aria-selected", String(on)); }
for (const c of gridEl.children) c.hidden = cat !== "all" && c.dataset.cat !== cat;
});
gridEl.addEventListener("click", (e) => {
const card = e.target.closest(".gal-card"); if (card) onPick(card.dataset.tpl);
});
}
let galleryReturn = null; // what had focus before the gallery took it
function openGallery() {
const grid = $("gal-grid");
if (!grid.children.length) buildCards(grid, $("gal-tabs"), pickFromGallery);
galleryReturn = document.activeElement;
$("gallery").hidden = false;
$("gal-tabs").firstElementChild?.focus();
}
function closeGallery() {
$("gallery").hidden = true;
try { galleryReturn?.focus?.(); } catch {}
galleryReturn = null;
}
function pickFromGallery(key) {
if (!confirm("Replace the current page with this template?")) return;
loadTemplate(key);
closeGallery();
status("Template loaded — edit, then Publish");
}
// The first-run picker has nothing to replace, so it skips the confirmation.
function showPicker() {
const grid = $("picker-grid");
if (!grid.children.length) buildCards(grid, $("picker-tabs"), (key) => {
loadTemplate(key); $("picker").hidden = true; status("Template loaded — edit, then Publish");
});
$("picker").hidden = false;
}
$("btn-templates").addEventListener("click", openGallery);
$("gal-close").addEventListener("click", closeGallery);
$("gallery").addEventListener("click", (e) => { if (e.target === $("gallery")) closeGallery(); });
// Esc closes; Tab stays inside the sheet while it is open.
document.addEventListener("keydown", (e) => {
if ($("gallery").hidden) return;
if (e.key === "Escape") { e.preventDefault(); closeGallery(); return; }
if (e.key !== "Tab") return;
const stops = [...$("gallery").querySelectorAll("button")].filter((el) => el.getClientRects().length);
if (!stops.length) return;
const first = stops[0], last = stops[stops.length - 1];
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
});
// The plain list behind the gallery, filled from the same map.
(function fillTemplateSelect() {
const sel = $("tpl-select");
const first = sel.options[0]; // "Templates…" — keep the node, i18n owns its text
sel.textContent = "";
sel.appendChild(first);
for (const [key, t] of Object.entries(TEMPLATES)) {
const o = document.createElement("option");
o.value = key; o.textContent = t.label || key;
sel.appendChild(o);
}
})();
$("tpl-select").addEventListener("change", (e) => {
const v = e.target.value; e.target.value = "";
if (!v) return;
if (!confirm("Replace the current page with this template?")) return;
loadTemplate(v);
});
$("btn-preview").addEventListener("click", () => {
const w = window.open("", "_blank"); if (!w) return;
w.document.open(); w.document.write(exportHtml()); w.document.close();
});
$("btn-draft").addEventListener("click", saveDraft);
$("btn-publish").addEventListener("click", publish);
$("pub-close").addEventListener("click", () => { $("pub").hidden = true; });