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

@@ -28,11 +28,15 @@ export PYTHON
# treat them as goals of their own, so each gets a no-op rule.
ARGS := $(wordlist 2,$(words $(MAKECMDGOALS)),$(MAKECMDGOALS))
ifneq ($(ARGS),)
# Declares each extra word as a target that does nothing: `:` is an empty rule
# body and `@` silences it. Without this, `make build sample` runs the build and
# then fails with "No rule to make target 'sample'", because make reads every
# word on the line as something it has been asked to build.
$(eval $(ARGS):;@:)
endif
.DEFAULT_GOAL := help
.PHONY: help build start stop cluster deploy component
.PHONY: help build start stop dist docs cluster deploy component
help: ## list targets
@grep -hE '^[a-z]+:.*?##' $(MAKEFILE_LIST) | sed 's/:.*##/\t/' | expand -t16
@@ -48,6 +52,14 @@ start: ## run a built room [<room>] [-d] [--build]
stop: ## stop a running room [<room>]
bash ctrl/stop.sh $(or $(ARGS),$(ROOM))
dist: ## compile the plexus UIs to single files [<room>]
bash ctrl/dist.sh $(or $(ARGS),$(ROOM))
# ── docs ───────────────────────────────────────────────────────────────────
docs: ## documentation [serve [port]|graphs [theme]] (default serve)
bash ctrl/docs.sh $(or $(ARGS),serve)
# ── cluster ────────────────────────────────────────────────────────────────
cluster: ## shared kind cluster [up|down|status] (default status)

184
build.py
View File

