updates 33.2 112
This commit is contained in:
14
Makefile
14
Makefile
@@ -28,11 +28,15 @@ export PYTHON
|
|||||||
# treat them as goals of their own, so each gets a no-op rule.
|
# treat them as goals of their own, so each gets a no-op rule.
|
||||||
ARGS := $(wordlist 2,$(words $(MAKECMDGOALS)),$(MAKECMDGOALS))
|
ARGS := $(wordlist 2,$(words $(MAKECMDGOALS)),$(MAKECMDGOALS))
|
||||||
ifneq ($(ARGS),)
|
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):;@:)
|
$(eval $(ARGS):;@:)
|
||||||
endif
|
endif
|
||||||
|
|
||||||
.DEFAULT_GOAL := help
|
.DEFAULT_GOAL := help
|
||||||
.PHONY: help build start stop cluster deploy component
|
.PHONY: help build start stop dist docs cluster deploy component
|
||||||
|
|
||||||
help: ## list targets
|
help: ## list targets
|
||||||
@grep -hE '^[a-z]+:.*?##' $(MAKEFILE_LIST) | sed 's/:.*##/\t/' | expand -t16
|
@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>]
|
stop: ## stop a running room [<room>]
|
||||||
bash ctrl/stop.sh $(or $(ARGS),$(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 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
cluster: ## shared kind cluster [up|down|status] (default status)
|
cluster: ## shared kind cluster [up|down|status] (default status)
|
||||||
|
|||||||
184
build.py
184
build.py
@@ -23,6 +23,7 @@ Generated structure for managed rooms:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import importlib.util
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import shutil
|
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")
|
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):
|
def build_soleprint(output_dir: Path, room: str):
|
||||||
"""Build soleprint folder with core + room config merged."""
|
"""Build soleprint folder with core + room config merged."""
|
||||||
soleprint = SPR_ROOT / "soleprint"
|
soleprint = SPR_ROOT / "soleprint"
|
||||||
@@ -568,6 +711,11 @@ def build_soleprint(output_dir: Path, room: str):
|
|||||||
log.info("Composing cabinets...")
|
log.info("Composing cabinets...")
|
||||||
compose_cabinets(output_dir, room)
|
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
|
# Generate models
|
||||||
log.info("Generating models...")
|
log.info("Generating models...")
|
||||||
if not generate_models(output_dir, room):
|
if not generate_models(output_dir, room):
|
||||||
@@ -624,6 +772,33 @@ def build_models_only():
|
|||||||
sys.exit(1)
|
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():
|
def main():
|
||||||
parser = argparse.ArgumentParser(description="Soleprint Build Tool")
|
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("--cfg", "-c", type=str, help="Room config name")
|
||||||
parser.add_argument("--all", action="store_true", help="Build all rooms")
|
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("--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()
|
args = parser.parse_args()
|
||||||
|
|
||||||
if args.models:
|
if args.plexuses:
|
||||||
|
build_plexuses_only(args.cfg or "standalone")
|
||||||
|
elif args.models:
|
||||||
build_models_only()
|
build_models_only()
|
||||||
elif args.all:
|
elif args.all:
|
||||||
build(SPR_ROOT / "gen" / "standalone", None)
|
build(SPR_ROOT / "gen" / "standalone", None)
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
{
|
{
|
||||||
"items": []
|
"items": [
|
||||||
|
{
|
||||||
|
"name": "bundle"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,14 @@ if [ "$SYNC_ONLY" = true ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Restarting soleprint on server..."
|
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"
|
echo "Deploy complete"
|
||||||
|
|||||||
31
ctrl/dist.sh
Executable file
31
ctrl/dist.sh
Executable 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
47
ctrl/docs.sh
Executable 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
|
||||||
93
docs/data/en/artery-plexuses.md
Normal file
93
docs/data/en/artery-plexuses.md
Normal 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.
|
||||||
@@ -19,6 +19,8 @@ make start # run it
|
|||||||
| Command | Runs | Does |
|
| Command | Runs | Does |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `make build [<room>\|all\|models]` | `ctrl/build.sh` | compile a room into `gen/` |
|
| `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 start [<room>] [-d]` | `ctrl/start.sh` | run a built room's compose stack |
|
||||||
| `make stop [<room>]` | `ctrl/stop.sh` | stop it |
|
| `make stop [<room>]` | `ctrl/stop.sh` | stop it |
|
||||||
| `make cluster [up\|down\|status]` | `ctrl/cluster.sh` | the shared kind cluster |
|
| `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
|
4. **Compose cabinets.** The dependency containers the room declared in
|
||||||
`data/cabinets.json` are merged into its `docker-compose.yml`. See
|
`data/cabinets.json` are merged into its `docker-compose.yml`. See
|
||||||
[Cabinets](#station-cabinets).
|
[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`.
|
`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.
|
`soleprint/ctrl/k8s/` writes manifests and lifecycle scripts.
|
||||||
|
|
||||||
## What comes out
|
## What comes out
|
||||||
@@ -66,6 +71,7 @@ gen/standalone/
|
|||||||
cfg/config.json
|
cfg/config.json
|
||||||
data/*.json
|
data/*.json
|
||||||
models/pydantic/
|
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
|
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
|
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
|
itself; a room's `cfg/config.json` does not exist there, so the landing pages
|
||||||
fall back to their defaults. Rooms use docker.
|
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
110
docs/data/en/themes.md
Normal 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.4–3.8 and were dropped for that reason.
|
||||||
@@ -1,32 +1,188 @@
|
|||||||
[
|
[
|
||||||
{"id": "intro", "title": {"en": "Introduction"}},
|
{
|
||||||
{"id": "quickstart", "title": {"en": "Quick Start"}},
|
"id": "intro",
|
||||||
{"id": "concepts", "title": {"en": "Concepts"}},
|
"title": {
|
||||||
{"id": "room-setup", "title": {"en": "↳ Room Setup"}, "sub": true},
|
"en": "Introduction"
|
||||||
{"id": "standalone", "title": {"en": "↳ Standalone"}, "sub": true},
|
}
|
||||||
{"id": "managed", "title": {"en": "↳ Managed"}, "sub": true},
|
},
|
||||||
|
{
|
||||||
{"id": "artery", "title": {"en": "Artery"}},
|
"id": "quickstart",
|
||||||
{"id": "artery-jira", "title": {"en": "↳ Jira"}, "sub": true},
|
"title": {
|
||||||
{"id": "artery-google", "title": {"en": "↳ Google"}, "sub": true},
|
"en": "Quick Start"
|
||||||
{"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": "concepts",
|
||||||
{"id": "atlas", "title": {"en": "Atlas"}},
|
"title": {
|
||||||
{"id": "atlas-books", "title": {"en": "↳ Books"}, "sub": true},
|
"en": "Concepts"
|
||||||
{"id": "atlas-templates", "title": {"en": "↳ Templates"}, "sub": true},
|
}
|
||||||
|
},
|
||||||
{"id": "station", "title": {"en": "Station"}},
|
{
|
||||||
{"id": "station-tester", "title": {"en": "↳ Tester"}, "sub": true},
|
"id": "room-setup",
|
||||||
{"id": "station-datagen", "title": {"en": "↳ Datagen"}, "sub": true},
|
"title": {
|
||||||
{"id": "station-modelgen", "title": {"en": "↳ Modelgen"}, "sub": true},
|
"en": "↳ Room Setup"
|
||||||
{"id": "station-graphgen", "title": {"en": "↳ Graphgen"}, "sub": true},
|
},
|
||||||
{"id": "station-shuntgen", "title": {"en": "↳ Shuntgen"}, "sub": true},
|
"sub": true
|
||||||
{"id": "station-databrowse", "title": {"en": "↳ Databrowse"}, "sub": true},
|
},
|
||||||
{"id": "station-cabinets", "title": {"en": "↳ Cabinets"}, "sub": true},
|
{
|
||||||
|
"id": "standalone",
|
||||||
{"id": "components", "title": {"en": "Shared Components"}},
|
"title": {
|
||||||
{"id": "export", "title": {"en": "Export / Compile"}},
|
"en": "↳ Standalone"
|
||||||
{"id": "deployment", "title": {"en": "Deployment"}}
|
},
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -9,6 +9,26 @@
|
|||||||
type="image/svg+xml"
|
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"
|
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>
|
<style>
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@@ -374,27 +394,7 @@
|
|||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<!-- theme:baked-defaults — generated by common/theme/bake.py; do not edit -->
|
</head>
|
||||||
<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>
|
<body>
|
||||||
<header style="position: relative">
|
<header style="position: relative">
|
||||||
<!-- Flux capacitor -->
|
<!-- Flux capacitor -->
|
||||||
|
|||||||
65
soleprint/artery/plexuses/README.md
Normal file
65
soleprint/artery/plexuses/README.md
Normal 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.
|
||||||
269
soleprint/artery/plexuses/bundle/app/index.html
Normal file
269
soleprint/artery/plexuses/bundle/app/index.html
Normal 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, "&").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">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>
|
||||||
181
soleprint/artery/plexuses/bundle/showcase.py
Normal file
181
soleprint/artery/plexuses/bundle/showcase.py
Normal 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
|
||||||
@@ -9,6 +9,21 @@
|
|||||||
type="image/svg+xml"
|
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"
|
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>
|
<style>
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@@ -138,22 +153,7 @@
|
|||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<!-- theme:baked-defaults — generated by common/theme/bake.py; do not edit -->
|
</head>
|
||||||
<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>
|
<body>
|
||||||
<header style="position: relative">
|
<header style="position: relative">
|
||||||
<!-- Open book -->
|
<!-- Open book -->
|
||||||
|
|||||||
@@ -9,6 +9,21 @@
|
|||||||
type="image/svg+xml"
|
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"
|
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>
|
<style>
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@@ -249,22 +264,7 @@
|
|||||||
<link rel="stylesheet" href="/sidebar.css">
|
<link rel="stylesheet" href="/sidebar.css">
|
||||||
<script src="/sidebar.js"></script>
|
<script src="/sidebar.js"></script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<!-- theme:baked-defaults — generated by common/theme/bake.py; do not edit -->
|
</head>
|
||||||
<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 %}>
|
<body{% if managed %} class="has-sidebar"{% endif %}>
|
||||||
|
|
||||||
<header>
|
<header>
|
||||||
|
|||||||
@@ -9,6 +9,25 @@
|
|||||||
type="image/svg+xml"
|
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"
|
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>
|
<style>
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@@ -157,26 +176,7 @@
|
|||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<!-- theme:baked-defaults — generated by common/theme/bake.py; do not edit -->
|
</head>
|
||||||
<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>
|
<body>
|
||||||
<header style="position: relative">
|
<header style="position: relative">
|
||||||
<!-- Control station / monitor -->
|
<!-- Control station / monitor -->
|
||||||
|
|||||||
Reference in New Issue
Block a user