updates 33.2 112

This commit is contained in:
2026-08-11 07:30:27 -03:00
parent 910927993e
commit 2d9bc9289c
17 changed files with 1287 additions and 109 deletions

View File

@@ -9,6 +9,26 @@
type="image/svg+xml"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48' fill='none' stroke='%23b91c1c' stroke-width='2.5'%3E%3Cpath d='M24 4 L24 20 M24 20 L8 40 M24 20 L40 40'/%3E%3Ccircle cx='24' cy='4' r='3' fill='%23b91c1c'/%3E%3Ccircle cx='8' cy='40' r='3' fill='%23b91c1c'/%3E%3Ccircle cx='40' cy='40' r='3' fill='%23b91c1c'/%3E%3Ccircle cx='24' cy='20' r='5' fill='none'/%3E%3Ccircle cx='24' cy='20' r='2' fill='%23b91c1c'/%3E%3C/svg%3E"
/>
<!-- theme:baked-defaults — generated by common/theme/bake.py; do not edit -->
<style>
:root {
--bg: #0d0d0f;
--border: #2e2e38;
--border-strong: #3d3d4a;
--dim: #555568;
--muted: #8888a0;
--surface: #16161a;
--system-accent: #d4a574;
--system-accent-text: #e0b98d;
--text: #e8e8f0;
}
</style>
<!-- /theme:baked-defaults -->
<link rel="stylesheet" href="/theme.css">
<style>
/* Artery keeps its own colour under every theme. */
:root { --system-accent: #b91c1c; --system-accent-text: #fca5a5; }
</style>
<style>
* {
box-sizing: border-box;
@@ -374,27 +394,7 @@
margin-bottom: 0.5rem;
}
</style>
<!-- theme:baked-defaults — generated by common/theme/bake.py; do not edit -->
<style>
:root {
--bg: #0d0d0f;
--border: #2e2e38;
--border-strong: #3d3d4a;
--dim: #555568;
--muted: #8888a0;
--surface: #16161a;
--system-accent: #d4a574;
--system-accent-text: #e0b98d;
--text: #e8e8f0;
}
</style>
<!-- /theme:baked-defaults -->
<link rel="stylesheet" href="/theme.css">
<style>
/* Artery keeps its own colour under every theme. */
:root { --system-accent: #b91c1c; --system-accent-text: #fca5a5; }
</style>
</head>
</head>
<body>
<header style="position: relative">
<!-- Flux capacitor -->

View File

@@ -0,0 +1,65 @@
# Plexuses
A plexus is a full app — and, unlike everything else here, one that is
**exported rather than served**. It compiles to a distributable file the way
vite builds for production.
```bash
make dist # standalone
make dist sample # a named room
```
Output: `gen/<room>/plexuses/<name>/index.html`, one self-contained file with its
theme, its data and its diagrams inlined.
## The constraint
It has to open from a double-clicked file on a machine with no egress — the
state a regulated Windows box is usually in. That rules out three things a
normal web app does, and every design decision here follows from it:
| Ruled out | Because |
| --- | --- |
| `fetch("bundle.json")` | `file://` treats every sibling file as cross-origin |
| `<link href="/theme.css">` | an absolute path assumes a server at the root |
| a webfont `@import` | a blocked stylesheet is a stall, not a fallback |
The acceptance test is opening the output with the network off and seeing zero
failed requests. Everything else is cosmetic.
## Layout
```
<name>/
plexus.json identity, theme, the data the page renders
app/index.html the template
```
`build.py::build_plexuses()` fills the placeholders and writes one file:
| Placeholder | Becomes |
| --- | --- |
| `%%THEME_CSS%%` | tokens plus every theme, so the switcher has somewhere to go |
| `%%BUNDLE%%` | `plexus.json` as a JS object literal |
| `%%GRAPH%%` | a rendered SVG from `docs/graphs/`, prolog stripped, inlined |
| `%%TITLE%%` `%%DESCRIPTION%%` `%%DEFAULT_THEME%%` `%%BUILT%%` | from the manifest |
Anything else in `app/` is copied alongside, for a plexus that outgrows one file.
A room declares which ones it wants in `cfg/<room>/data/plexuses.json`, and may
override anything the manifest sets — most usefully `theme`.
## Why the diagram is inlined
`<img src="…svg">` makes the SVG a separate document that the page's stylesheet
cannot reach. Inlined, it is part of the DOM — and graphviz writes
`class="node accent"` into the markup, so the page's CSS recolours it when the
theme switches. One diagram, three themes, no re-render.
That is also the demonstration: switch to `lucid` and both the page and the
diagram become something you could put in a document.
## Adding one
Drop a directory in beside `bundle/`. Nothing dispatches on the name — the room's
`plexuses.json` is the only place it has to be mentioned.

View File

@@ -0,0 +1,269 @@
<!DOCTYPE html>
<!--
Bundle plexus — the template. build.py inlines everything marked %%…%% and
writes one self-contained file to gen/<room>/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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
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">&rarr;</span><span class="box"><b>' + t.counts.models + '</b> models</span>' +
'<span class="arrow">&rarr;</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">A room 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>

View File

@@ -0,0 +1,181 @@
"""
Collect what the tools actually produce, at build time.
A page that lists tool names ages badly and proves nothing. This runs the real
tools over the real fixtures and hands the output to the template, so the
showcase cannot drift from the thing it is showcasing — the same reason rig's
`make up` applies the artifact it just generated rather than a parallel path.
Everything here is best-effort: a plexus that fails to build because a demo
panel could not be produced would be a bad trade, so each collector returns
None on failure and the template renders what it got.
Called by build.py::build_plexuses when a plexus ships a showcase.py exposing
collect().
"""
import importlib.util
import json
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
SOLEPRINT = HERE.parents[2]
FIXTURES = SOLEPRINT / "station" / "tools" / "shuntgen" / "fixtures"
def _first_lines(path: Path, count: int) -> str:
return "\n".join(path.read_text().splitlines()[:count])
def _extract(name: str, block: str, source: str) -> str:
"""Pull one class out of generated source, for showing a representative slice."""
lines = source.splitlines()
try:
start = next(i for i, l in enumerate(lines) if l.startswith(f"{block} {name}"))
except StopIteration:
return source[:400]
out = [lines[start]]
for line in lines[start + 1:]:
if line and not line.startswith((" ", "\t")):
break
out.append(line)
return "\n".join(out).rstrip()
def _tabular(tmp: Path) -> dict | None:
"""modelgen + datagen over the sheet fixtures — the headline demo.
Three spreadsheets become typed models, a graph schema and a generator that
samples the real rows. Showing the input beside the output is the whole
argument for the tool.
"""
sheets = FIXTURES / "sheets"
if not sheets.is_dir():
return None
sys.path.insert(0, str(SOLEPRINT))
try:
from station.tools.modelgen.loader.extract.tabular import TabularExtractor
from station.tools.modelgen.generator import GENERATORS
except ImportError:
return None
extractor = TabularExtractor(sheets)
models, enums = extractor.extract()
datasets = extractor.datasets()
GENERATORS["pydantic"]().generate((models, enums), tmp / "models.py")
GENERATORS["schema"]().generate((models, enums), tmp / "schema.json")
GENERATORS["datagen"]().generate((models, enums, datasets), tmp / "gen.py")
# Import the generated generator and let it produce records, so the JSON on
# the page is genuinely what a caller would receive.
records = []
try:
spec = importlib.util.spec_from_file_location("showcase_gen", tmp / "gen.py")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
cls = next(
v for k, v in vars(module).items()
if isinstance(v, type) and k.endswith("Generator") and k != "BaseDataGenerator"
)
records = cls().generate("Customers", 2)
except Exception:
pass
schema = json.loads((tmp / "schema.json").read_text())
return {
"inputs": [
{"name": f.name, "rows": len(next((d.rows for d in datasets
if d.source.startswith(f.name)), [])),
"sample": _first_lines(f, 4) if f.suffix != ".ods" else None}
for f in sorted(sheets.iterdir()) if f.is_file()
],
"models": [
{
"name": name,
"fields": [
{"name": fname, "type": fdef.get("type", "?"),
"pk": bool(fdef.get("pk")), "nullable": bool(fdef.get("nullable"))}
for fname, fdef in body.get("fields", {}).items()
],
}
for name, body in schema.get("models", {}).items()
],
"pydantic": _extract("Customers", "class", (tmp / "models.py").read_text()),
"records": records,
"counts": {
"files": sum(1 for f in sheets.iterdir() if f.is_file()),
"models": len(models),
"rows": sum(len(d.rows) for d in datasets),
},
}
def _shunt(tmp: Path) -> dict | None:
"""shuntgen: the same sheets, turned into routes that answer."""
sheets = FIXTURES / "sheets"
if not sheets.is_dir():
return None
sys.path.insert(0, str(SOLEPRINT))
try:
from station.tools.modelgen.loader.extract.tabular import TabularExtractor
from station.tools.shuntgen.emit import ShuntEmitter
except ImportError:
return None
extractor = TabularExtractor(sheets)
models, enums = extractor.extract()
datasets = extractor.datasets()
emitter = ShuntEmitter(
name="books", output=tmp / "shunt", models=models, enums=enums,
datasets=datasets, source="sheets", kind="tabular",
)
collections = emitter._collections()
routes = emitter._routes(collections)
seeded = next((d.rows for d in datasets if d.model == "Customers"), [])
return {
"routes": [
{"method": r["method"], "path": r["path"], "operation": r["operation"]}
for r in routes
],
"count": len(routes),
# What GET /customers actually answers with — the imported rows.
"response": seeded[:2],
}
def _cabinet() -> dict | None:
"""A cabinet's compose fragment, as it gets merged into a room."""
path = SOLEPRINT / "station" / "cabinets" / "postgres" / "service.yml"
if not path.exists():
return None
body = "\n".join(
l for l in path.read_text().splitlines() if not l.strip().startswith("#")
).strip()
return {"name": "postgres", "fragment": body}
def collect() -> dict:
"""Everything the showcase renders. Never raises."""
import tempfile
out: dict = {}
with tempfile.TemporaryDirectory(prefix="spr-showcase-") as raw:
tmp = Path(raw)
for key, fn in (("tabular", lambda: _tabular(tmp)),
("shunt", lambda: _shunt(tmp)),
("cabinet", lambda: _cabinet())):
try:
value = fn()
except Exception as e: # a demo panel is never worth failing a build
print(f" showcase: {key} unavailable ({type(e).__name__}: {e})")
value = None
if value:
out[key] = value
return out

View File

@@ -9,6 +9,21 @@
type="image/svg+xml"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48' fill='%2315803d'%3E%3Cpath d='M4 8 C4 8 12 6 24 10 L24 42 C12 38 4 40 4 40 Z' opacity='0.3'/%3E%3Cpath d='M44 8 C44 8 36 6 24 10 L24 42 C36 38 44 40 44 40 Z' opacity='0.5'/%3E%3Cpath d='M4 8 C4 8 12 6 24 10 M44 8 C44 8 36 6 24 10' fill='none' stroke='%2315803d' stroke-width='2'/%3E%3Cpath d='M4 40 C4 40 12 38 24 42 M44 40 C44 40 36 38 24 42' fill='none' stroke='%2315803d' stroke-width='2'/%3E%3Cline x1='24' y1='10' x2='24' y2='42' stroke='%2315803d' stroke-width='2'/%3E%3C/svg%3E"
/>
<!-- theme:baked-defaults — generated by common/theme/bake.py; do not edit -->
<style>
:root {
--bg: #0d0d0f;
--muted: #8888a0;
--system-accent-text: #e0b98d;
--text: #e8e8f0;
}
</style>
<!-- /theme:baked-defaults -->
<link rel="stylesheet" href="/theme.css">
<style>
/* Atlas keeps its own colour under every theme. */
:root { --system-accent: #15803d; --system-accent-text: #86efac; }
</style>
<style>
* {
box-sizing: border-box;
@@ -138,22 +153,7 @@
opacity: 0.5;
}
</style>
<!-- theme:baked-defaults — generated by common/theme/bake.py; do not edit -->
<style>
:root {
--bg: #0d0d0f;
--muted: #8888a0;
--system-accent-text: #e0b98d;
--text: #e8e8f0;
}
</style>
<!-- /theme:baked-defaults -->
<link rel="stylesheet" href="/theme.css">
<style>
/* Atlas keeps its own colour under every theme. */
:root { --system-accent: #15803d; --system-accent-text: #86efac; }
</style>
</head>
</head>
<body>
<header style="position: relative">
<!-- Open book -->

View File

@@ -9,6 +9,21 @@
type="image/svg+xml"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64' fill='%23e5e5e5'%3E%3Cg transform='rotate(-15 18 38)'%3E%3Cellipse cx='18' cy='32' rx='7' ry='13'/%3E%3Cellipse cx='18' cy='48' rx='6' ry='7'/%3E%3C/g%3E%3Cg transform='rotate(15 46 28)'%3E%3Cellipse cx='46' cy='22' rx='7' ry='13'/%3E%3Cellipse cx='46' cy='38' rx='6' ry='7'/%3E%3C/g%3E%3C/svg%3E"
/>
<!-- theme:baked-defaults — generated by common/theme/bake.py; do not edit -->
<style>
:root {
--accent: #d4a574;
--accent-dim: #b8956a;
--bg: #0d0d0f;
--border: #2e2e38;
--dim: #555568;
--muted: #8888a0;
--surface: #16161a;
--text: #e8e8f0;
}
</style>
<!-- /theme:baked-defaults -->
<link rel="stylesheet" href="/theme.css">
<style>
* {
box-sizing: border-box;
@@ -249,22 +264,7 @@
<link rel="stylesheet" href="/sidebar.css">
<script src="/sidebar.js"></script>
{% endif %}
<!-- theme:baked-defaults — generated by common/theme/bake.py; do not edit -->
<style>
:root {
--accent: #d4a574;
--accent-dim: #b8956a;
--bg: #0d0d0f;
--border: #2e2e38;
--dim: #555568;
--muted: #8888a0;
--surface: #16161a;
--text: #e8e8f0;
}
</style>
<!-- /theme:baked-defaults -->
<link rel="stylesheet" href="/theme.css">
</head>
</head>
<body{% if managed %} class="has-sidebar"{% endif %}>
<header>

View File

@@ -9,6 +9,25 @@
type="image/svg+xml"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48' fill='%231d4ed8'%3E%3Crect x='4' y='8' width='40' height='28' rx='3' fill='%231d4ed8'/%3E%3Crect x='8' y='12' width='32' height='20' rx='2' fill='%230a0a0a'/%3E%3Crect x='16' y='36' width='16' height='4' fill='%231d4ed8'/%3E%3Crect x='12' y='40' width='24' height='3' rx='1' fill='%231d4ed8'/%3E%3C/svg%3E"
/>
<!-- theme:baked-defaults — generated by common/theme/bake.py; do not edit -->
<style>
:root {
--bg: #0d0d0f;
--border: #2e2e38;
--border-strong: #3d3d4a;
--muted: #8888a0;
--surface: #16161a;
--system-accent: #d4a574;
--system-accent-text: #e0b98d;
--text: #e8e8f0;
}
</style>
<!-- /theme:baked-defaults -->
<link rel="stylesheet" href="/theme.css">
<style>
/* Station keeps its own colour under every theme. */
:root { --system-accent: #1d4ed8; --system-accent-text: #93c5fd; }
</style>
<style>
* {
box-sizing: border-box;
@@ -157,26 +176,7 @@
opacity: 0.5;
}
</style>
<!-- theme:baked-defaults — generated by common/theme/bake.py; do not edit -->
<style>
:root {
--bg: #0d0d0f;
--border: #2e2e38;
--border-strong: #3d3d4a;
--muted: #8888a0;
--surface: #16161a;
--system-accent: #d4a574;
--system-accent-text: #e0b98d;
--text: #e8e8f0;
}
</style>
<!-- /theme:baked-defaults -->
<link rel="stylesheet" href="/theme.css">
<style>
/* Station keeps its own colour under every theme. */
:root { --system-accent: #1d4ed8; --system-accent-text: #93c5fd; }
</style>
</head>
</head>
<body>
<header style="position: relative">
<!-- Control station / monitor -->