@@ -23,6 +23,7 @@ Generated structure for managed rooms:
"""
import argparse
import importlib.util
import json
import logging
import shutil
@@ -532,6 +533,148 @@ def _append_cabinet_env(output_dir: Path, cabinets: list[dict]):
example.write_text(existing.rstrip("\n") + "\n" + "\n".join(lines) + "\n")
def load_plexuses(room: str) -> list[dict]:
"""The plexuses a room asked for. Same shape as its sibling data/*.json."""
path = SPR_ROOT / "cfg" / room / "data" / "plexuses.json"
if not path.exists():
return []
try:
raw = json.loads(path.read_text())
except ValueError as e:
log.warning(f" plexuses.json is not valid JSON, ignoring: {e}")
return []
entries = raw.get("items", raw) if isinstance(raw, dict) else raw
out = []
for entry in entries if isinstance(entries, list) else []:
if isinstance(entry, str):
entry = {"name": entry}
if isinstance(entry, dict) and entry.get("name"):
out.append(entry)
return out
def _theme_css(theme: str) -> str:
"""The token contract plus one theme, flattened for inlining.
Only the named theme ships alongside the others it can switch to, because
the export has to work with no server: there is no /theme.css to fetch.
"""
theme_dir = SPR_ROOT / "soleprint" / "common" / "theme"
parts = []
tokens = theme_dir / "tokens.css"
if tokens.exists():
parts.append(tokens.read_text())
# Every theme, so the switcher in the page has something to switch to.
for sheet in sorted((theme_dir / "themes").glob("*.css")):
parts.append(sheet.read_text())
return "\n".join(parts)
def _inline_svg(name: str, theme: str) -> str:
"""A rendered graph, stripped of its XML prolog so it can sit in HTML.
Inlined rather than <img>-linked so the page's CSS can recolour it when the
theme switches — graphviz writes class="node accent" into the SVG, and CSS
outranks the presentation attributes it bakes in.
"""
graphs = SPR_ROOT / "docs" / "graphs"
for candidate in (graphs / f"{name}.{theme}.svg", graphs / f"{name}.svg"):
if candidate.exists():
svg = candidate.read_text()
start = svg.find("<svg")
return svg[start:] if start >= 0 else svg
log.warning(f" no rendered graph '{name}' — run docs/graphs/render.sh")
return "<p>diagram not rendered</p>"
def build_plexuses(output_dir: Path, room: str):
"""Export each plexus the room declared to a single self-contained file.
A plexus is exported, not served. The output is one index.html carrying its
theme, its data and its diagram, so it survives a locked-down machine, a zip
attachment and a double-click — which is the whole point of the format.
"""
requested = load_plexuses(room)
if not requested:
return
source_root = SPR_ROOT / "soleprint" / "artery" / "plexuses"
built = []
for entry in requested:
name = entry["name"]
source = source_root / name
manifest_path = source / "plexus.json"
if not manifest_path.exists():
available = sorted(
p.name for p in source_root.iterdir() if p.is_dir()
) if source_root.exists() else []
log.warning(
f" no such plexus: {name} (available: {', '.join(available) or 'none'})"
)
continue
try:
manifest = json.loads(manifest_path.read_text())
except ValueError as e:
log.warning(f" plexus {name} has invalid plexus.json: {e}")
continue
# The room may override anything the plexus declares — theme first.
manifest.update({k: v for k, v in entry.items() if k != "name"})
template_path = source / "app" / "index.html"
if not template_path.exists():
log.warning(f" plexus {name} has no app/index.html")
continue
theme = manifest.get("theme", "soleprint")
data = {k: v for k, v in manifest.items() if not k.startswith("_")}
# A plexus may ship a showcase.py exposing collect(): anything it returns
# is merged into the page's data. The bundle uses it to run the real
# tools over the real fixtures at build time, so what the page shows
# cannot drift from what the tools do.
collector = source / "showcase.py"
if collector.exists():
try:
spec = importlib.util.spec_from_file_location(
f"plexus_{name}_showcase", collector
)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
data["showcase"] = module.collect()
except Exception as e:
log.warning(f" {name}: showcase.py failed ({type(e).__name__}: {e})")
page = template_path.read_text()
for token, value in (
("%%TITLE%%", manifest.get("title", name)),
("%%DESCRIPTION%%", manifest.get("description", "")),
("%%DEFAULT_THEME%%", theme),
("%%BUILT%%", f"{room} · built by soleprint build.py"),
("%%THEME_CSS%%", _theme_css(theme)),
("%%GRAPH%%", _inline_svg(manifest.get("graph", "system_overview"), theme)),
("%%BUNDLE%%", json.dumps(data, indent=2)),
):
page = page.replace(token, value)
target = output_dir / "plexuses" / name
ensure_dir(target)
(target / "index.html").write_text(page)
# Anything else in app/ rides along, for a plexus that outgrows one file.
for extra in (source / "app").iterdir():
if extra.name != "index.html":
copy_path(extra, target / extra.name, quiet=True)
built.append(f"{name} ({theme})")
if built:
log.info(f" plexuses: {', '.join(built)}")
def build_soleprint(output_dir: Path, room: str):
"""Build soleprint folder with core + room config merged."""
soleprint = SPR_ROOT / "soleprint"
@@ -568,6 +711,11 @@ def build_soleprint(output_dir: Path, room: str):
log.info("Composing cabinets...")
compose_cabinets(output_dir, room)
# Plexuses are exported rather than served, so this is a compile step like
# the cabinet merge above — not something run.py does at request time.
log.info("Exporting plexuses...")
build_plexuses(output_dir, room)
# Generate models
log.info("Generating models...")
if not generate_models(output_dir, room):
@@ -624,6 +772,33 @@ def build_models_only():
sys.exit(1)
def build_plexuses_only(room: str):
"""Compile just the plexus UIs, without rebuilding the room around them.
The equivalent of `vite build` for this repo: the iteration loop when you
are working on the UI itself is edit, compile, reopen the file — and a full
room build to see a CSS change is a slow way to do that.
"""
output_dir = SPR_ROOT / "gen" / room
if not output_dir.exists():
log.error(f"Room '{room}' is not built — run: python build.py --cfg {room}")
sys.exit(1)
log.info(f"Compiling plexus UIs for {room}...")
build_plexuses(output_dir, room)
built = sorted((output_dir / "plexuses").glob("*/index.html"))
if not built:
log.warning(
f" nothing compiled — does cfg/{room}/data/plexuses.json list one?"
)
return
for page in built:
log.info(f" {page.relative_to(SPR_ROOT)} ({page.stat().st_size // 1024} KB)")
log.info("\n✓ Open directly — no server needed:")
log.info(f" xdg-open {built[0]}")
def main():
parser = argparse.ArgumentParser(description="Soleprint Build Tool")
@@ -631,10 +806,17 @@ def main():
parser.add_argument("--cfg", "-c", type=str, help="Room config name")
parser.add_argument("--all", action="store_true", help="Build all rooms")
parser.add_argument("--models", action="store_true", help="Only regenerate models")
parser.add_argument(
"--plexuses",
action="store_true",
help="Only compile the plexus UIs into an already-built room",
)
args = parser.parse_args()
if args.models:
if args.plexuses:
build_plexuses_only(args.cfg or "standalone")
elif args.models:
build_models_only()
elif args.all:
build(SPR_ROOT / "gen" / "standalone", None)

