4343import datetime
4444import html as _html
4545import json
46+ import os
47+ import pathlib
4648import sys
4749from dataclasses import dataclass , field
4850from typing import Any , Iterable , Sequence
@@ -145,6 +147,37 @@ def _library_names() -> tuple:
145147# that links "the webpage" agrees on the URL.
146148PAGES_URL = "https://pyautolabs.github.io/PyAutoHeart/"
147149
150+ # The family look lives once, in the Brain (``board/_theme.py``): the
151+ # stylesheet, the hero that redraws this organ's logo as a mark, and the
152+ # cross-board footer. Imported rather than copied, so the look moves for the
153+ # whole family at once — heart-health.yml checks PyAutoBrain out beside this
154+ # repo, and a local run finds the sibling checkout the way the other PyAuto
155+ # tools resolve each other.
156+ HEART_HOME = pathlib .Path (__file__ ).resolve ().parents [1 ]
157+ BOARD_KEY = "heart" # this board's entry in the Brain's palette table
158+
159+
160+ def theme ():
161+ """The shared theme module, or a RuntimeError naming the fix.
162+
163+ Only the html surface needs it; the md/json/badge surfaces never call
164+ here, so the Health Agent keeps working with no PyAutoBrain in reach.
165+ """
166+ for cand in (os .environ .get ("PYAUTO_BRAIN" ), HEART_HOME / "PyAutoBrain" ,
167+ HEART_HOME .parent / "PyAutoBrain" ,
168+ pathlib .Path .home () / "Code" / "PyAutoLabs" / "PyAutoBrain" ):
169+ if not cand :
170+ continue
171+ board_dir = pathlib .Path (cand ) / "board"
172+ if (board_dir / "_theme.py" ).is_file ():
173+ if str (board_dir ) not in sys .path :
174+ sys .path .insert (0 , str (board_dir ))
175+ import _theme
176+ return _theme
177+ raise RuntimeError (
178+ "the shared board theme (PyAutoBrain/board/_theme.py) is not in reach "
179+ "— check PyAutoBrain out beside this repo or set PYAUTO_BRAIN" )
180+
148181# The one-tap board family — the cross-board footer nav every board carries,
149182# each board skipping its own entry. The base comes from PAGES_URL so the
150183# owner is named exactly once in this file.
@@ -154,9 +187,11 @@ def _library_names() -> tuple:
154187
155188
156189def _boards_nav_html () -> str :
190+ """The cross-board footer — one chip per sibling, each in its own organ's
191+ colour (the theme owns the chip palette; this board owns the URLs)."""
157192 base = PAGES_URL .rsplit ("/" , 2 )[0 ]
158- return " · " . join ( f'<a href= "{ base } /{ repo } /"> { name } </a>'
159- for name , repo in BOARD_FAMILY )
193+ links = { key : f "{ base } /{ repo } /" for key , repo in BOARD_FAMILY }
194+ return theme (). boards_footer ( links , BOARD_KEY )
160195
161196# v2: sections gained links/action/observed_ago; the board gained structured
162197# `blockers` ({text, severity, repo, repo_url, run_url, prompt}). Additive.
@@ -1195,8 +1230,7 @@ def _copy_btn(payload: str, label: str = "copy") -> str:
11951230 and the payload — a Claude prompt or a command — is ready to paste."""
11961231 return (f"<button class='copy' type='button' "
11971232 f"title='{ _html .escape (label , quote = True )} ' "
1198- f"data-copy=\" { _html .escape (payload , quote = True )} \" "
1199- f"onclick='cp(this)'>📋</button>" )
1233+ f"data-cmd=\" { _html .escape (payload , quote = True )} \" >📋</button>" )
12001234
12011235
12021236def _html_reason (item : dict ) -> str :
@@ -1214,6 +1248,42 @@ def _html_reason(item: dict) -> str:
12141248 return f"<li>{ text } </li>"
12151249
12161250
1251+ # The Heart's verdict in the theme's tone vocabulary. The board's own
1252+ # `_VERDICT_STATE` stays the internal truth; this is only how it is painted.
1253+ _VERDICT_TONE = {"red" : "bad" , "yellow" : "warn" , "stale" : "warn" ,
1254+ "green" : "ok" }
1255+
1256+ _LEDE = ("Is it safe to release? Every check the Heart observes, with the "
1257+ "evidence behind each verdict. \U0001f4cb copies a ready-to-paste prompt "
1258+ "or command for a Claude Code chat." )
1259+
1260+ # The page-specific shapes the shared sheet has no opinion on: the per-row
1261+ # state dot, the evidence list, the stale banner. Written against the theme's
1262+ # variables, so this board follows the family accent rather than setting a
1263+ # second palette.
1264+ _EXTRA_CSS = """
1265+ table.board td.dot{width:1.15rem;padding-right:.35rem}
1266+ table.board td.dot::before{content:"";display:inline-block;width:10px;
1267+ height:10px;border-radius:50%;margin-top:.35rem;background:var(--muted)}
1268+ table.board tr.ok td.dot::before{background:var(--ok)}
1269+ table.board tr.warn td.dot::before{background:var(--warn)}
1270+ table.board tr.fail td.dot::before{background:var(--bad)}
1271+ table.board tr.info td.dot::before{background:var(--accent)}
1272+ table.board td.name{font-weight:600;white-space:nowrap}
1273+ table.board tr.unobs td.name,table.board tr.unobs td.sum{color:var(--muted)}
1274+ ul.det{margin:.35rem 0 0;padding-left:1.1rem;color:var(--muted);
1275+ font-size:.85rem}
1276+ .ago{color:var(--muted)}
1277+ a.out{font-size:.85rem;white-space:nowrap}
1278+ .stale{background:var(--btn);border:1px solid var(--warn);color:var(--warn);
1279+ padding:.55rem .75rem;border-radius:8px}
1280+ .reasons{margin:1.5rem 0}
1281+ .reasons li{margin:.3rem 0}
1282+ .hint{color:var(--muted);font-size:.85em;margin:.5rem 0 0}
1283+ footer{margin-top:2rem;color:var(--muted);font-size:.82em}
1284+ """
1285+
1286+
12171287def _render_html (board : Board ) -> str :
12181288 word = _VERDICT_WORD .get (board .verdict , "GREEN" )
12191289 vstate = _VERDICT_STATE .get (board .verdict , OK )
@@ -1253,73 +1323,28 @@ def _render_html(board: Board) -> str:
12531323 "<p class='stale'>⚠️ This board is stale — the last tick is older than the "
12541324 "freshness threshold; the numbers may not be current.</p>" if board .stale else ""
12551325 )
1326+ t_ = theme ()
1327+ hero = t_ .hero (BOARD_KEY , "Dashboard" , _LEDE )
12561328 return f"""<!doctype html>
12571329<html lang="en"><head><meta charset="utf-8">
12581330<meta name="viewport" content="width=device-width, initial-scale=1">
1259- <title>PyAuto health — { word } </title>
1260- <style>
1261- :root {{ color-scheme: light dark; }}
1262- * {{ box-sizing: border-box; }}
1263- body {{ font: 15px/1.5 -apple-system, Segoe UI, Roboto, sans-serif;
1264- margin: 0; padding: 2rem 1rem; background: #0d1117; color: #c9d1d9; }}
1265- .wrap {{ max-width: 760px; margin: 0 auto; }}
1266- h1 {{ font-size: 1.3rem; margin: 0 0 .25rem; }}
1267- .verdict {{ display: inline-block; padding: .3rem .9rem; border-radius: 999px;
1268- font-weight: 700; letter-spacing: .04em; }}
1269- .verdict.ok {{ background: #1a7f37; color: #fff; }}
1270- .verdict.warn {{ background: #9e6a03; color: #fff; }}
1271- .verdict.fail {{ background: #b62324; color: #fff; }}
1272- .meta {{ color: #8b949e; margin: .5rem 0 1.25rem; font-size: .9rem; }}
1273- .stale {{ background: #341a00; color: #e3b341; padding: .5rem .75rem;
1274- border-radius: 6px; }}
1275- table {{ width: 100%; border-collapse: collapse; }}
1276- td {{ padding: .55rem .5rem; border-top: 1px solid #21262d; vertical-align: top; }}
1277- td.dot {{ width: 10px; }}
1278- td.dot::before {{ content: ""; display: inline-block; width: 10px; height: 10px;
1279- border-radius: 50%; margin-top: .35rem; }}
1280- tr.ok td.dot::before {{ background: #3fb950; }}
1281- tr.warn td.dot::before {{ background: #d29922; }}
1282- tr.fail td.dot::before {{ background: #f85149; }}
1283- tr.unobs td.dot::before {{ background: #6e7681; }}
1284- tr.info td.dot::before {{ background: #58a6ff; }}
1285- td.name {{ font-weight: 600; white-space: nowrap; }}
1286- tr.unobs td.name, tr.unobs td.sum {{ color: #8b949e; }}
1287- ul.det {{ margin: .35rem 0 0; padding-left: 1.1rem; color: #8b949e;
1288- font-size: .85rem; }}
1289- .reasons {{ margin: 1.5rem 0; }}
1290- .reasons h2 {{ font-size: 1rem; }}
1291- .reasons li {{ margin: .25rem 0; }}
1292- a {{ color: #58a6ff; text-decoration: none; }}
1293- a:hover {{ text-decoration: underline; }}
1294- a.out {{ font-size: .85rem; white-space: nowrap; }}
1295- .ago {{ color: #8b949e; font-size: .85rem; }}
1296- .hint {{ color: #8b949e; font-size: .8rem; margin: .5rem 0 0; }}
1297- button.copy {{ background: #21262d; border: 1px solid #30363d; border-radius: 6px;
1298- color: #c9d1d9; cursor: pointer; padding: .05rem .45rem;
1299- margin-left: .35rem; font-size: .85rem; line-height: 1.4; }}
1300- button.copy:hover {{ background: #30363d; }}
1301- footer {{ margin-top: 2rem; color: #8b949e; font-size: .8rem; }}
1302- </style>
1303- <script>
1304- function cp(b){{var t=b.getAttribute('data-copy');
1305- if(navigator.clipboard&&navigator.clipboard.writeText){{
1306- navigator.clipboard.writeText(t).then(function(){{ok(b)}},function(){{fb(t)}});
1307- }}else{{fb(t)}}}}
1308- function ok(b){{b.textContent='✓';setTimeout(function(){{b.textContent='📋'}},1200)}}
1309- function fb(t){{window.prompt('Copy this:',t)}}
1310- </script></head>
1311- <body><div class="wrap">
1312- <h1>PyAutoHeart Dashboard</h1>
1313- <p><span class="verdict { vstate } ">{ word } · score { board .score } </span></p>
1314- <p class="meta">snapshot { _html .escape (board .ts )} · { age } · <a href="dashboard.md">markdown version</a></p>
1315- { stale_html }
1316- { reasons_html }
1317- <table>{ '' .join (rows )} </table>
1318- <footer>Rendered by <code>heart/dashboard.py</code> — one renderer, many surfaces.
1319- Observer only: PyAutoHeart never writes outside its own repo/state.
1320- 📋 buttons copy a Claude prompt or command to your clipboard.</footer>
1321- <p class="meta">Boards: { _boards_nav_html ()} </p>
1322- </div></body></html>
1331+ <title>PyAutoHeart Dashboard — { word } </title>
1332+ <style>{ t_ .css (BOARD_KEY )} { _EXTRA_CSS } </style>
1333+ </head>
1334+ <body>
1335+ { hero }
1336+ <p class="verdict { _VERDICT_TONE .get (board .verdict , '' )} "><b>{ word } · score
1337+ { board .score } </b><span class="muted">snapshot { _html .escape (board .ts )} ·
1338+ { age } · <a href="dashboard.md">markdown version</a></span></p>
1339+ { stale_html }
1340+ { reasons_html }
1341+ <table class="recent board">{ '' .join (rows )} </table>
1342+ { _boards_nav_html ()}
1343+ <footer>Rendered by <code>heart/dashboard.py</code> — one renderer, many
1344+ surfaces. Observer only: PyAutoHeart never writes outside its own repo/state.
1345+ \U0001f4cb buttons copy a Claude prompt or command to your clipboard.</footer>
1346+ <script>{ t_ .JS } </script>
1347+ </body></html>
13231348"""
13241349
13251350
0 commit comments