updates 33.1 139

This commit is contained in:
2026-08-10 09:19:19 -03:00
parent 9a6337e493
commit 910927993e
49 changed files with 1876 additions and 341 deletions

View File

@@ -374,6 +374,21 @@
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. */
@@ -398,8 +413,8 @@
<circle cx="24" cy="20" r="2" fill="currentColor" />
</svg>
<h1>Artery</h1>
{% if pawprint_url %}<a
href="{{ pawprint_url }}"
{% if soleprint_url %}<a
href="{{ soleprint_url }}"
style="
position: absolute;
right: 0;
@@ -918,7 +933,7 @@
</section>
<footer>
{% if pawprint_url %}<a href="{{ pawprint_url }}">← Soleprint</a>{%
{% if soleprint_url %}<a href="{{ soleprint_url }}">← Soleprint</a>{%
else %}<span class="disabled">← Soleprint</span>{% endif %}
</footer>

View File

@@ -0,0 +1,43 @@
{
"name": "bundle",
"title": "Soleprint Bundle",
"description": "What a rig installation has at its disposal. Exports to a single file that opens with no server.",
"theme": "lucid",
"graph": "system_overview",
"_comment": "A plexus is exported, not served. build.py inlines the theme, the data and the diagram into one index.html so it survives a locked-down Windows box, a zip attachment and a double-click. Nothing here is fetched at runtime.",
"tools": [
{"name": "modelgen", "summary": "Generate models from 6 sources to 9 targets", "standalone": true},
{"name": "datagen", "summary": "Serve rig-owned generators; seed from real rows", "standalone": true},
{"name": "graphgen", "summary": "Generate navigable model graphs", "standalone": true},
{"name": "shuntgen", "summary": "OpenAPI spec or CSV/ODS folder to a running fake service", "standalone": true},
{"name": "tester", "summary": "HTTP contract test runner — one suite, any environment", "standalone": true},
{"name": "databrowse", "summary": "SQL data browser", "standalone": true},
{"name": "sbwrapper", "summary": "Sandbox wrapper", "standalone": true}
],
"cabinets": [
{"name": "postgres", "summary": "Relational database", "rig_addon": "postgres"},
{"name": "redis", "summary": "Cache and broker", "rig_addon": "redis"},
{"name": "airflow", "summary": "Scheduled pipelines", "rig_addon": "airflow", "needs": ["postgres", "redis"]}
],
"veins": [
{"name": "google", "summary": "Sheets and Drive, over OAuth2"},
{"name": "jira", "summary": "Issues and boards"},
{"name": "slack", "summary": "Messages and channels"},
{"name": "ia", "summary": "Model connector"}
],
"themes": [
{"name": "lucid", "summary": "Light, print-ready, shaped after lucid.app", "active": true},
{"name": "soleprint", "summary": "Dark, rounded, amber — the default"},
{"name": "mcrn", "summary": "Dark, square, monospace"}
],
"next": [
"Every tool above is standalone: it runs without the rest of soleprint.",
"Cabinets install as compose services on a laptop, or as rig addons of the same name in a cluster.",
"The diagram below is generated from docs/graphs/*.dot and themed by the same palette as this page."
]
}

View File

@@ -138,6 +138,16 @@
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. */

View File

@@ -1,5 +1,5 @@
"""
Album - Documentation system.
Atlas - Documentation system.
"""
import os
@@ -11,7 +11,7 @@ from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
app = FastAPI(title="Album", version="0.1.0")
app = FastAPI(title="Atlas", version="0.1.0")
BASE_DIR = Path(__file__).parent.resolve()
BOOK_DIR = BASE_DIR / "book"
@@ -25,24 +25,24 @@ templates = Jinja2Templates(directory=str(BASE_DIR))
# Serve static files
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
# Pawprint URL for data fetching
PAWPRINT_URL = os.getenv("PAWPRINT_URL", "http://localhost:12000")
# The soleprint hub this atlas reads its data from.
SOLEPRINT_URL = os.getenv("SOLEPRINT_URL", "http://localhost:12000")
def get_data():
"""Fetch data from pawprint hub."""
"""Fetch data from the soleprint hub."""
try:
resp = httpx.get(f"{PAWPRINT_URL}/api/data/album", timeout=5.0)
resp = httpx.get(f"{SOLEPRINT_URL}/api/data/atlas", timeout=5.0)
if resp.status_code == 200:
return resp.json()
except Exception as e:
print(f"Failed to fetch data from pawprint: {e}")
return {"templates": [], "larders": [], "books": []}
print(f"Failed to fetch data from soleprint: {e}")
return {"templates": [], "depots": [], "books": []}
@app.get("/health")
def health():
return {"status": "ok", "service": "album"}
return {"status": "ok", "service": "atlas"}
@app.get("/")
@@ -52,7 +52,7 @@ def index(request: Request):
"index.html",
{
"request": request,
"pawprint_url": os.getenv("PAWPRINT_EXTERNAL_URL", PAWPRINT_URL),
"soleprint_url": os.getenv("SOLEPRINT_EXTERNAL_URL", SOLEPRINT_URL),
**data,
},
)
@@ -60,7 +60,7 @@ def index(request: Request):
@app.get("/api/data")
def api_data():
"""API endpoint for frontend data (proxied from pawprint)."""
"""API endpoint for frontend data (proxied from soleprint)."""
return get_data()
@@ -130,7 +130,7 @@ def feature_form_samples_template():
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Feature Form Template · Album</title>
<title>Feature Form Template · Atlas</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
@@ -229,7 +229,7 @@ def feature_form_samples_template():
<div class="container">
<header>
<div class="breadcrumb">
<a href="/">Album</a> / <a href="/book/feature-form-samples/">Feature Form Samples</a> / Template
<a href="/">Atlas</a> / <a href="/book/feature-form-samples/">Feature Form Samples</a> / Template
</div>
<h1>Feature Form Template</h1>
<div class="meta">
@@ -244,7 +244,7 @@ def feature_form_samples_template():
<div class="form-body">
<div class="field">
<label class="field-label">Tipo de Usuario</label>
<div class="field-value">[Dueno de mascota / Veterinario / Admin]</div>
<div class="field-value">[Tipo A / Tipo B / Admin]</div>
</div>
<div class="field">
<label class="field-label">Punto de Entrada</label>
@@ -296,25 +296,25 @@ def feature_form_samples_template():
return HTMLResponse(html)
@app.get("/book/feature-form-samples/larder/", response_class=HTMLResponse)
@app.get("/book/feature-form-samples/larder", response_class=HTMLResponse)
def feature_form_samples_larder():
"""Browse the larder (actual data)"""
@app.get("/book/feature-form-samples/depot/", response_class=HTMLResponse)
@app.get("/book/feature-form-samples/depot", response_class=HTMLResponse)
def feature_form_samples_depot():
"""Browse the depot (actual data)"""
html_file = BOOK_DIR / "feature-form-samples" / "index.html"
if html_file.exists():
return HTMLResponse(html_file.read_text())
return HTMLResponse("<h1>Larder index not found</h1>", status_code=404)
return HTMLResponse("<h1>Depot index not found</h1>", status_code=404)
@app.get(
"/book/feature-form-samples/larder/{user_type}/{filename}",
"/book/feature-form-samples/depot/{user_type}/{filename}",
response_class=HTMLResponse,
)
def feature_form_samples_detail(request: Request, user_type: str, filename: str):
"""View a specific feature form"""
# Look in the larder subfolder (feature-form)
larder_dir = BOOK_DIR / "feature-form-samples" / "feature-form"
file_path = larder_dir / user_type / filename
# Look in the depot subfolder (feature-form)
depot_dir = BOOK_DIR / "feature-form-samples" / "feature-form"
file_path = depot_dir / user_type / filename
if not file_path.exists():
return HTMLResponse("<h1>Not found</h1>", status_code=404)