View File

@@ -1,3 +1,7 @@
{
"items": []
"items": [
{
"name": "bundle"
}
]
}

View File

@@ -41,6 +41,14 @@ if [ "$SYNC_ONLY" = true ]; then
fi
echo "Restarting soleprint on server..."
ssh "$SERVER" "cd $REMOTE_DIR && docker compose up -d --build"
# The compose file runs the container as ${UID:-1000}:${GID:-1000} and bind-mounts
# the deployed tree at /app. Those have to be the ids that OWN the tree, and they
# are not 1000 on every host — mcrn.ar's user is 1001. Without this the container
# starts fine and then 500s on the first file it reads, which reads as an app bug
# rather than a permissions one.
#
# `env` rather than a prefix assignment: UID is readonly in bash, so
# `UID=$(id -u) docker ...` fails outright.
ssh "$SERVER" "cd $REMOTE_DIR && env UID=\$(id -u) GID=\$(id -g) docker compose up -d --build"
echo "Deploy complete"

31
ctrl/dist.sh Executable file
View File

@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Compile the plexus UIs to distributable files — this repo's `vite build`.
#
# Usage:
# ./ctrl/dist.sh # standalone
# ./ctrl/dist.sh sample # a named room
#
# A plexus is a UI that gets EXPORTED, not served. The output is a single
# index.html carrying its theme, its data and its diagrams inline, so it opens
# from a double-click on a machine with no server, no node and no network — the
# state a regulated Windows box is usually in.
#
# `make build` runs this as one of its steps. This exists for the loop where the
# UI is what you are working on: rebuilding a whole room to see a CSS change is
# a slow way to iterate.
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(dirname "$SCRIPT_DIR")"
cd "$ROOT_DIR"
PYTHON="${PYTHON:-python3}"
ROOM="${1:-standalone}"
if [[ ! -d "cfg/$ROOM" ]]; then
echo "No such room: cfg/$ROOM" >&2
echo "Available: $(find cfg -mindepth 1 -maxdepth 1 -type d -not -name '.*' -printf '%f ')" >&2
exit 1
fi
exec "$PYTHON" build.py --plexuses --cfg "$ROOM"

47
ctrl/docs.sh Executable file
View File

