sirius/js/studio.js
Silent Mode 3d9301d239 feat(studio): twelve more starting points so a name is not stuck with a landing page
The four templates covered a launch page and a link hub. Everyone else —
a writer, a restaurant, a shop, a project that ships releases — started
from Blank and rebuilt the same scaffolding by hand. These twelve cover
the shapes people actually asked for, each one written to be overwritten:
plain copy that reads as an instruction, no images to source, no
JavaScript to keep working.

Every entry now carries its own card text and filter category, and the
top-bar list is generated from the map, so adding the thirteenth is one
object literal and nothing else.
2026-09-21 00:48:35 +02:00

1190 lines
78 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

// Sirius Studio — a site builder for BCNR names.
//
// GrapesJS (BSD-3, vendored under ./vendor/grapesjs/) edits the page. On
// Publish the exported HTML+CSS becomes index.html in the name's own folder
// on Sia — bns/<name>/ — uploaded through the gateway's /api/site route,
// every request signed with the wallet key that holds the name's NFT. The
// editor's project data is saved next to it (_studio.json) so the site can
// be reopened and edited later. If the name's on-chain s3 record does not
// point at that folder yet, Publish also sends one UPD that sets it.
//
// The gateway never holds a key: it verifies each upload's signature
// against the current NFT owner and refuses writes outside bns/<name>/.
import * as BNS from "https://silentmode.st/js/bns-register.js?v=20260920usd";
import { initAssistant } from "./studio-ai.js?v=20260920ai";
const API = "https://silentmode.st";
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
const name = (new URLSearchParams(location.search).get("name") || "").toLowerCase().trim();
const folder = name ? `bns/${name}/` : null;
const readUrl = (path) => `${API}/api/site/${encodeURIComponent(name)}/${path}`;
const siteUrl = () => `${API}/bns/${encodeURIComponent(name)}/`;
let wallet = null;
let entry = null; // { records, category, ... } from /api/name
let editor = null;
let dirty = false;
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 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);
dirty = true; $("btn-draft").disabled = false;
$("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; }
// ---------- 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>Key</th><th>Default</th><th>What it does</th></tr></thead>
<tbody><tr><td>port</td><td>8080</td><td>Where it listens.</td></tr><tr><td>data_dir</td><td>./data</td><td>Where state is kept.</td></tr><tr><td>log_level</td><td>info</td><td>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">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">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">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", () => { dirty = true; $("btn-draft").disabled = false; });
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 (dirty) { 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) => bm.add(`sx-${id}`, { label, category: "Sections", media: SECTION_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">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}`);
// 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 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);
dirty = true; $("btn-draft").disabled = false;
}
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.
function exportHtml() {
const html = editor.getHtml();
const css = editor.getCss();
const rel = (s) => s.split(readUrl("")).join("");
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(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;
status("Saving draft…", "busy");
try {
const data = JSON.stringify({ v: 1, name, saved_at: new Date().toISOString(), project: editor.getProjectData(), importedCss, extraHeadLinks, importedHtmlAttrs });
await putFile("_studio.json", data, "application/json");
dirty = false; $("btn-draft").disabled = true;
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;
$("btn-publish").disabled = true;
try {
const html = exportHtml();
pushStep(`exported page · ${html.length.toLocaleString()} bytes`);
const res = await putFile("index.html", html, "text/html; charset=utf-8");
pushStep(`uploaded index.html → ${res.sia_key}`);
const data = JSON.stringify({ v: 1, name, saved_at: new Date().toISOString(), project: editor.getProjectData(), importedCss, extraHeadLinks, importedHtmlAttrs });
await putFile("_studio.json", data, "application/json");
pushStep("saved editor project (_studio.json)");
dirty = false; $("btn-draft").disabled = true;
// 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();
$("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) {
editor.loadProjectData(j.project);
importedCss = typeof j.importedCss === "string" ? j.importedCss : ""; extraHeadLinks = Array.isArray(j.extraHeadLinks) ? j.extraHeadLinks : [];
importedHtmlAttrs = j.importedHtmlAttrs && typeof j.importedHtmlAttrs === "object" ? j.importedHtmlAttrs : {};
editor.once("canvas:frame:load", injectImportedCss); injectImportedCss();
dirty = false; 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"); $("picker").hidden = false; }
} else if (src) {
const ok = await importLive({ confirmIfDirty: false });
if (ok) { dirty = false; $("btn-draft").disabled = true; if (src.kind !== "studio") notice(`Imported the page the name serves from ${src.label}. Publishing writes to ${folder} and repoints the name there.`); }
else { $("picker").hidden = false; }
} else {
$("picker").hidden = false; status("New site");
}
})();
$("btn-import").addEventListener("click", () => importLive());
$("notice-import").addEventListener("click", () => importLive());
$("notice-close").addEventListener("click", () => { $("notice").hidden = true; });
$("picker").addEventListener("click", (e) => {
const b = e.target.closest("[data-tpl]"); if (!b) return;
loadTemplate(b.dataset.tpl); $("picker").hidden = true; status("Template loaded — edit, then Publish");
});
// The template list in the top bar is generated from TEMPLATES, so adding a
// template is a one-place change.
(function fillTemplateSelect() {
const sel = $("tpl-select");
const first = sel.options[0]; // "Templates…" — keep the node, i18n owns its text
sel.innerHTML = "";
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; });