270 lines
14 KiB
HTML
270 lines
14 KiB
HTML
<!DOCTYPE html>
|
|
<!--
|
|
Bundle plexus — the template. `make dist bundle` inlines everything marked
|
|
%%…%% and writes one self-contained file to gen/plexuses/bundle/index.html.
|
|
|
|
What this page is FOR: showing the three themes doing real work. Every panel
|
|
renders genuine output — showcase.py runs modelgen, datagen and shuntgen over
|
|
the fixtures at build time — so the page is simultaneously a demo of the tools
|
|
and a specimen of the theme. A list of tool names would prove neither.
|
|
|
|
Three rules, all consequences of "must open from a double-clicked file on a
|
|
machine with no egress":
|
|
no fetch() file:// treats every sibling file as cross-origin
|
|
no /theme.css an absolute path assumes a server at the root
|
|
no webfont a blocked stylesheet is a stall, not a fallback
|
|
-->
|
|
<html lang="en" data-theme="%%DEFAULT_THEME%%">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>%%TITLE%%</title>
|
|
<style>
|
|
%%THEME_CSS%%
|
|
|
|
* { box-sizing: border-box; }
|
|
body { margin: 0; padding: var(--space-6); }
|
|
main { max-width: 64rem; margin: 0 auto; }
|
|
|
|
header { display: flex; align-items: flex-start; gap: var(--space-4); flex-wrap: wrap;
|
|
padding-bottom: var(--space-4); border-bottom: var(--hairline) solid var(--border); }
|
|
h1 { margin: 0; font-size: 1.5rem; }
|
|
h1 .ok { color: var(--status-ok); }
|
|
.sub { color: var(--muted); margin: .35rem 0 0; max-width: 46rem; }
|
|
.built { color: var(--dim); font-family: var(--font-mono); font-size: 11px; margin: .5rem 0 0; }
|
|
|
|
h2 { font-size: .75rem; text-transform: uppercase; letter-spacing: .1em;
|
|
color: var(--muted); margin: var(--space-8) 0 var(--space-2); font-weight: 600; }
|
|
h2 .n { color: var(--dim); font-weight: 400; letter-spacing: 0; text-transform: none; }
|
|
.lede { color: var(--muted); margin: 0 0 var(--space-3); font-size: .9rem; max-width: 46rem; }
|
|
|
|
.panel { background: var(--surface); border: var(--hairline) solid var(--border);
|
|
border-radius: var(--radius-lg); padding: var(--space-4); }
|
|
.split { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-3); align-items: start; }
|
|
@media (max-width: 820px) { .split { grid-template-columns: 1fr; } }
|
|
|
|
pre { margin: 0; background: var(--bg); border: var(--hairline) solid var(--border);
|
|
border-radius: var(--radius); padding: var(--space-3); overflow-x: auto;
|
|
font-family: var(--font-mono); font-size: 11.5px; line-height: 1.55; color: var(--text); }
|
|
.cap { font-family: var(--font-mono); font-size: 10px; text-transform: uppercase;
|
|
letter-spacing: .08em; color: var(--dim); margin-bottom: 6px; }
|
|
|
|
table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
|
th { text-align: left; padding: 5px 8px; color: var(--muted); font-weight: 600; font-size: 10px;
|
|
text-transform: uppercase; letter-spacing: .05em; border-bottom: var(--hairline) solid var(--border); }
|
|
td { padding: 5px 8px; border-bottom: var(--hairline) solid var(--border); font-family: var(--font-mono); }
|
|
tr:last-child td { border-bottom: none; }
|
|
tr:hover td { background: var(--bg-2); }
|
|
|
|
.verb { font-weight: 600; font-size: 10px; padding: 1px 6px; border-radius: var(--radius-sm);
|
|
border: 1px solid currentColor; }
|
|
.GET { color: var(--status-info); } .POST { color: var(--status-ok); }
|
|
.PUT { color: var(--status-warn); } .DELETE { color: var(--status-error); }
|
|
|
|
.chip { display: inline-block; font-family: var(--font-mono); font-size: 9.5px;
|
|
padding: 0 5px; border-radius: var(--radius-sm);
|
|
border: var(--hairline) solid var(--border); color: var(--dim); }
|
|
.chip.pk { border-color: var(--accent); color: var(--accent-text); }
|
|
.chip.fk { border-color: var(--status-ok); color: var(--status-ok); }
|
|
|
|
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); gap: var(--space-3); }
|
|
.card { background: var(--bg); border: var(--hairline) solid var(--border);
|
|
border-radius: var(--radius); overflow: hidden; }
|
|
.card h3 { margin: 0; padding: 6px 10px; font-size: 12px; background: var(--surface-raised);
|
|
border-bottom: var(--hairline) solid var(--border); font-family: var(--font-mono); }
|
|
.card .row { display: flex; gap: 6px; align-items: baseline; padding: 3px 10px; font-size: 11px;
|
|
font-family: var(--font-mono); }
|
|
.card .row .f { flex: 1; }
|
|
.card .row .t { color: var(--dim); font-size: 10px; }
|
|
|
|
/* Swatches: the palette, read back from the live page rather than written out,
|
|
so what you see is what the theme actually resolved to. */
|
|
.swatches { display: grid; grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr)); gap: 8px; }
|
|
.sw { border: var(--hairline) solid var(--border); border-radius: var(--radius); overflow: hidden; }
|
|
.sw .chipc { height: 34px; }
|
|
.sw .meta { padding: 4px 7px; font-family: var(--font-mono); font-size: 9.5px; }
|
|
.sw .meta b { display: block; color: var(--text); font-weight: 600; }
|
|
.sw .meta span { color: var(--dim); }
|
|
|
|
.themes { display: flex; margin-left: auto; }
|
|
.themes button { font-family: var(--font-mono); font-size: 10px; padding: 5px 11px;
|
|
text-transform: uppercase; letter-spacing: .06em; border-radius: 0; }
|
|
.themes button:first-child { border-radius: var(--radius) 0 0 var(--radius); }
|
|
.themes button:last-child { border-radius: 0 var(--radius) var(--radius) 0; }
|
|
.themes button + button { border-left: none; }
|
|
.themes button[aria-pressed="true"] { background: var(--accent); color: var(--bg); border-color: var(--accent); }
|
|
|
|
.flow { display: flex; align-items: center; gap: var(--space-3); flex-wrap: wrap;
|
|
font-family: var(--font-mono); font-size: 12px; margin-bottom: var(--space-3); }
|
|
.flow .box { border: var(--hairline) solid var(--border); border-radius: var(--radius);
|
|
padding: 5px 10px; background: var(--bg); }
|
|
.flow .arrow { color: var(--accent); }
|
|
.flow .box b { color: var(--accent-text); }
|
|
|
|
.diagram { overflow-x: auto; border: var(--hairline) solid var(--border);
|
|
border-radius: var(--radius); background: var(--bg); padding: var(--space-3); }
|
|
.diagram svg { max-width: 100%; height: auto; display: block; margin: 0 auto; }
|
|
.diagram .graph > polygon { fill: var(--bg); }
|
|
.diagram .node ellipse, .diagram .node polygon, .diagram .node path { fill: var(--surface); stroke: var(--border); }
|
|
.diagram .node text { fill: var(--text); }
|
|
.diagram .edge path { stroke: var(--dim); }
|
|
.diagram .edge polygon { fill: var(--dim); stroke: var(--dim); }
|
|
.diagram .edge text { fill: var(--muted); }
|
|
.diagram .cluster polygon, .diagram .cluster path { fill: var(--bg-2); stroke: var(--border); }
|
|
.diagram .cluster text { fill: var(--muted); }
|
|
.diagram .node.accent ellipse, .diagram .node.accent polygon, .diagram .node.accent path,
|
|
.diagram .node.ok ellipse, .diagram .node.ok polygon, .diagram .node.ok path { stroke: var(--accent); stroke-width: 1.5; }
|
|
.diagram .node.accent-text text { fill: var(--accent-text); }
|
|
.diagram .node.ok text { fill: var(--status-ok); }
|
|
.diagram .cluster.artery polygon, .diagram .cluster.artery path { stroke: var(--sys-artery); }
|
|
.diagram .cluster.atlas polygon, .diagram .cluster.atlas path { stroke: var(--sys-atlas); }
|
|
.diagram .cluster.station polygon, .diagram .cluster.station path { stroke: var(--sys-station); }
|
|
.diagram .cluster.artery text { fill: var(--sys-artery); }
|
|
.diagram .cluster.atlas text { fill: var(--sys-atlas); }
|
|
.diagram .cluster.station text { fill: var(--sys-station); }
|
|
.diagram .edge.artery path, .diagram .edge.artery polygon { stroke: var(--sys-artery); fill: var(--sys-artery); }
|
|
.diagram .edge.atlas path, .diagram .edge.atlas polygon { stroke: var(--sys-atlas); fill: var(--sys-atlas); }
|
|
.diagram .edge.station path, .diagram .edge.station polygon { stroke: var(--sys-station);fill: var(--sys-station); }
|
|
|
|
:root { --sys-artery: #b91c1c; --sys-atlas: #15803d; --sys-station: #1d4ed8; }
|
|
[data-theme="lucid"] { --sys-artery: #c0392b; --sys-atlas: #1a7f45; --sys-station: #2b5fd9; }
|
|
[data-theme="mcrn"] { --sys-artery: #c0392b; --sys-atlas: #2ecc71; --sys-station: #5dade2; }
|
|
|
|
@media print { .themes { display: none; } .panel, .card { break-inside: avoid; } }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main>
|
|
<header>
|
|
<div>
|
|
<h1><span class="ok">IT WORKS</span> — %%TITLE%%</h1>
|
|
<p class="sub">%%DESCRIPTION%%</p>
|
|
<p class="built">%%BUILT%%</p>
|
|
</div>
|
|
<div class="themes" id="themes" role="group" aria-label="Theme"></div>
|
|
</header>
|
|
|
|
<div id="body"></div>
|
|
|
|
<h2>Architecture</h2>
|
|
<p class="lede">Rendered from <code>docs/graphs/*.dot</code> by the same palette
|
|
as this page. Inlined rather than linked, so it follows the theme switch.</p>
|
|
<div class="diagram">%%GRAPH%%</div>
|
|
</main>
|
|
|
|
<script>
|
|
var BUNDLE = %%BUNDLE%%;
|
|
var THEMES = ["lucid", "soleprint", "mcrn"];
|
|
|
|
function esc(s) {
|
|
return String(s == null ? "" : s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
}
|
|
function el(html) { return html; }
|
|
|
|
var S = BUNDLE.showcase || {};
|
|
var out = [];
|
|
|
|
/* ── the headline: spreadsheets in, a working service out ───────────────── */
|
|
if (S.tabular) {
|
|
var t = S.tabular;
|
|
out.push('<h2>Spreadsheets to a typed API <span class="n">— modelgen · datagen · shuntgen</span></h2>');
|
|
out.push('<p class="lede">Nobody wrote a model. These are the real files in ' +
|
|
'<code>fixtures/sheets/</code>, and everything below was generated from them at build time.</p>');
|
|
out.push('<div class="flow">' +
|
|
'<span class="box"><b>' + t.counts.files + '</b> files · <b>' + t.counts.rows + '</b> rows</span>' +
|
|
'<span class="arrow">→</span><span class="box"><b>' + t.counts.models + '</b> models</span>' +
|
|
'<span class="arrow">→</span><span class="box"><b>' +
|
|
(S.shunt ? S.shunt.count : 0) + '</b> routes</span></div>');
|
|
|
|
var sample = (t.inputs.filter(function (i) { return i.sample; })[0]) || null;
|
|
out.push('<div class="panel"><div class="split">' +
|
|
'<div><div class="cap">in — ' + esc(sample ? sample.name : "sheet") + '</div><pre>' +
|
|
esc(sample ? sample.sample : "") + '</pre></div>' +
|
|
'<div><div class="cap">out — models.py</div><pre>' + esc(t.pydantic) + '</pre></div>' +
|
|
'</div></div>');
|
|
|
|
if (t.models && t.models.length) {
|
|
out.push('<h2>Inferred schema <span class="n">— types, keys and relations, from the data</span></h2>');
|
|
out.push('<div class="cards">' + t.models.map(function (m) {
|
|
return '<div class="card"><h3>' + esc(m.name) + '</h3>' + m.fields.map(function (f) {
|
|
var kind = f.pk ? '<span class="chip pk">pk</span>'
|
|
: (String(f.type).indexOf("FK:") === 0 ? '<span class="chip fk">fk</span>' : "");
|
|
return '<div class="row"><span class="f">' + esc(f.name) + "</span>" +
|
|
'<span class="t">' + esc(f.type) + "</span>" + kind + "</div>";
|
|
}).join("") + "</div>";
|
|
}).join("") + "</div>");
|
|
}
|
|
|
|
if (t.records && t.records.length) {
|
|
out.push('<h2>Generated records <span class="n">— datagen, sampling the imported rows</span></h2>');
|
|
out.push('<div class="panel"><pre>' + esc(JSON.stringify(t.records, null, 2)) + "</pre></div>");
|
|
}
|
|
}
|
|
|
|
if (S.shunt) {
|
|
out.push('<h2>The service it becomes <span class="n">— shuntgen</span></h2>');
|
|
out.push('<p class="lede">Five routes per table, backed by the real rows. ' +
|
|
'<code>python run.py</code> and it answers.</p>');
|
|
out.push('<div class="panel"><div class="split">' +
|
|
'<div><div class="cap">routes</div><table><tbody>' +
|
|
S.shunt.routes.slice(0, 10).map(function (r) {
|
|
return "<tr><td><span class=\"verb " + r.method + '">' + r.method + "</span></td>" +
|
|
"<td>" + esc(r.path) + "</td><td>" + esc(r.operation) + "</td></tr>";
|
|
}).join("") + "</tbody></table></div>" +
|
|
'<div><div class="cap">GET /customers</div><pre>' +
|
|
esc(JSON.stringify(S.shunt.response, null, 2)) + "</pre></div></div></div>");
|
|
}
|
|
|
|
if (S.cabinet) {
|
|
out.push('<h2>Dependencies it declares <span class="n">— cabinets</span></h2>');
|
|
out.push('<p class="lede">An environment asks for <code>' + esc(S.cabinet.name) +
|
|
'</code> once. This is merged into its compose file on a laptop; the same name ' +
|
|
'installs as a rig addon in a cluster.</p>');
|
|
out.push('<div class="panel"><pre>' + esc(S.cabinet.fragment) + "</pre></div>");
|
|
}
|
|
|
|
/* ── the palette, read back from the live page ──────────────────────────── */
|
|
var TOKENS = ["--bg", "--bg-2", "--surface", "--surface-raised", "--border",
|
|
"--text", "--muted", "--dim",
|
|
"--accent", "--accent-text", "--status-ok", "--status-info",
|
|
"--status-warn", "--status-error"];
|
|
|
|
out.push('<h2>The theme itself <span class="n">— read back from this page, live</span></h2>');
|
|
out.push('<p class="lede">Switch above and every panel, table, badge and the diagram follow. ' +
|
|
'Nothing here is fetched: the whole thing is one file.</p>');
|
|
out.push('<div class="panel"><div class="swatches" id="swatches"></div></div>');
|
|
|
|
document.getElementById("body").innerHTML = out.join("");
|
|
|
|
function paintSwatches() {
|
|
var cs = getComputedStyle(document.documentElement);
|
|
document.getElementById("swatches").innerHTML = TOKENS.map(function (name) {
|
|
var v = cs.getPropertyValue(name).trim();
|
|
return '<div class="sw"><div class="chipc" style="background:' + v + '"></div>' +
|
|
'<div class="meta"><b>' + name + "</b><span>" + (v || "—") + "</span></div></div>";
|
|
}).join("");
|
|
}
|
|
|
|
var root = document.documentElement;
|
|
function apply(theme) {
|
|
root.setAttribute("data-theme", theme);
|
|
try { localStorage.setItem("spr-bundle-theme", theme); } catch (e) { /* file:// */ }
|
|
var bs = document.querySelectorAll("#themes button");
|
|
for (var i = 0; i < bs.length; i++) {
|
|
bs[i].setAttribute("aria-pressed", bs[i].dataset.theme === theme ? "true" : "false");
|
|
}
|
|
paintSwatches();
|
|
}
|
|
document.getElementById("themes").innerHTML = THEMES.map(function (t) {
|
|
return '<button type="button" data-theme="' + t + '">' + t + "</button>";
|
|
}).join("");
|
|
document.getElementById("themes").addEventListener("click", function (e) {
|
|
if (e.target.dataset.theme) apply(e.target.dataset.theme);
|
|
});
|
|
var saved = null;
|
|
try { saved = localStorage.getItem("spr-bundle-theme"); } catch (e) { /* file:// */ }
|
|
apply(THEMES.indexOf(saved) !== -1 ? saved : root.getAttribute("data-theme"));
|
|
</script>
|
|
</body>
|
|
</html>
|