@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# Documentation: serve the pages, and re-render the diagrams.
#
# Usage: docs.sh serve [port] | graphs [theme]
#
# The docs are a static SPA — index.html plus data/*.md read at runtime — so
# they need a server only because fetch() refuses file:// origins. Any static
# server does; python is already a hard dependency here (build.py is python), so
# there is no reason to reach for docker the way rig does.
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(dirname "$SCRIPT_DIR")"
DOCS_DIR="$ROOT_DIR/docs"
PYTHON="${PYTHON:-python3}"
PORT="${DOCS_PORT:-8080}"
serve() {
[ -n "${1:-}" ] && PORT="$1"
if [ ! -f "$DOCS_DIR/index.html" ]; then
echo "no docs/index.html" >&2
exit 1
fi
echo "docs on http://localhost:${PORT}/"
echo " (ctrl-c to stop; nothing is installed and nothing persists)"
cd "$DOCS_DIR"
exec "$PYTHON" -m http.server "$PORT"
}
# Re-render docs/graphs/*.dot through every theme. The sources carry structure;
# the palette lives in docs/graphs/themes/*.gvpr. See docs/graphs/README.md.
graphs() {
if [ ! -x "$DOCS_DIR/graphs/render.sh" ]; then
echo "no docs/graphs/render.sh" >&2
exit 1
fi
exec bash "$DOCS_DIR/graphs/render.sh" "$@"
}
case "${1:-serve}" in
serve) shift || true; serve "$@" ;;
graphs) shift || true; graphs "$@" ;;
# `make docs 8090` is the obvious thing to type, so take it.
''|*[!0-9]*) echo "usage: $0 [serve [port]|graphs [theme]]" >&2; exit 1 ;;
*) serve "$1" ;;
esac

View File

@@ -0,0 +1,93 @@
# Plexuses
A **plexus** is a full app — the vocabulary has always said so
(*"full app with backend, frontend and DB"*). What was missing is that a plexus
is **exported, not served**. It is compiled to a distributable file the way vite
builds for production, and that compile is the whole point of the format.
```bash
make dist # compile the plexus UIs for standalone
make dist sample # for a named room
```
Output is one `index.html` per plexus under `gen/<room>/plexuses/<name>/`,
carrying its theme, its data and its diagrams inline. No server, no node, no
network. Zip it, mail it, double-click it.
`make build` runs the same step as part of a room build. `make dist` exists for
the loop where the UI is what you are working on — rebuilding a whole room to
see a CSS change is a slow way to iterate.
## The constraint that shapes it
It has to open from a **double-clicked file on a machine with no egress**. That
is the state a regulated Windows box is usually in, and it rules out three
things a normal web app does:
| 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 |
So the data is a JS object, the theme is inlined at compile time, and the fonts
are stacks. The test that matters is opening the output with the network off and
seeing zero failed requests — everything else is cosmetic.
## Declaring one
A room opts in through `cfg/<room>/data/plexuses.json`, the same shape and the
same place as its sibling `data/*.json` files:
```json
{ "items": [ { "name": "bundle" } ] }
```
A room may override anything the plexus declares — most usefully the theme:
```json
{ "items": [ { "name": "bundle", "theme": "mcrn" } ] }
```
## Writing one
```
soleprint/artery/plexuses/<name>/
plexus.json identity, theme, the data the page renders
app/index.html the template
```
`build.py` fills these placeholders and writes one file:
| Placeholder | Becomes |
| --- | --- |
| `%%THEME_CSS%%` | tokens plus every theme, so the switcher has something to switch to |
| `%%BUNDLE%%` | `plexus.json` as a JS object |
| `%%GRAPH%%` | a rendered SVG from `docs/graphs/`, inlined |
| `%%TITLE%%` `%%DESCRIPTION%%` `%%DEFAULT_THEME%%` `%%BUILT%%` | from the manifest |
Anything else in `app/` is copied alongside, for a plexus that outgrows one file.
## The bundle plexus
The one that ships. It answers "what does a rig installation have at its
disposal" — tools, cabinets, veins, themes — and embeds the system diagram.
Because the SVG is **inlined** rather than `<img>`-linked, the theme switch
recolours the diagram too: graphviz writes `class="node accent"` into the SVG,
and CSS outranks the presentation attributes it bakes in. Switching to `lucid`
turns both the page and the diagram into something printable, which is the
demonstration the format exists for.
## Not the same as rig's bundle
Two artifacts, both called bundle, generated by different repos:
| | soleprint | rig |
| --- | --- | --- |
| Command | `make dist` | `make manifest` in `sample-rig` |
| Artifact | `plexuses/<name>/index.html` | `generated/<slug>.yaml` |
| Needs | nothing | kind + MetalLB |
| Answers | what shipped, on any machine | whether this cluster install is sound |
Complementary. One proves the environment, the other travels.