View File

@@ -0,0 +1,177 @@
#!/usr/bin/env python3
"""
Bake the default palette into the pages that use it.
python3 common/theme/bake.py # rewrite the baked blocks
python3 common/theme/bake.py --check # fail if any page is stale
A page that says `background: var(--bg)` and never gets `--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.
That matters because `/theme.css` is an absolute path and soleprint is not
always at the root. In the sample room's nginx, soleprint sits under `/spr/`
while `location /` proxies to the frontend, so `/theme.css` reaches the wrong
service. Same story for a page opened over file://.
So every page carries a baked default: a `:root` block with literal values,
emitted BEFORE the `<link>`. Both are `:root`, so document order decides — the
served stylesheet wins whenever it loads, and the baked block is what is left
when it does not. Nothing is given up in either direction.
The block is generated rather than hand-written, which is the point: the values
come from tokens.css and the default theme, so there is still one source. Only
the variables a page actually uses are emitted, so the blocks stay small.
"""
import re
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
SPR_ROOT = HERE.parent.parent # soleprint/
TOKENS = HERE / "tokens.css"
DEFAULT_THEME = HERE / "themes" / "soleprint.css"
BEGIN = "<!-- theme:baked-defaults — generated by common/theme/bake.py; do not edit -->"
END = "<!-- /theme:baked-defaults -->"
# Pages that link /theme.css and therefore need a default to fall back on.
PAGES = [
"index.html",
"artery/index.html",
"atlas/index.html",
"station/index.html",
"station/tools/datagen/templates/index.html",
"station/tools/graphgen/templates/index.html",
"station/tools/shuntgen/templates/index.html",
]
def declarations(css: str, selector: str) -> dict[str, str]:
"""Pull `--name: value;` pairs out of one rule."""
match = re.search(re.escape(selector) + r"\s*\{(.*?)\n\}", css, re.S)
if not match:
return {}
out = {}
for name, value in re.findall(r"(--[\w-]+)\s*:\s*([^;]+);", match.group(1)):
out[name] = value.strip()
return out
def resolve(name: str, table: dict[str, str], seen: frozenset = frozenset()) -> str | None:
"""Flatten a value to literals, following var() chains and honouring fallbacks."""
if name in seen or name not in table:
return None
value = table[name]
def swap(match: re.Match) -> str:
inner = match.group(1)
# var(--x, fallback) — the fallback may itself contain commas.
if "," in inner:
ref, fallback = inner.split(",", 1)
ref, fallback = ref.strip(), fallback.strip()
else:
ref, fallback = inner.strip(), None
resolved = resolve(ref, table, seen | {name})
if resolved is not None:
return resolved
return fallback if fallback is not None else ""
for _ in range(10): # chains are shallow; the bound just stops a cycle
new = re.sub(r"var\(\s*([^()]*(?:\([^()]*\)[^()]*)*)\)", swap, value)
if new == value:
break
value = new
return value.strip() or None
def palette() -> dict[str, str]:
"""Every token, flattened to literals, with the default theme applied."""
tokens = declarations(TOKENS.read_text(), ":root")
tokens.update(declarations(DEFAULT_THEME.read_text(), '[data-theme="soleprint"]'))
return {name: resolve(name, tokens) for name in tokens}
def used(html: str) -> set[str]:
"""Variables a page references, ignoring the baked block itself."""
body = re.sub(re.escape(BEGIN) + r".*?" + re.escape(END), "", html, flags=re.S)
return set(re.findall(r"var\(\s*(--[\w-]+)", body))
def block(names: set[str], values: dict[str, str], indent: str) -> str:
"""The baked <style>, wrapped in markers so it can be replaced next time."""
lines = [f"{indent}{BEGIN}", f"{indent}<style>", f"{indent} :root {{"]
for name in sorted(names):
value = values.get(name)
if value:
lines.append(f"{indent} {name}: {value};")
lines += [f"{indent} }}", f"{indent}</style>", f"{indent}{END}"]
return "\n".join(lines)
def bake(path: Path, values: dict[str, str]) -> tuple[bool, str]:
"""Return (changed, note) for one page."""
html = path.read_text()
link = re.search(r'([ \t]*)<link rel="stylesheet" href="/theme.css">', html)
if not link:
return False, "no /theme.css link — skipped"
indent = link.group(1)
names = used(html)
if not names:
return False, "uses no theme variables — skipped"
fresh = block(names, values, indent)
existing = re.search(re.escape(BEGIN) + r".*?" + re.escape(END), html, re.S)
if existing:
updated = html[: existing.start()] + fresh.lstrip() + html[existing.end() :]
else:
# Before the link, never after: document order is what makes the served
# stylesheet win over the baked one.
updated = html[: link.start()] + fresh + "\n" + html[link.start() :]
if updated == html:
return False, f"up to date ({len(names)} variables)"
path.write_text(updated)
return True, f"baked {len(names)} variables"
def main() -> int:
check = "--check" in sys.argv
values = palette()
missing = [n for n, v in values.items() if not v]
if missing:
print(f"warning: unresolved tokens: {', '.join(sorted(missing))}", file=sys.stderr)
stale = []
for rel in PAGES:
path = SPR_ROOT / rel
if not path.exists():
print(f" {rel}: not found")
continue
if check:
before = path.read_text()
changed, note = bake(path, values)
if changed:
path.write_text(before)
stale.append(rel)
print(f" {rel}: STALE")
else:
print(f" {rel}: {note}")
else:
_, note = bake(path, values)
print(f" {rel}: {note}")
if check and stale:
print(f"\n{len(stale)} page(s) stale — run: python3 common/theme/bake.py", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -19,7 +19,11 @@
(function () {
"use strict";
var THEMES = ["soleprint", "mcrn"];
// Order is the toggle order. soleprint stays first because it is the
// default; lucid is last because it is the one you switch to on purpose,
// to show someone something.
var THEMES = ["soleprint", "mcrn", "lucid"];
var LABELS = { soleprint: "SPR", mcrn: "MCRN", lucid: "LUCID" };
var KEY = "spr-theme";
var root = document.documentElement;
@@ -82,7 +86,7 @@
var button = document.createElement("button");
button.type = "button";
button.dataset.theme = theme;
button.textContent = theme === "mcrn" ? "MCRN" : "SPR";
button.textContent = LABELS[theme] || theme.toUpperCase();
button.title = "Switch to the " + theme + " theme";
button.addEventListener("click", function () {
apply(theme, true);

View File

@@ -0,0 +1,150 @@
/* Lucid — the regulated-document look, shaped after lucid.app exports.
*
* Why it exists: the deliverable gets shown on Windows, printed, and pasted next
* to real Lucidchart diagrams. Dark developer chrome cannot go in that room. The
* target is that a generated page and a genuine Lucid export sit side by side
* without announcing which is which.
*
* This is the first LIGHT theme here, and that is the part that needed care —
* tokens.css and both sibling themes were written assuming near-black. Two
* things do not survive the inversion and are overridden below rather than
* inherited:
*
* - the glow. A coloured halo means "lit" against black; against white it just
* looks like a rendering fault. Replaced with a hairline drop shadow.
* - --dim as body-adjacent text. At #555568 on white it fails contrast, so the
* ramp is rebuilt from the light end rather than reused.
*
* Fonts are stacks, never a webfont: this has to render with no egress. Arial is
* last because it is the one face guaranteed on Windows and aliased on Linux —
* the same reason the graphviz themes name it (see docs/graphs/themes/).
*/
[data-theme="lucid"] {
color-scheme: light;
--bg: #ffffff;
--bg-2: #f5f7fa;
--surface: #f5f7fa;
--surface-raised: #e4e7eb;
--border: #cbd2d9;
--border-strong: #9aa5b1;
/* Measured against both #ffffff and the #f5f7fa panel, because --dim is used
* for 11px notes and --status-warn for 10px labels — sizes where AA wants
* 4.5:1, not the 3:1 that large text gets away with. The obvious lighter
* greys (#7b8794, #73808d) come in at 3.43.8 on the panel and were dropped
* for that reason. */
--text: #1f2933; /* 14.76 on white — near-black; pure #000 reads harsh in print */
--muted: #616e7c; /* 5.21 / 4.86 */
--dim: #66717d; /* 4.97 / 4.63 */
--accent: #3a7dff;
--accent-dim: #2f6ae0;
--accent-text: #1c5bd9; /* darkened: the fill blue is too light for small text */
--glow: rgba(58, 125, 255, 0.18);
--status-ok: #0b875b; /* 4.53 / 4.23 */
--status-info: #1c5bd9; /* 5.92 / 5.52 */
--status-warn: #a35f00; /* 5.01 / 4.67 — #b06a00 was 3.99 on the panel */
--status-error: #cf2e2e; /* 5.14 / 4.79 */
--status-idle: #9aa5b1; /* dots and rules only, never text */
--radius-sm: 4px;
--radius: 6px;
--radius-lg: 8px;
--radius-xl: 8px;
--font-ui: "Segoe UI", Inter, system-ui, -apple-system, Arial, sans-serif;
--font-mono: "Cascadia Mono", Consolas, "JetBrains Mono", monospace;
--font-heading: "Segoe UI", Inter, system-ui, Arial, sans-serif;
--heading-transform: none;
--heading-spacing: 0;
--heading-weight: 600;
--label-spacing: 0.02em;
--speed-fast: 0.12s;
--speed: 0.18s;
/* Elevation, not luminosity. */
--hover-shadow: 0 1px 3px rgba(16, 24, 40, 0.1), 0 1px 2px rgba(16, 24, 40, 0.06);
--hover-lift: none;
--focus-shadow: 0 0 0 2px rgba(58, 125, 255, 0.35);
}
/* Panels are white cards on a pale canvas — the inverse of the dark themes,
* where the panel is lighter than the page. */
[data-theme="lucid"] .panel,
[data-theme="lucid"] .card,
[data-theme="lucid"] .model-card {
background: #ffffff;
}
[data-theme="lucid"] .card:hover,
[data-theme="lucid"] .panel:hover,
[data-theme="lucid"] .system-card:hover,
[data-theme="lucid"] .tool-card:hover,
[data-theme="lucid"] .model-card:hover {
border-color: var(--system-accent, var(--accent));
box-shadow: var(--hover-shadow);
transform: none;
}
/* A solid fill with white knocked out, the way a Lucid toolbar reads. No
* gradient: gradients are the first thing that looks wrong in print. */
[data-theme="lucid"] button[aria-pressed="true"],
[data-theme="lucid"] .active,
[data-theme="lucid"] .selected {
background: var(--accent);
border-color: var(--accent);
color: #ffffff;
}
[data-theme="lucid"] .label,
[data-theme="lucid"] .panel-title {
font-size: var(--font-size-sm);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--muted);
}
/* Pale-fill chips, the shape Lucid uses for tags on a shape. */
[data-theme="lucid"] .badge {
border-radius: var(--radius-sm);
padding: 1px 6px;
font-family: var(--font-mono);
font-size: 10px;
background: #eaf0ff;
border: 1px solid #c3d4ff;
color: var(--accent-text);
}
[data-theme="lucid"] code,
[data-theme="lucid"] pre {
background: #f5f7fa;
border-color: var(--border);
}
/* Diagrams are the point of this theme, and they are rendered as <img>, so the
* page cannot colour them — it can only stop fighting them. A white-canvas SVG
* needs a frame to read as a figure rather than as a hole in the page. */
[data-theme="lucid"] img[src$=".svg"] {
background: #ffffff;
border: 1px solid var(--border);
border-radius: var(--radius);
}
/* Printing is a first-class output here: this theme exists to end up in a
* document. Drop the chrome that has no meaning on paper. */
@media print {
[data-theme="lucid"] #spr-theme-toggle,
[data-theme="lucid"] #spr-sidebar {
display: none !important;
}
[data-theme="lucid"] .panel,
[data-theme="lucid"] .card {
box-shadow: none;
break-inside: avoid;
}
}

View File

@@ -42,9 +42,9 @@
--radius-lg: 0;
--radius-xl: 0;
--font-ui: "JetBrains Mono", "Fira Code", "SF Mono", monospace;
--font-mono: "JetBrains Mono", "Fira Code", "SF Mono", monospace;
--font-heading: "JetBrains Mono", "Fira Code", "SF Mono", monospace;
--font-ui: "JetBrains Mono", "Cascadia Mono", Consolas, "SF Mono", monospace;
--font-mono: "JetBrains Mono", "Cascadia Mono", Consolas, "SF Mono", monospace;
--font-heading: "JetBrains Mono", "Cascadia Mono", Consolas, "SF Mono", monospace;
--heading-transform: uppercase;
--heading-spacing: 0.1em;
--heading-weight: 400;

View File

@@ -41,9 +41,9 @@
--radius-lg: 8px;
--radius-xl: 12px;
--font-ui: "Inter", system-ui, -apple-system, sans-serif;
--font-mono: "JetBrains Mono", "Fira Code", monospace;
--font-heading: "Inter", system-ui, sans-serif;
--font-ui: Inter, "Segoe UI", system-ui, -apple-system, Arial, sans-serif;
--font-mono: "JetBrains Mono", "Cascadia Mono", Consolas, monospace;
--font-heading: Inter, "Segoe UI", system-ui, Arial, sans-serif;
--heading-transform: none;
--heading-spacing: 0.02em;
--heading-weight: 600;

View File

@@ -20,7 +20,11 @@
* handler beside /sidebar.css.
*/
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;600&display=swap");
/* No webfont import. This has to render on a locked-down Windows box with no
* egress and from a double-clicked file, and a blocked stylesheet there is a
* blank page or a stall, not a fallback. The stacks below resolve to something
* deliberate on every target: Segoe UI and Consolas ship with Windows, Inter and
* JetBrains Mono are picked up where they happen to be installed. */
:root {
/* ── surfaces ─────────────────────────────────────────────────────── */
@@ -63,8 +67,8 @@
--hairline: 1px;
/* ── type ─────────────────────────────────────────────────────────── */
--font-ui: "Inter", system-ui, -apple-system, sans-serif;
--font-mono: "JetBrains Mono", "Fira Code", "SF Mono", monospace;
--font-ui: "Segoe UI", Inter, system-ui, -apple-system, Arial, sans-serif;
--font-mono: "Cascadia Mono", "JetBrains Mono", Consolas, "SF Mono", monospace;
--font-heading: var(--font-ui);
--font-size-sm: 11px;
--font-size-base: 13px;

View File

@@ -249,6 +249,20 @@
<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

@@ -601,11 +601,21 @@ def station_route(path: str):
# page can switch themes without a second request and without FOUC.
def available_themes() -> list[str]:
"""Theme names, from the files themselves — adding one is adding a file."""
theme_dir = SPR_ROOT / "common" / "theme" / "themes"
if not theme_dir.exists():
return ["soleprint"]
names = sorted(p.stem for p in theme_dir.glob("*.css"))
# Default first, so a consumer taking names[0] gets the sensible one.
return sorted(names, key=lambda n: (n != "soleprint", n))
def get_default_theme() -> str:
"""The theme a page is served in, before the browser has an opinion."""
framework = load_config().get("framework", {})
theme = framework.get("theme", "soleprint")
return theme if theme in ("soleprint", "mcrn") else "soleprint"
return theme if theme in available_themes() else "soleprint"
@app.get("/theme.css")
@@ -652,7 +662,7 @@ def theme_js():
@app.get("/api/theme")
def theme_config():
"""The server-side default, for pages that render their own <html> tag."""
return {"theme": get_default_theme(), "themes": ["soleprint", "mcrn"]}
return {"theme": get_default_theme(), "themes": available_themes()}
@app.get("/sidebar.css")

View File

@@ -157,6 +157,20 @@
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. */

View File

@@ -15,26 +15,26 @@ This monitor provides at-a-glance views of the database grouped by test-relevant
## Architecture
Follows pawprint **book/larder pattern**:
- **larder/** contains all data files (schema, views, scenarios)
Follows the book/depot pattern:
- **depot/** contains all data files (schema, views, scenarios)
- **main.py** generates SQL queries from view definitions
- Two modes: **SQL** (direct queries) and **API** (Django backend, placeholder)
### Key Concepts
**Schema** (`larder/schema.json`)
**Schema** (`depot/schema.json`)
- AMAR data model with SQL table mappings
- Regular fields (from database columns)
- Computed fields (SQL expressions)
- Support for multiple graph generators
**Views** (`larder/views.json`)
**Views** (`depot/views.json`)
- Define what to display and how to group it
- Each view targets an entity (User, PetOwner, Veterinarian, etc.)
- Can group results (e.g., by role, by data state, by availability)
- SQL is generated automatically from view configuration
**Scenarios** (`larder/scenarios.json`)
**Scenarios** (`depot/scenarios.json`)
- Test scenarios emerge from actual usage
- Format defined, real scenarios added as needed
- Links scenarios to specific views with filters
@@ -49,21 +49,21 @@ Follows pawprint **book/larder pattern**:
## Running Locally
```bash
cd /home/mariano/wdir/ama/pawprint/ward/monitor/data_browse
cd soleprint/station/monitors/databrowse
python main.py
# Opens on http://localhost:12020
```
Or with uvicorn:
```bash
uvicorn ward.monitor.data_browse.main:app --port 12020 --reload
uvicorn station.monitors.databrowse.main:app --port 12020 --reload
```
## Environment Variables
```bash
# Database connection (defaults to local dev)
export NEST_NAME=local
export ROOM_NAME=local
export DB_HOST=localhost
export DB_PORT=5433
export DB_NAME=amarback
@@ -85,7 +85,7 @@ GET /api/scenarios # Test scenarios (JSON)
## Adding New Views
Edit `larder/views.json`:
Edit `depot/views.json`:
```json
{
@@ -112,7 +112,7 @@ The SQL query is automatically generated from:
## Adding Computed Fields
Edit `larder/schema.json` in the entity definition:
Edit `depot/schema.json` in the entity definition:
```json
"computed": {
@@ -127,7 +127,7 @@ Computed fields can be used in views just like regular fields.
## Adding Test Scenarios
As you identify test patterns, add them to `larder/scenarios.json`:
As you identify test patterns, add them to `depot/scenarios.json`:
```json
{
@@ -153,8 +153,8 @@ As you identify test patterns, add them to `larder/scenarios.json`:
```
data_browse/
├── larder/
│ ├── .larder # Larder marker (book pattern)
├── depot/
│ ├── .depot # Depot marker (book pattern)
│ ├── schema.json # AMAR data model with SQL mappings
│ ├── views.json # View configurations
│ └── scenarios.json # Test scenarios

View File

@@ -245,7 +245,7 @@
{% if views|length == 0 %}
<div class="empty">
No views configured. Add views to larder/views.json
No views configured. Add views to depot/views.json
</div>
{% else %}
<div class="card-grid">
@@ -289,7 +289,7 @@
<div class="empty">
No scenarios defined yet. Scenarios emerge from usage and
conversations.
<br />Add them to larder/scenarios.json as you identify test
<br />Add them to depot/scenarios.json as you identify test
patterns.
</div>
{% else %}

View File

@@ -6,6 +6,21 @@
<title>datagen — Test Data Generator</title>
<!-- Palette, fonts and the theme switcher. The :root block that used to sit
here was one of eight copies that had already drifted apart. -->
<!-- theme:baked-defaults — generated by common/theme/bake.py; do not edit -->
<style>
:root {
--amber: #d4a574;
--amber-dim: #b8956a;
--bg: #0d0d0f;
--border: #2e2e38;
--dim: #555568;
--font-ui: Inter, "Segoe UI", system-ui, -apple-system, Arial, sans-serif;
--muted: #8888a0;
--surface: #16161a;
--text: #e8e8f0;
}
</style>
<!-- /theme:baked-defaults -->
<link rel="stylesheet" href="/theme.css">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }

View File

@@ -5,6 +5,21 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>graphgen — Schema Explorer</title>
<!-- Palette, fonts and the theme switcher — see common/theme/. -->
<!-- theme:baked-defaults — generated by common/theme/bake.py; do not edit -->
<style>
:root {
--amber: #d4a574;
--amber-dim: #b8956a;
--bg: #0d0d0f;
--border: #2e2e38;
--dim: #555568;
--font-ui: Inter, "Segoe UI", system-ui, -apple-system, Arial, sans-serif;
--muted: #8888a0;
--surface: #16161a;
--text: #e8e8f0;
}
</style>
<!-- /theme:baked-defaults -->
<link rel="stylesheet" href="/theme.css">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pawprint Wrapper - Demo</title>
<title>Sidebar Wrapper - Demo</title>
<link rel="stylesheet" href="sidebar.css">
<style>
/* Demo page styles */
@@ -19,7 +19,7 @@
transition: margin-right 0.3s ease;
}
#pawprint-sidebar.expanded ~ #demo-content {
#spr-sidebar.expanded ~ #demo-content {
margin-right: var(--sidebar-width);
}
@@ -105,14 +105,14 @@
<div id="demo-content">
<div class="demo-header">
<h1>🐾 Pawprint Wrapper</h1>
<p>Development tools sidebar for any pawprint-managed nest</p>
<h1>Sidebar Wrapper</h1>
<p>Development tools sidebar for any soleprint-managed room</p>
</div>
<div class="demo-section">
<h2>👋 Quick Start</h2>
<p>
This is a standalone demo of the Pawprint Wrapper sidebar.
This is a standalone demo of the sidebar wrapper.
Click the toggle button on the right edge of the screen, or press
<span class="kbd">Ctrl</span> + <span class="kbd">Shift</span> + <span class="kbd">P</span>
to open the sidebar.

View File

@@ -4,6 +4,37 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>shuntgen</title>
<!-- theme:baked-defaults — generated by common/theme/bake.py; do not edit -->
<style>
:root {
--accent: #d4a574;
--accent-dim: #b8956a;
--accent-text: #e0b98d;
--bg: #0d0d0f;
--bg-2: #16161a;
--border: #2e2e38;
--dim: #555568;
--font-mono: "JetBrains Mono", "Cascadia Mono", Consolas, monospace;
--font-ui: Inter, "Segoe UI", system-ui, -apple-system, Arial, sans-serif;
--hairline: 1px;
--label-spacing: 0.04em;
--muted: #8888a0;
--radius: 6px;
--radius-lg: 8px;
--radius-sm: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-6: 24px;
--status-error: #f06565;
--status-info: #4f9cf9;
--status-ok: #3ecf8e;
--status-warn: #f5a623;
--surface: #16161a;
--text: #e8e8f0;
}
</style>
<!-- /theme:baked-defaults -->
<link rel="stylesheet" href="/theme.css">
<style>
* { box-sizing: border-box; }

View File

@@ -1,6 +0,0 @@
# Contract HTTP Tests - Environment Configuration
#
# Get API key: ./get-api-key.sh --docker core_nest_db
CONTRACT_TEST_URL=http://backend:8000
CONTRACT_TEST_API_KEY=118b1fcca089496919f0d82df2c4c89d35126793dfc3ea645366ae09d931f49f

View File

@@ -0,0 +1,19 @@
# Contract test target. Copy to .env and fill in — .env is gitignored.
#
# cp .env.example .env
#
# Environment variables override this file (see config.py), so CI can set these
# without a file at all:
#
# CONTRACT_TEST_URL=https://staging.example.com python -m tester run
#
# A real key was committed here once. Keep credentials in .env or the
# environment; environments.json is tracked and must stay empty of them.
CONTRACT_TEST_URL=http://localhost:8000
# One of: bearer (default) | api-key | none
CONTRACT_TEST_AUTH_TYPE=none
# CONTRACT_TEST_API_KEY=
# CONTRACT_TEST_TOKEN=

View File

@@ -154,11 +154,11 @@ ward/tools/tester/
If running standalone:
```bash
cd /home/mariano/wdir/ama/pawprint/ward/tools/tester
cd soleprint/station/tools/tester
python -m uvicorn main:app --reload --port 12003
```
Or if integrated with ward:
Or if integrated with station:
```bash
# Ward service should pick it up automatically
```

View File

@@ -16,7 +16,9 @@ set -e
# Defaults
DB_HOST="${DB_HOST:-localhost}"
DB_PORT="${DB_PORT:-5432}"
DB_NAME="${DB_NAME:-amarback}"
# No default database name: this ships in an open framework, and a client's
# schema name is not a sensible fallback. Set DB_NAME or pass --name.
DB_NAME="${DB_NAME:-}"
DB_USER="${DB_USER:-postgres}"
DB_PASSWORD="${DB_PASSWORD:-}"
DOCKER_CONTAINER=""
@@ -25,7 +27,7 @@ DOCKER_CONTAINER=""
while [[ $# -gt 0 ]]; do
case $1 in
--docker)
DOCKER_CONTAINER="${2:-core_nest_db}"
DOCKER_CONTAINER="${2:-}"
shift 2 || shift 1
;;
--host)
@@ -52,10 +54,10 @@ while [[ $# -gt 0 ]]; do
echo "Usage: $0 [options]"
echo ""
echo "Options:"
echo " --docker [container] Query via docker exec (default: core_nest_db)"
echo " --docker <container> Query via docker exec (container name required)"
echo " --host HOST Database host"
echo " --port PORT Database port (default: 5432)"
echo " --name NAME Database name (default: amarback)"
echo " --name NAME Database name (required, or set DB_NAME)"
echo " --user USER Database user (default: postgres)"
echo " --password PASS Database password"
echo ""
@@ -69,6 +71,13 @@ while [[ $# -gt 0 ]]; do
esac
done
if [[ -z "$DB_NAME" ]]; then
echo "no database name — pass --name NAME or set DB_NAME" >&2
echo "(there is no default: this ships in an open framework, and a client's" >&2
echo " schema name is not a sensible fallback)" >&2
exit 1
fi
QUERY="SELECT key FROM common_apikey WHERE is_active=true LIMIT 1;"
if [[ -n "$DOCKER_CONTAINER" ]]; then

View File

@@ -6,7 +6,6 @@ Tests basic HTTP connectivity and authentication flow.
"""
from ..base import ContractTestCase
from ..endpoints import Endpoints
class TestHealth(ContractTestCase):