sirius/js/studio.js

1502 lines
104 KiB
JavaScript
Raw Permalink Normal View History

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