pyrtan / sourcev0.13.0 · main

Lowercase the wordmark, and let tan's picker set light, dark or auto mode

Claude · 2026-10-27 · bceca938fda02d8bc3577bc92868af9472ba42ee · parents 78e426c

The wordmark reads "pyrtan" in both colours. tan's picker gains an Auto / Light / Dark switch: it sets data-theme on <html>, which each page's CSS honours alongside prefers-color-scheme, and it is kept with the colours so it carries across the console, landing page, catalog and source pages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013rD7jeoP6sMSg6vZQiAoSC

Changes

 README.md                 |  6 +++---
 site/catalog/index.html   |  5 +++--
 site/index.html           |  5 +++--
 site/source.py            |  5 +++--
 src/pyrtan/js/theme.js    | 40 ++++++++++++++++++++++++++++++----------
 src/pyrtan/page.py        |  5 +++--
 tests/test_service_hub.py |  2 +-
 7 files changed, 46 insertions(+), 22 deletions(-)
diff --git a/README.md b/README.md
index a3d0ffb..af820b1 100644
--- a/README.md
+++ b/README.md
@@ -192,10 +192,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`. The wordmark is two colours, **Pyr** the
+`--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,
+press and hold it on a phone to pick that half's colour; tan's picker also
+sets light, dark or auto mode. The choice is 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/catalog/index.html b/site/catalog/index.html index db974fb..7e0cbe5 100644 --- a/site/catalog/index.html +++ b/site/catalog/index.html
@@ -9,7 +9,8 @@
<script src="../theme.js"></script> <style> :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 } }
+ @media (prefers-color-scheme: dark) { :root:not([data-theme=light]) { --bg:#141513; --fg:#ecebe6; --mute:#9d9c95; --line:#2b2c29; --card:#1c1d1b; --pyr:#fb8a4c; --pyr-fg:#101318; --tan:#d2b48c; --tan-fg:#101318; --soft:#222420 } }
+ :root[data-theme=dark] { --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 }
@@ -30,7 +31,7 @@
</head> <body> <main>
- <h1><span class="pyr" data-theme-part="pyr">Pyr</span><span class="tan" data-theme-part="tan">tan</span> 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 392657e..a64c3b5 100644 --- a/site/index.html +++ b/site/index.html
@@ -9,7 +9,8 @@
<script src="theme.js"></script> <style> :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 } }
+ @media (prefers-color-scheme: dark) { :root:not([data-theme=light]) { --bg:#141513; --fg:#ecebe6; --mute:#9d9c95; --line:#2b2c29; --card:#1c1d1b; --pyr:#fb8a4c; --pyr-fg:#101318; --tan:#d2b48c; --tan-fg:#101318; --soft:#222420 } }
+ :root[data-theme=dark] { --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 }
@@ -40,7 +41,7 @@
</head> <body> <main>
- <h1><span class="pyr" data-theme-part="pyr">Pyr</span><span class="tan" data-theme-part="tan">tan</span></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 inspectable decision trees. It answers like an assistant. Inside, there is no model.</p> <div class="actions"> diff --git a/site/source.py b/site/source.py index 6262d8e..89d864a 100644 --- a/site/source.py +++ b/site/source.py
@@ -46,7 +46,8 @@ def git_bytes(*args: str) -> bytes:
CSS = """ :root { --bg:#fbfaf7; --fg:#1d1d1b; --mute:#6b6a64; --line:#e3e1da; --card:#ffffff; --pyr:#c2410c; --tan:#a8804f; --tan-fg:#fff; --soft:#f1efe9; --add:#e6f4ea; --del:#fbe9e7 }
- @media (prefers-color-scheme: dark) { :root { --bg:#141513; --fg:#ecebe6; --mute:#9d9c95; --line:#2b2c29; --card:#1c1d1b; --pyr:#fb8a4c; --tan:#d2b48c; --tan-fg:#101318; --soft:#222420; --add:#16301f; --del:#3a1d19 } }
+ @media (prefers-color-scheme: dark) { :root:not([data-theme=light]) { --bg:#141513; --fg:#ecebe6; --mute:#9d9c95; --line:#2b2c29; --card:#1c1d1b; --pyr:#fb8a4c; --tan:#d2b48c; --tan-fg:#101318; --soft:#222420; --add:#16301f; --del:#3a1d19 } }
+ :root[data-theme=dark] { --bg:#141513; --fg:#ecebe6; --mute:#9d9c95; --line:#2b2c29; --card:#1c1d1b; --pyr:#fb8a4c; --tan:#d2b48c; --tan-fg:#101318; --soft:#222420; --add:#16301f; --del:#3a1d19 }
:root { --accent:var(--pyr) } * { box-sizing:border-box } body { margin:0; background:var(--bg); color:var(--fg); font:16px/1.55 system-ui,-apple-system,"Segoe UI",sans-serif }
@@ -107,7 +108,7 @@ class Site:
</head> <body> <main>
-<header><b><a href="{self.link(page, '../index.html')}"><span class="pyr">Pyr</span><span class="tan">tan</span></a> / <a href="{self.link(page, 'index.html')}">source</a></b><nav>{nav}</nav><span class="mute small">v{esc(self.version)} · {esc(self.branch)}</span></header>
+<header><b><a href="{self.link(page, '../index.html')}"><span class="pyr">pyr</span><span class="tan">tan</span></a> / <a href="{self.link(page, 'index.html')}">source</a></b><nav>{nav}</nav><span class="mute small">v{esc(self.version)} · {esc(self.branch)}</span></header>
{body} </main> </body> diff --git a/src/pyrtan/js/theme.js b/src/pyrtan/js/theme.js index ec06a69..ded17ed 100644 --- a/src/pyrtan/js/theme.js +++ b/src/pyrtan/js/theme.js
@@ -1,19 +1,23 @@
-// The wordmark and its two colours. "Pyr" is the primary colour (buttons,
+// 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.
+// selected text), and its picker also sets the mode: light, dark, or auto to
+// follow the system. Click either half of the wordmark, right-click it, or
+// press and hold it on a touch screen to open that half's picker. 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.
+// A page gives the defaults as --pyr, --pyr-fg, --tan and --tan-fg on :root,
+// with its dark values both under prefers-color-scheme (for
+// :root:not([data-theme=light])) and under :root[data-theme=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"]]},
+ 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 MODES = [["auto", "Auto"], ["light", "Light"], ["dark", "Dark"]];
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) {} };
@@ -28,6 +32,7 @@
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"); } }
+ if (t.mode === "light" || t.mode === "dark") root.dataset.theme = t.mode; else delete root.dataset.theme;
const meta = document.querySelector('meta[name="theme-color"]'); if (meta) { const c = getComputedStyle(root).getPropertyValue("--pyr").trim(); if (c) meta.content = c; } }
@@ -36,6 +41,7 @@
const style = document.createElement("style"); style.textContent = `
+ :root[data-theme=light] { color-scheme:light } :root[data-theme=dark] { color-scheme:dark }
[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 }
@@ -50,6 +56,10 @@
.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-mode { display:flex; gap:4px; margin:10px 0 0; padding:3px; background:var(--soft, #f1efe9); border-radius:8px }
+ .theme-pick .theme-mode[hidden] { display:none }
+ .theme-pick .theme-mode button { flex:1; font:inherit; padding:5px 0; border:0; border-radius:6px; background:transparent; color:var(--fg, #1d1d1b); cursor:pointer }
+ .theme-pick .theme-mode button[aria-pressed=true] { background:var(--card, #fff); box-shadow:0 1px 3px rgba(0,0,0,.2); font-weight:600 }
.theme-pick .theme-hint { color:var(--mute, #6b6a64); font-size:12px; margin:8px 0 0 }`; document.head.appendChild(style);
@@ -60,6 +70,10 @@
if (color) theme[part] = color.toLowerCase(); else delete theme[part]; save(theme); apply(theme); render(); }
+ function mode(m) {
+ if (m === "light" || m === "dark") theme.mode = m; else delete theme.mode;
+ save(theme); apply(theme); render();
+ }
function render() { if (!pick || !openPart) return; const p = PARTS[openPart], cur = current(openPart);
@@ -68,15 +82,19 @@
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;
+ const modes = pick.querySelector(".theme-mode"), on = theme.mode || "auto";
+ modes.hidden = openPart !== "tan";
+ for (const b of modes.querySelectorAll("button")) b.setAttribute("aria-pressed", String(b.dataset.mode === on));
} 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.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-mode" role="group" aria-label="Mode">${MODES.map(([m, label]) => `<button type="button" data-mode="${m}">${label}</button>`).join("")}</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);
+ else if (e.target.closest("button[data-mode]")) mode(e.target.closest("button[data-mode]").dataset.mode);
}); pick.querySelector("input[type=color]").addEventListener("input", (e) => choose(openPart, e.target.value)); document.body.appendChild(pick);
@@ -129,6 +147,8 @@
window.addEventListener("resize", () => { if (pick && !pick.hidden) place(); }); } if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", start); else start();
+ // Auto follows the system; the browser bar's colour follows with it.
+ try { matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => { apply(theme); render(); }); } catch (e) {}
// 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 a9ca17a..846e3b2 100644 --- a/src/pyrtan/page.py +++ b/src/pyrtan/page.py
@@ -42,7 +42,8 @@ PAGE = r"""<!doctype html>
<script>__THEME__</script> <style> :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 } }
+ @media (prefers-color-scheme: dark) { :root:not([data-theme=light]) { --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[data-theme=dark] { --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 }
@@ -86,7 +87,7 @@ PAGE = r"""<!doctype html>
<body> <main> <header>
- <h1><span class="pyr" data-theme-part="pyr">Pyr</span><span class="tan" data-theme-part="tan">tan</span> <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 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> diff --git a/tests/test_service_hub.py b/tests/test_service_hub.py index 98e7a27..8e2ee91 100644 --- a/tests/test_service_hub.py +++ b/tests/test_service_hub.py
@@ -221,7 +221,7 @@ def test_demo_page_is_served_by_service_and_hub(example_service):
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'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 page status, api = http_json("GET", f"{server.url}/api") assert status == 200 and api["pyrtan"] == "service"