View File

@@ -19,6 +19,8 @@ make start # run it
| Command | Runs | Does |
| --- | --- | --- |
| `make build [<room>\|all\|models]` | `ctrl/build.sh` | compile a room into `gen/` |
| `make dist [<room>]` | `ctrl/dist.sh` | compile just the plexus UIs to single files |
| `make docs [serve\|graphs]` | `ctrl/docs.sh` | serve the docs, or re-render the diagrams |
| `make start [<room>] [-d]` | `ctrl/start.sh` | run a built room's compose stack |
| `make stop [<room>]` | `ctrl/stop.sh` | stop it |
| `make cluster [up\|down\|status]` | `ctrl/cluster.sh` | the shared kind cluster |
@@ -50,9 +52,12 @@ make component ARGS="publish soleprint-ui /tmp/out --dist"
4. **Compose cabinets.** The dependency containers the room declared in
`data/cabinets.json` are merged into its `docker-compose.yml`. See
[Cabinets](#station-cabinets).
5. **Generate models.** modelgen reads the room's `config.json` and writes
5. **Export plexuses.** Each plexus the room declared in `data/plexuses.json` is
compiled to a single self-contained `index.html` — theme, data and diagrams
inlined, so it opens with no server. See [Plexuses](#artery-plexuses).
6. **Generate models.** modelgen reads the room's `config.json` and writes
`models/pydantic/__init__.py`.
6. **Render k8s** (optional). When the room's config enables it,
7. **Render k8s** (optional). When the room's config enables it,
`soleprint/ctrl/k8s/` writes manifests and lifecycle scripts.
## What comes out
@@ -66,6 +71,7 @@ gen/standalone/
cfg/config.json
data/*.json
models/pydantic/
plexuses/<name>/index.html # one file each, opens with no server
```
A **managed** room — one that wraps an existing application — is three folders
@@ -122,3 +128,17 @@ the far side. `.env` is excluded, so server secrets stay on the server.
default) straight from the source tree. It is for developing the framework
itself; a room's `cfg/config.json` does not exist there, so the landing pages
fall back to their defaults. Rooms use docker.
## Diagrams
The `.dot` sources under `docs/graphs/` carry structure; the palette lives in
`docs/graphs/themes/*.gvpr` and is applied at render time, so one source renders
in every theme.
```bash
make docs graphs # every graph, every theme
make docs graphs lucid # one theme
```
`<name>.svg` is the dark default the docs link to; other themes write
`<name>.<theme>.svg`. See [Themes](#themes).

110
docs/data/en/themes.md Normal file
View File

@@ -0,0 +1,110 @@
# Themes
Three themes ship, and the same palettes drive the diagrams as well as the
pages. `soleprint` is the default; the others are switched to on purpose.
| Theme | Reads as | For |
| --- | --- | --- |
| `soleprint` | dark, rounded, amber | the default — tools and dev chrome |
| `mcrn` | dark, square, monospace, orange glow | the terminal look, matched to mariano.mcrn.ar |
| `lucid` | light, hairline, printable | regulated documents, and sitting beside a real lucid.app export |
Switch with `?theme=lucid`, or the toggle in the corner. The choice persists.
## Where it lives
```
soleprint/common/theme/
tokens.css the vocabulary + neutral defaults
themes/*.css one file per theme
theme.js resolve, apply, remember
bake.py inline the defaults into pages
```
Served together at `/theme.css` — tokens first, then every theme, each scoped to
`[data-theme="..."]`. Adding a theme is adding a file: `run.py` lists the
directory rather than carrying a hardcoded list.
## Two naming families, one set of values
Both are answered, because both were already in use and renaming across a dozen
templates would have been the larger change:
- `--bg` / `--surface` / `--border` / `--text` / `--muted` / `--accent` — the
station tools and the docs site
- `--surface-0..3` / `--text-primary` / `--panel-radius``common/ui`'s Vue
components
The second family is derived from the first in `tokens.css`, so a page using
either name gets the same colour and a theme author fills in one set.
## Order matters
```html
<!-- baked defaults --> <style>:root { }</style>
<link rel="stylesheet" href="/theme.css">
<style> the page's own rules </style>
```
The theme has to load **before** the page's styles, so its element defaults
underpin the page rather than override it. Get this backwards and
`tokens.css`'s `body { background: var(--bg) }` flattens whatever the page
wanted — which is exactly how artery, atlas and station briefly lost their
coloured content columns.
## Baked defaults, and why
`/theme.css` is an absolute path, and soleprint is not always at the root — in a
room's nginx it sits under `/spr/` while `location /` goes to the frontend. A
page that says `background: var(--bg)` and never receives `--bg` does not fall
back to something plainer: the declaration is invalid at computed-value time, so
the background goes transparent and the text goes initial-black on a design that
assumed dark. Unstyled, not merely unbranded.
So every page carries a generated `:root` block **before** the link. Both are
`:root`, so document order decides: the served stylesheet wins when it loads,
and the baked block is what is left when it does not.
```bash
python3 common/theme/bake.py # regenerate
python3 common/theme/bake.py --check # fail if a page is stale
```
Only the variables a page actually uses are emitted, so the blocks stay small.
## No webfonts
The stacks name faces that exist on the target rather than fetching any:
```css
--font-ui: "Segoe UI", Inter, system-ui, -apple-system, Arial, sans-serif;
--font-mono: "Cascadia Mono", "JetBrains Mono", Consolas, "SF Mono", monospace;
```
Segoe UI and Consolas ship with Windows. A regulated network blocks
`fonts.googleapis.com` and `file://` stalls on it, and neither failure looks
like a missing font — they look like a broken page.
## Diagrams follow
`docs/graphs/themes/*.gvpr` carry the same palettes for graphviz, so a diagram
and the page around it are one visual language. See
[the graphs README](https://git.mcrn.ar/mariano/soleprint/src/branch/main/docs/graphs/README.md)
and [Export / Compile](#export).
```bash
make docs graphs # every graph, every theme
```
Diagrams are baked per theme rather than styled by CSS, because the docs embed
them with `<img src=…>` — which makes the SVG a separate document the page's
stylesheet cannot reach. A plexus that **inlines** the SVG can style it live,
and the bundle plexus does exactly that.
## Contrast
`lucid` is the first light theme, and the palette was measured rather than
guessed — against both `#ffffff` and the `#f5f7fa` panel, at the sizes actually
used. `--dim` drives 11px notes and `--status-warn` drives 10px labels, so both
need 4.5:1 rather than the 3:1 large text gets away with. The obvious lighter
greys came in at 3.43.8 and were dropped for that reason.

View File

@@ -1,32 +1,188 @@
[
{"id": "intro", "title": {"en": "Introduction"}},
{"id": "quickstart", "title": {"en": "Quick Start"}},
{"id": "concepts", "title": {"en": "Concepts"}},
{"id": "room-setup", "title": {"en": "↳ Room Setup"}, "sub": true},
{"id": "standalone", "title": {"en": "↳ Standalone"}, "sub": true},
{"id": "managed", "title": {"en": "↳ Managed"}, "sub": true},
{"id": "artery", "title": {"en": "Artery"}},
{"id": "artery-jira", "title": {"en": "↳ Jira"}, "sub": true},
{"id": "artery-google", "title": {"en": "↳ Google"}, "sub": true},
{"id": "artery-slack", "title": {"en": "↳ Slack"}, "sub": true},
{"id": "artery-ia", "title": {"en": "↳ IA"}, "sub": true},
{"id": "artery-shunts", "title": {"en": "↳ Shunts"}, "sub": true},
{"id": "atlas", "title": {"en": "Atlas"}},
{"id": "atlas-books", "title": {"en": "↳ Books"}, "sub": true},
{"id": "atlas-templates", "title": {"en": "↳ Templates"}, "sub": true},
{"id": "station", "title": {"en": "Station"}},
{"id": "station-tester", "title": {"en": "↳ Tester"}, "sub": true},
{"id": "station-datagen", "title": {"en": "↳ Datagen"}, "sub": true},
{"id": "station-modelgen", "title": {"en": "↳ Modelgen"}, "sub": true},
{"id": "station-graphgen", "title": {"en": "↳ Graphgen"}, "sub": true},
{"id": "station-shuntgen", "title": {"en": "↳ Shuntgen"}, "sub": true},
{"id": "station-databrowse", "title": {"en": "↳ Databrowse"}, "sub": true},
{"id": "station-cabinets", "title": {"en": "↳ Cabinets"}, "sub": true},
{"id": "components", "title": {"en": "Shared Components"}},
{"id": "export", "title": {"en": "Export / Compile"}},
{"id": "deployment", "title": {"en": "Deployment"}}
{
"id": "intro",
"title": {
"en": "Introduction"
}
},
{
"id": "quickstart",
"title": {
"en": "Quick Start"
}
},
{
"id": "concepts",
"title": {
"en": "Concepts"
}
},
{
"id": "room-setup",
"title": {
"en": "↳ Room Setup"
},
"sub": true
},
{
"id": "standalone",
"title": {
"en": "↳ Standalone"
},
"sub": true
},
{
"id": "managed",
"title": {
"en": "↳ Managed"
},
"sub": true
},
{
"id": "artery",
"title": {
"en": "Artery"
}
},
{
"id": "artery-jira",
"title": {
"en": "↳ Jira"
},
"sub": true
},
{
"id": "artery-google",
"title": {
"en": "↳ Google"
},
"sub": true
},
{
"id": "artery-slack",
"title": {
"en": "↳ Slack"
},
"sub": true
},
{
"id": "artery-ia",
"title": {
"en": "↳ IA"
},
"sub": true
},
{
"id": "artery-shunts",
"title": {
"en": "↳ Shunts"
},
"sub": true
},
{
"id": "artery-plexuses",
"title": {
"en": "↳ Plexuses"
},
"sub": true
},
{
"id": "atlas",
"title": {
"en": "Atlas"
}
},
{
"id": "atlas-books",
"title": {
"en": "↳ Books"
},
"sub": true
},
{
"id": "atlas-templates",
"title": {
"en": "↳ Templates"
},
"sub": true
},
{
"id": "station",
"title": {
"en": "Station"
}
},
{
"id": "station-tester",
"title": {
"en": "↳ Tester"
},
"sub": true
},
{
"id": "station-datagen",
"title": {
"en": "↳ Datagen"
},
"sub": true
},
{
"id": "station-modelgen",
"title": {
"en": "↳ Modelgen"
},
"sub": true
},
{
"id": "station-graphgen",
"title": {
"en": "↳ Graphgen"
},
"sub": true
},
{
"id": "station-shuntgen",
"title": {
"en": "↳ Shuntgen"
},
"sub": true
},
{
"id": "station-databrowse",
"title": {
"en": "↳ Databrowse"
},
"sub": true
},
{
"id": "station-cabinets",
"title": {
"en": "↳ Cabinets"
},
"sub": true
},
{
"id": "components",
"title": {
"en": "Shared Components"
}
},
{
"id": "export",
"title": {
"en": "Export / Compile"
}
},
{
"id": "themes",
"title": {
"en": "Themes"
}
},
{
"id": "deployment",
"title": {
"en": "Deployment"
}
}
]

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,26 +394,6 @@
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>
<body>
<header style="position: relative">

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,21 +153,6 @@
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>
<body>
<header style="position: relative">

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,21 +264,6 @@
<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>
<body{% if managed %} class="has-sidebar"{% endif %}>

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,25 +176,6 @@
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>
<body>
<header style="position: relative">