Theme the wordmark: Pyr is the primary colour, tan the secondary, each picked from its own half
Claude · 2026-10-27 · 1a7ca26602fb476fc9cc485283cbe305b1a675f2 · parents ad3c1f6
Click either half of the wordmark, right-click it, or press and hold it on a touch screen to choose that half's colour from presets or a custom picker. The choice is kept in the browser (localStorage) and applies across the console, the landing page and the catalog on the same origin, before first paint. Pyr drives buttons, links and the current tab (replacing the fixed blue accent with ember); tan drives focus rings, hover edges and selected text. The picker is src/pyrtan/js/theme.js, inlined into the console and copied beside pyrtan.com's pages by site/build.sh.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013rD7jeoP6sMSg6vZQiAoSC
Changes
README.md | 5 ++- site/README.md | 1 + site/build.sh | 2 + site/catalog/index.html | 12 ++++-- site/index.html | 13 +++++-- src/pyrtan/js/theme.js | 134 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/pyrtan/page.py | 22 +++++++---- tests/test_service_hub.py | 3 ++ 8 files changed, 178 insertions(+), 14 deletions(-)
diff --git a/README.md b/README.md index 87fa026..21bb800 100644 --- a/README.md +++ b/README.md@@ -190,7 +190,10 @@ Open <http://127.0.0.1:8700/> for the console: a sentence box, the answer,the output and the trace; everything the swarm can answer; each peer and its `link`; and the gaps. The same console lives at <https://pyrtan.com/console/> and can be pointed at any hub started with-`--cors https://pyrtan.com`. To run the swarm on a NAS or any Docker host,+`--cors https://pyrtan.com`. The wordmark is two colours, **Pyr** the+primary and **tan** the secondary: click either half, right-click it, or+press and hold it on a phone to pick that half's colour, kept in the+browser. To run the swarm on a NAS or any Docker host,see [`deploy/README.md`](deploy/README.md). ## A federation diff --git a/site/README.md b/site/README.md index 74d4c8f..8f69c82 100644 --- a/site/README.md +++ b/site/README.md@@ -9,6 +9,7 @@ static directory and publishes it.| `/` | the landing page, with the live directory read from the seeds in the visitor's browser | `site/index.html` | | `/console/` | the console (installable: manifest, service worker and `pyrtan.js`, the tree runtime, beside it); point it at any hub started with `--cors https://pyrtan.com`; its Member tab joins that hub, and runs its trees when joined with the token | `pyrtan console --out` | | `/catalog/` | the trees people can `pyrtan pull`, as `namespace/name.toml`, and `index.json` the command reads | `pyrtan index examples catalog --copy`, through `site/gate.py` |+| `/theme.js` | the wordmark's colour picker the landing page and the catalog load (the console carries its own copy inline) | `src/pyrtan/js/theme.js` || `/rendezvous/seeds.json` | hubs to follow first | `site/rendezvous/seeds.json` | | `/rendezvous/directory.json` | a snapshot of the directory as the seeds know it, refreshed hourly | `site/snapshot.py` | | `/rendezvous/reports.json` | the gap reports the seeds carry for catalog trees, refreshed hourly | `site/snapshot.py --reports` | diff --git a/site/build.sh b/site/build.sh index e2f584f..7f54ce6 100755 --- a/site/build.sh +++ b/site/build.sh@@ -9,6 +9,8 @@ rm -rf "$out"mkdir -p "$out/console" "$out/catalog" "$out/rendezvous" out="$(cd "$out" && pwd)" cp site/index.html site/CNAME "$out/"+# The wordmark's colour picker, shared by the landing page and the catalog (the console inlines it).+cp src/pyrtan/js/theme.js "$out/"cp site/rendezvous/seeds.json "$out/rendezvous/" # The directory as the seeds know it now, keeping the last published # snapshot's entries when a seed is down (SNAPSHOT_PREVIOUS may add more). diff --git a/site/catalog/index.html b/site/catalog/index.html index 892c031..f685bbc 100644 --- a/site/catalog/index.html +++ b/site/catalog/index.html@@ -5,9 +5,12 @@<meta name="viewport" content="width=device-width, initial-scale=1"> <title>Pyrtan catalog</title> <meta name="description" content="Trees you can pull into your own Pyrtan swarm, checked and indexed from the repository.">+<meta name="theme-color" content="#c2410c">+<script src="../theme.js"></script><style>- :root { --bg:#fbfaf7; --fg:#1d1d1b; --mute:#6b6a64; --line:#e3e1da; --card:#ffffff; --accent:#2b5db8; --soft:#f1efe9 }- @media (prefers-color-scheme: dark) { :root { --bg:#141513; --fg:#ecebe6; --mute:#9d9c95; --line:#2b2c29; --card:#1c1d1b; --accent:#7fa6f0; --soft:#222420 } }+ :root { --bg:#fbfaf7; --fg:#1d1d1b; --mute:#6b6a64; --line:#e3e1da; --card:#ffffff; --pyr:#c2410c; --pyr-fg:#fff; --tan:#a8804f; --tan-fg:#fff; --soft:#f1efe9 }+ @media (prefers-color-scheme: dark) { :root { --bg:#141513; --fg:#ecebe6; --mute:#9d9c95; --line:#2b2c29; --card:#1c1d1b; --pyr:#fb8a4c; --pyr-fg:#101318; --tan:#d2b48c; --tan-fg:#101318; --soft:#222420 } }+ :root { --accent:var(--pyr); --accent-fg:var(--pyr-fg) }* { box-sizing:border-box } body { margin:0; background:var(--bg); color:var(--fg); font:16px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif } main { max-width:920px; margin:0 auto; padding:32px 16px 64px }@@ -19,12 +22,15 @@code { font:13px ui-monospace,SFMono-Regular,Menlo,monospace; background:var(--soft); padding:1px 5px; border-radius:4px } pre { margin:8px 0 0; padding:8px 12px; background:var(--soft); border-radius:8px; font:13px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace; overflow:auto } a { color:var(--accent) }+ .pyr { color:var(--pyr) } .tan { color:var(--tan) }+ :focus-visible { outline:2px solid var(--tan); outline-offset:2px }+ ::selection { background:var(--tan); color:var(--tan-fg) }footer { color:var(--mute); font-size:13px; margin-top:32px } </style> </head> <body> <main>- <h1>Pyrtan catalog</h1>+ <h1><span class="pyr" data-theme-part="pyr">Pyr</span><span class="tan" data-theme-part="tan">tan</span> catalog</h1><p class="lede">Trees you can pull into your own swarm. Each is one TOML file, checked by the repository's tests, with the licence its author chose, under the namespace of whoever published it. <code>pyrtan pull NAMESPACE/NAME</code> verifies the file against <a href="index.json">index.json</a> before writing it. Where hubs that opted in have met a value a tree had no branch for, the <a href="../rendezvous/reports.json">gap reports</a> say so beside it.</p> <input type="search" id="q" placeholder="filter: weather, github, read-only…" autocomplete="off"> <div id="list"><p class="mute">Loading index.json…</p></div> diff --git a/site/index.html b/site/index.html index 37bc9ef..d082513 100644 --- a/site/index.html +++ b/site/index.html@@ -5,9 +5,12 @@<meta name="viewport" content="width=device-width, initial-scale=1"> <title>Pyrtan</title> <meta name="description" content="Open-source total automation: a core set of expandable services that act on all forms of I/O through hardcoded, inspectable decision trees. No model in the loop.">+<meta name="theme-color" content="#c2410c">+<script src="theme.js"></script><style>- :root { --bg:#fbfaf7; --fg:#1d1d1b; --mute:#6b6a64; --line:#e3e1da; --card:#ffffff; --accent:#2b5db8; --accent-fg:#fff; --soft:#f1efe9 }- @media (prefers-color-scheme: dark) { :root { --bg:#141513; --fg:#ecebe6; --mute:#9d9c95; --line:#2b2c29; --card:#1c1d1b; --accent:#7fa6f0; --accent-fg:#101318; --soft:#222420 } }+ :root { --bg:#fbfaf7; --fg:#1d1d1b; --mute:#6b6a64; --line:#e3e1da; --card:#ffffff; --pyr:#c2410c; --pyr-fg:#fff; --tan:#a8804f; --tan-fg:#fff; --soft:#f1efe9 }+ @media (prefers-color-scheme: dark) { :root { --bg:#141513; --fg:#ecebe6; --mute:#9d9c95; --line:#2b2c29; --card:#1c1d1b; --pyr:#fb8a4c; --pyr-fg:#101318; --tan:#d2b48c; --tan-fg:#101318; --soft:#222420 } }+ :root { --accent:var(--pyr); --accent-fg:var(--pyr-fg) }* { box-sizing:border-box } body { margin:0; background:var(--bg); color:var(--fg); font:17px/1.55 system-ui,-apple-system,"Segoe UI",sans-serif } main { max-width:820px; margin:0 auto; padding:48px 16px 64px }@@ -16,6 +19,7 @@h2 { font-size:19px; margin:36px 0 10px } .actions { display:flex; flex-wrap:wrap; gap:10px; margin:0 0 8px } .actions a { display:inline-block; padding:10px 16px; border-radius:8px; text-decoration:none; font-weight:600; border:1px solid var(--line); background:var(--card); color:var(--fg) }+ .actions a:hover { border-color:var(--tan) }.actions a.primary { background:var(--accent); color:var(--accent-fg); border-color:var(--accent) } pre { padding:12px 14px; background:var(--card); border:1px solid var(--line); border-radius:10px; overflow:auto; font:13.5px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace; white-space:pre-wrap } code { font:14px ui-monospace,SFMono-Regular,Menlo,monospace; background:var(--soft); padding:1px 5px; border-radius:4px }@@ -24,6 +28,9 @@.card { background:var(--card); border:1px solid var(--line); border-radius:10px; padding:14px 16px } .card h3 { margin:0 0 6px; font-size:16px } .card p { margin:0; color:var(--mute); font-size:15px } a { color:var(--accent) }+ .pyr { color:var(--pyr) } .tan { color:var(--tan) }+ :focus-visible { outline:2px solid var(--tan); outline-offset:2px }+ ::selection { background:var(--tan); color:var(--tan-fg) }footer { color:var(--mute); font-size:14px; margin-top:48px; border-top:1px solid var(--line); padding-top:16px } table { width:100%; border-collapse:collapse; font-size:15px } td,th { text-align:left; padding:6px 8px; border-top:1px solid var(--line); vertical-align:top } th { color:var(--mute); font-weight:600; border-top:0 } .link { display:inline-block; width:9px; height:9px; border-radius:50%; margin-right:6px; vertical-align:middle; background:var(--mute) }@@ -33,7 +40,7 @@</head> <body> <main>- <h1>Pyrtan</h1>+ <h1><span class="pyr" data-theme-part="pyr">Pyr</span><span class="tan" data-theme-part="tan">tan</span></h1><p class="lede">Open-source total automation: a core set of expandable services that act on all forms of I/O through hardcoded, inspectable decision trees. It answers like an assistant. Inside, there is no model.</p> <div class="actions"> diff --git a/src/pyrtan/js/theme.js b/src/pyrtan/js/theme.js new file mode 100644 index 0000000..ec06a69 --- /dev/null +++ b/src/pyrtan/js/theme.js@@ -0,0 +1,134 @@+// The wordmark and its two colours. "Pyr" is the primary colour (buttons,+// links, the current tab); "tan" is the secondary (focus rings, hover edges,+// selected text). Click either half of the wordmark, right-click it, or press+// and hold it on a touch screen to pick that half's colour. The choice is kept+// in this browser only, under this origin, and applies to every Pyrtan page+// served from it.+//+// A page gives the defaults as --pyr, --pyr-fg, --tan and --tan-fg on :root+// (light and dark), marks the halves with data-theme-part="pyr" and "tan",+// and loads this script in <head> so a saved choice applies before first paint.+(() => {+ const KEY = "pyrtan.theme";+ const PARTS = {+ pyr: {label: "Pyr", role: "primary", presets: [["ember", "#c2410c"], ["crimson", "#c92a2a"], ["rose", "#c2255c"], ["violet", "#7048e8"], ["blue", "#2b5db8"], ["teal", "#0c8599"], ["green", "#2f9e44"], ["slate", "#495057"]]},+ tan: {label: "tan", role: "secondary", presets: [["tan", "#c19a6b"], ["sand", "#d2b48c"], ["copper", "#b87333"], ["clay", "#a0522d"], ["gold", "#d4a017"], ["khaki", "#b5a642"], ["sage", "#8a9a5b"], ["steel", "#7d8ea3"]]},+ };+ const root = document.documentElement;+ const load = () => { try { return JSON.parse(localStorage.getItem(KEY) || "{}") || {}; } catch (e) { return {}; } };+ const save = (t) => { try { if (Object.keys(t).length) localStorage.setItem(KEY, JSON.stringify(t)); else localStorage.removeItem(KEY); } catch (e) {} };+ const valid = (c) => typeof c === "string" && /^#[0-9a-f]{6}$/i.test(c);+ // Text on a filled background of colour c: near-black on light colours, white on dark ones.+ function ink(c) {+ const lin = (i) => { const v = parseInt(c.slice(i, i + 2), 16) / 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; };+ return 0.2126 * lin(1) + 0.7152 * lin(3) + 0.0722 * lin(5) > 0.22 ? "#101318" : "#ffffff";+ }+ function apply(t) {+ for (const part in PARTS) {+ if (valid(t[part])) { root.style.setProperty("--" + part, t[part]); root.style.setProperty("--" + part + "-fg", ink(t[part])); }+ else { root.style.removeProperty("--" + part); root.style.removeProperty("--" + part + "-fg"); }+ }+ const meta = document.querySelector('meta[name="theme-color"]');+ if (meta) { const c = getComputedStyle(root).getPropertyValue("--pyr").trim(); if (c) meta.content = c; }+ }+ let theme = load();+ apply(theme);++ const style = document.createElement("style");+ style.textContent = `+ [data-theme-part] { cursor:pointer; border-radius:4px; -webkit-user-select:none; user-select:none; -webkit-touch-callout:none; touch-action:manipulation }+ [data-theme-part]:hover { text-decoration:underline; text-decoration-thickness:2px; text-underline-offset:4px }+ [data-theme-part][aria-expanded=true] { text-decoration:underline; text-decoration-thickness:2px; text-underline-offset:4px }+ .theme-pick { position:absolute; z-index:1000; width:min(264px, calc(100vw - 32px)); padding:12px; background:var(--card, #fff); color:var(--fg, #1d1d1b); border:1px solid var(--line, #e3e1da); border-radius:10px; box-shadow:0 8px 28px rgba(0,0,0,.18); font:14px/1.4 system-ui,-apple-system,"Segoe UI",sans-serif; letter-spacing:normal }+ .theme-pick[hidden] { display:none }+ .theme-pick .theme-head { font-weight:600; margin:0 0 8px } .theme-pick .theme-head span { color:var(--mute, #6b6a64); font-weight:normal }+ .theme-pick .theme-swatches { display:grid; grid-template-columns:repeat(4, 1fr); gap:8px; margin:0 0 10px }+ .theme-pick .theme-swatches button { aspect-ratio:1; width:100%; min-height:36px; padding:0; border:2px solid var(--line, #e3e1da); border-radius:8px; cursor:pointer }+ .theme-pick .theme-swatches button[aria-pressed=true] { border-color:var(--fg, #1d1d1b); box-shadow:inset 0 0 0 2px var(--card, #fff) }+ .theme-pick .theme-row { display:flex; align-items:center; justify-content:space-between; gap:8px }+ .theme-pick label { display:flex; align-items:center; gap:8px; cursor:pointer }+ .theme-pick input[type=color] { width:36px; height:28px; padding:0; border:1px solid var(--line, #e3e1da); border-radius:6px; background:none; cursor:pointer }+ .theme-pick .theme-reset { font:inherit; padding:6px 10px; border:1px solid var(--line, #e3e1da); border-radius:6px; background:var(--soft, #f1efe9); color:var(--fg, #1d1d1b); cursor:pointer }+ .theme-pick .theme-reset:disabled { opacity:.5; cursor:default }+ .theme-pick .theme-hint { color:var(--mute, #6b6a64); font-size:12px; margin:8px 0 0 }`;+ document.head.appendChild(style);++ let pick = null, openPart = null, openEl = null;++ function current(part) { return valid(theme[part]) ? theme[part].toLowerCase() : null; }+ function choose(part, color) {+ if (color) theme[part] = color.toLowerCase(); else delete theme[part];+ save(theme); apply(theme); render();+ }+ function render() {+ if (!pick || !openPart) return;+ const p = PARTS[openPart], cur = current(openPart);+ pick.setAttribute("aria-label", `${p.label} colour`);+ pick.querySelector(".theme-head").innerHTML = `${p.label} colour <span>· ${p.role}</span>`;+ pick.querySelector(".theme-swatches").innerHTML = p.presets.map(([name, c]) => `<button type="button" data-color="${c}" style="background:${c}" title="${name}" aria-label="${name}" aria-pressed="${cur === c}"></button>`).join("");+ pick.querySelector("input[type=color]").value = cur || getComputedStyle(root).getPropertyValue("--" + openPart).trim() || "#000000";+ pick.querySelector(".theme-reset").disabled = !cur;+ }+ function build() {+ pick = document.createElement("div");+ pick.className = "theme-pick"; pick.hidden = true; pick.setAttribute("role", "dialog");+ pick.innerHTML = `<div class="theme-head"></div><div class="theme-swatches"></div><div class="theme-row"><label><input type="color"> Custom</label><button type="button" class="theme-reset">Default</button></div><div class="theme-hint">Kept in this browser only.</div>`;+ pick.addEventListener("click", (e) => {+ const b = e.target.closest("button[data-color]");+ if (b) choose(openPart, b.dataset.color);+ else if (e.target.closest(".theme-reset")) choose(openPart, null);+ });+ pick.querySelector("input[type=color]").addEventListener("input", (e) => choose(openPart, e.target.value));+ document.body.appendChild(pick);+ }+ function place() {+ const r = openEl.getBoundingClientRect(), w = pick.offsetWidth;+ const left = Math.max(16, Math.min(r.left, document.documentElement.clientWidth - w - 16));+ pick.style.left = (left + window.scrollX) + "px";+ pick.style.top = (r.bottom + window.scrollY + 8) + "px";+ }+ function open(part, el) {+ if (!pick) build();+ if (openEl && openEl !== el) openEl.setAttribute("aria-expanded", "false");+ openPart = part; openEl = el; el.setAttribute("aria-expanded", "true");+ render(); pick.hidden = false; place();+ }+ function close(refocus) {+ if (!pick || pick.hidden) return;+ pick.hidden = true;+ if (openEl) { openEl.setAttribute("aria-expanded", "false"); if (refocus) openEl.focus(); }+ openPart = null; openEl = null;+ }+ const toggle = (part, el) => (openEl === el && !pick.hidden) ? close(false) : open(part, el);++ function bind(el) {+ const part = el.dataset.themePart;+ if (!PARTS[part] || el.dataset.themeBound) return;+ el.dataset.themeBound = "1";+ el.setAttribute("role", "button"); el.tabIndex = 0;+ el.setAttribute("aria-haspopup", "dialog"); el.setAttribute("aria-expanded", "false");+ el.title = `${PARTS[part].label} colour (${PARTS[part].role}): click, right-click or press and hold`;+ // Press and hold on touch and pen; a mouse has click and right-click.+ let timer = null, held = false;+ const cancel = () => { clearTimeout(timer); timer = null; };+ el.addEventListener("pointerdown", (e) => {+ held = false;+ if (e.pointerType === "mouse") return;+ cancel(); timer = setTimeout(() => { held = true; open(part, el); }, 450);+ });+ for (const ev of ["pointerup", "pointercancel", "pointerleave"]) el.addEventListener(ev, cancel);+ el.addEventListener("click", (e) => { e.preventDefault(); if (held) { held = false; return; } toggle(part, el); });+ el.addEventListener("contextmenu", (e) => { e.preventDefault(); cancel(); held = e.pointerType !== "mouse"; open(part, el); });+ el.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggle(part, el); } });+ }++ function start() {+ document.querySelectorAll("[data-theme-part]").forEach(bind);+ document.addEventListener("pointerdown", (e) => { if (pick && !pick.hidden && !pick.contains(e.target) && !e.target.closest("[data-theme-part]")) close(false); });+ document.addEventListener("keydown", (e) => { if (e.key === "Escape") close(true); });+ window.addEventListener("resize", () => { if (pick && !pick.hidden) place(); });+ }+ if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", start); else start();+ // Another tab on the same origin changed the colours.+ window.addEventListener("storage", (e) => { if (e.key === KEY) { theme = load(); apply(theme); render(); } });+})();diff --git a/src/pyrtan/page.py b/src/pyrtan/page.py index 5d33d40..21ab0d7 100644 --- a/src/pyrtan/page.py +++ b/src/pyrtan/page.py@@ -26,6 +26,8 @@ from . import __version__# The tree runtime in JavaScript, served beside the console so a member can run trees. PYRTAN_JS = (Path(__file__).parent / "js" / "pyrtan.js").read_text(encoding="utf-8")+# The wordmark's colour picker, inlined into the console and copied beside pyrtan.com's pages.+THEME_JS = (Path(__file__).parent / "js" / "theme.js").read_text(encoding="utf-8")PAGE = r"""<!doctype html> <html lang="en">@@ -36,10 +38,12 @@ PAGE = r"""<!doctype html><meta name="description" content="Pyrtan console: ask the swarm, read the trace, see what it can answer, find the gaps, and join a hub as a member that keeps its blobs."> <link rel="manifest" href="manifest.webmanifest"> <script src="pyrtan.js"></script>-<meta name="theme-color" content="#2b5db8">+<meta name="theme-color" content="#c2410c">+<script>__THEME__</script><style>- :root { --bg:#fbfaf7; --fg:#1d1d1b; --mute:#6b6a64; --line:#e3e1da; --card:#ffffff; --ok:#1f7a4d; --bad:#a3382b; --ask:#8a6400; --accent:#2b5db8; --accent-fg:#fff; --soft:#f1efe9 }- @media (prefers-color-scheme: dark) { :root { --bg:#141513; --fg:#ecebe6; --mute:#9d9c95; --line:#2b2c29; --card:#1c1d1b; --ok:#5fcf93; --bad:#f08a7a; --ask:#e2b84a; --accent:#7fa6f0; --accent-fg:#101318; --soft:#222420 } }+ :root { --bg:#fbfaf7; --fg:#1d1d1b; --mute:#6b6a64; --line:#e3e1da; --card:#ffffff; --ok:#1f7a4d; --bad:#a3382b; --ask:#8a6400; --pyr:#c2410c; --pyr-fg:#fff; --tan:#a8804f; --tan-fg:#fff; --soft:#f1efe9 }+ @media (prefers-color-scheme: dark) { :root { --bg:#141513; --fg:#ecebe6; --mute:#9d9c95; --line:#2b2c29; --card:#1c1d1b; --ok:#5fcf93; --bad:#f08a7a; --ask:#e2b84a; --pyr:#fb8a4c; --pyr-fg:#101318; --tan:#d2b48c; --tan-fg:#101318; --soft:#222420 } }+ :root { --accent:var(--pyr); --accent-fg:var(--pyr-fg) }* { box-sizing:border-box } body { margin:0; background:var(--bg); color:var(--fg); font:16px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif } main { max-width:920px; margin:0 auto; padding:24px 16px 64px }@@ -48,6 +52,7 @@ PAGE = r"""<!doctype html>h2 { font-size:17px; margin:24px 0 8px } nav { display:flex; gap:4px; flex-wrap:wrap; margin:12px 0 16px } nav a { padding:6px 12px; border-radius:999px; color:var(--fg); text-decoration:none; border:1px solid var(--line); background:var(--card); font-size:14px }+ nav a:hover { border-color:var(--tan) }nav a[aria-current=page] { background:var(--accent); color:var(--accent-fg); border-color:var(--accent) } section[hidden] { display:none } p.lede { color:var(--mute); margin:0 0 16px }@@ -72,13 +77,16 @@ PAGE = r"""<!doctype html>.notice.bad { border-color:var(--bad) } footer { color:var(--mute); font-size:13px; margin-top:32px } footer a, .card a { color:var(--accent) }+ .pyr { color:var(--pyr) } .tan { color:var(--tan) }+ :focus-visible { outline:2px solid var(--tan); outline-offset:2px }+ ::selection { background:var(--tan); color:var(--tan-fg) }@media (max-width:560px) { table thead { display:none } table td { display:block; border-top:0 } table tr { display:block; border-top:1px solid var(--line); padding:6px 0 } } </style> </head> <body> <main> <header>- <h1>Pyrtan <small id="who">…</small></h1>+ <h1><span class="pyr" data-theme-part="pyr">Pyr</span><span class="tan" data-theme-part="tan">tan</span> <small id="who">…</small></h1><span class="mute" id="where"></span> </header> <p class="lede">A core set of expandable services that act on all forms of I/O through hardcoded, inspectable decision trees. Ask a sentence a tree answers to; the answer comes back with the exact path that produced it. If nothing answers, you get the tree that would. No model is involved.</p>@@ -648,7 +656,7 @@ load();</script> </body> </html>-""".replace("__VERSION__", __version__)+""".replace("__VERSION__", __version__).replace("__THEME__", THEME_JS)MANIFEST = json.dumps({ "name": "Pyrtan",@@ -658,8 +666,8 @@ MANIFEST = json.dumps({"scope": "./", "display": "standalone", "background_color": "#fbfaf7",- "theme_color": "#2b5db8",- "icons": [{"src": "data:image/svg+xml," + urllib.parse.quote('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="12" fill="#2b5db8"/><path d="M18 46V18h14a9 9 0 0 1 0 18h-8" fill="none" stroke="#fff" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/></svg>'), "sizes": "any", "type": "image/svg+xml", "purpose": "any"}],+ "theme_color": "#c2410c",+ "icons": [{"src": "data:image/svg+xml," + urllib.parse.quote('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="12" fill="#c2410c"/><path d="M18 46V18h14a9 9 0 0 1 0 18h-8" fill="none" stroke="#fff" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/></svg>'), "sizes": "any", "type": "image/svg+xml", "purpose": "any"}],}, indent=2) # The service worker keeps the console shell so the page opens offline (a diff --git a/tests/test_service_hub.py b/tests/test_service_hub.py index 7edf84b..98e7a27 100644 --- a/tests/test_service_hub.py +++ b/tests/test_service_hub.py@@ -220,6 +220,9 @@ def test_demo_page_is_served_by_service_and_hub(example_service):assert resp.headers.get_content_type() == "text/html" page = resp.read() assert b"<title>Pyrtan</title>" in page and f"console v{__version__}".encode() in page+ # The wordmark's two halves pick their own colours; the picker is inline, not another request.+ assert b'data-theme-part="pyr">Pyr<' in page and b'data-theme-part="tan">tan<' in page+ assert b"__THEME__" not in page and b'localStorage.getItem(KEY)' in pagestatus, api = http_json("GET", f"{server.url}/api") assert status == 200 and api["pyrtan"] == "service" hub = Hub()