diff --git a/.github/workflows/heart-health.yml b/.github/workflows/heart-health.yml index 77ea25d..3c20dd8 100644 --- a/.github/workflows/heart-health.yml +++ b/.github/workflows/heart-health.yml @@ -65,6 +65,14 @@ jobs: - name: Checkout PyAutoHeart uses: actions/checkout@v4 + # The shared board look the dashboard renders with — imported from the + # Brain, never copied here. + - name: Checkout PyAutoBrain (the shared board theme) + uses: actions/checkout@v4 + with: + repository: PyAutoLabs/PyAutoBrain + path: PyAutoBrain + - name: Set up Python uses: actions/setup-python@v5 with: diff --git a/.github/workflows/heart-tests.yml b/.github/workflows/heart-tests.yml index 6d58252..c0e9537 100644 --- a/.github/workflows/heart-tests.yml +++ b/.github/workflows/heart-tests.yml @@ -53,6 +53,13 @@ jobs: with: repository: PyAutoLabs/PyAutoMind path: PyAutoMind + # The shared board look the dashboard renders with — imported from the + # Brain, never copied here. + - name: Checkout PyAutoBrain (the shared board theme) + uses: actions/checkout@v4 + with: + repository: PyAutoLabs/PyAutoBrain + path: PyAutoBrain - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: diff --git a/heart/dashboard.py b/heart/dashboard.py index 260d77f..87ef3ac 100644 --- a/heart/dashboard.py +++ b/heart/dashboard.py @@ -43,6 +43,8 @@ import datetime import html as _html import json +import os +import pathlib import sys from dataclasses import dataclass, field from typing import Any, Iterable, Sequence @@ -145,6 +147,47 @@ def _library_names() -> tuple: # that links "the webpage" agrees on the URL. PAGES_URL = "https://pyautolabs.github.io/PyAutoHeart/" +# The family look lives once, in the Brain (``board/_theme.py``): the +# stylesheet, the hero that redraws this organ's logo as a mark, and the +# cross-board footer. Imported rather than copied, so the look moves for the +# whole family at once — heart-health.yml checks PyAutoBrain out beside this +# repo, and a local run finds the sibling checkout the way the other PyAuto +# tools resolve each other. +HEART_HOME = pathlib.Path(__file__).resolve().parents[1] +BOARD_KEY = "heart" # this board's entry in the Brain's palette table + + +def _workspace_root() -> pathlib.Path: + """Where the sibling PyAuto checkouts live: `$PYAUTO_ROOT`, else `~/Code`. + + The org's own directory name is an instance fact, so it is never written + here — a workspace that does not follow the default sets `$PYAUTO_ROOT` + (the same variable the dev-flow doors read). + """ + return pathlib.Path(os.environ.get("PYAUTO_ROOT") or pathlib.Path.home() / "Code") + + +def theme(): + """The shared theme module, or a RuntimeError naming the fix. + + Only the html surface needs it; the md/json/badge surfaces never call + here, so the Health Agent keeps working with no PyAutoBrain in reach. + """ + for cand in (os.environ.get("PYAUTO_BRAIN"), HEART_HOME / "PyAutoBrain", + HEART_HOME.parent / "PyAutoBrain", + _workspace_root() / "PyAutoBrain"): + if not cand: + continue + board_dir = pathlib.Path(cand) / "board" + if (board_dir / "_theme.py").is_file(): + if str(board_dir) not in sys.path: + sys.path.insert(0, str(board_dir)) + import _theme + return _theme + raise RuntimeError( + "the shared board theme (PyAutoBrain/board/_theme.py) is not in reach " + "— check PyAutoBrain out beside this repo or set PYAUTO_BRAIN") + # The one-tap board family — the cross-board footer nav every board carries, # each board skipping its own entry. The base comes from PAGES_URL so the # owner is named exactly once in this file. @@ -154,9 +197,11 @@ def _library_names() -> tuple: def _boards_nav_html() -> str: + """The cross-board footer — one chip per sibling, each in its own organ's + colour (the theme owns the chip palette; this board owns the URLs).""" base = PAGES_URL.rsplit("/", 2)[0] - return " · ".join(f'{name}' - for name, repo in BOARD_FAMILY) + links = {key: f"{base}/{repo}/" for key, repo in BOARD_FAMILY} + return theme().boards_footer(links, BOARD_KEY) # v2: sections gained links/action/observed_ago; the board gained structured # `blockers` ({text, severity, repo, repo_url, run_url, prompt}). Additive. @@ -1195,8 +1240,7 @@ def _copy_btn(payload: str, label: str = "copy") -> str: and the payload — a Claude prompt or a command — is ready to paste.""" return (f"") + f"data-cmd=\"{_html.escape(payload, quote=True)}\">📋") def _html_reason(item: dict) -> str: @@ -1214,6 +1258,42 @@ def _html_reason(item: dict) -> str: return f"
  • {text}
  • " +# The Heart's verdict in the theme's tone vocabulary. The board's own +# `_VERDICT_STATE` stays the internal truth; this is only how it is painted. +_VERDICT_TONE = {"red": "bad", "yellow": "warn", "stale": "warn", + "green": "ok"} + +_LEDE = ("Is it safe to release? Every check the Heart observes, with the " + "evidence behind each verdict. \U0001f4cb copies a ready-to-paste prompt " + "or command for a Claude Code chat.") + +# The page-specific shapes the shared sheet has no opinion on: the per-row +# state dot, the evidence list, the stale banner. Written against the theme's +# variables, so this board follows the family accent rather than setting a +# second palette. +_EXTRA_CSS = """ +table.board td.dot{width:1.15rem;padding-right:.35rem} +table.board td.dot::before{content:"";display:inline-block;width:10px; + height:10px;border-radius:50%;margin-top:.35rem;background:var(--muted)} +table.board tr.ok td.dot::before{background:var(--ok)} +table.board tr.warn td.dot::before{background:var(--warn)} +table.board tr.fail td.dot::before{background:var(--bad)} +table.board tr.info td.dot::before{background:var(--accent)} +table.board td.name{font-weight:600;white-space:nowrap} +table.board tr.unobs td.name,table.board tr.unobs td.sum{color:var(--muted)} +ul.det{margin:.35rem 0 0;padding-left:1.1rem;color:var(--muted); + font-size:.85rem} +.ago{color:var(--muted)} +a.out{font-size:.85rem;white-space:nowrap} +.stale{background:var(--btn);border:1px solid var(--warn);color:var(--warn); + padding:.55rem .75rem;border-radius:8px} +.reasons{margin:1.5rem 0} +.reasons li{margin:.3rem 0} +.hint{color:var(--muted);font-size:.85em;margin:.5rem 0 0} +footer{margin-top:2rem;color:var(--muted);font-size:.82em} +""" + + def _render_html(board: Board) -> str: word = _VERDICT_WORD.get(board.verdict, "GREEN") vstate = _VERDICT_STATE.get(board.verdict, OK) @@ -1253,73 +1333,28 @@ def _render_html(board: Board) -> str: "

    ⚠️ This board is stale — the last tick is older than the " "freshness threshold; the numbers may not be current.

    " if board.stale else "" ) + t_ = theme() + hero = t_.hero(BOARD_KEY, "Dashboard", _LEDE) return f""" -PyAuto health — {word} - - -
    -

    PyAutoHeart Dashboard

    -

    {word} · score {board.score}

    -

    snapshot {_html.escape(board.ts)} · {age} · markdown version

    - {stale_html} - {reasons_html} - {''.join(rows)}
    - -

    Boards: {_boards_nav_html()}

    -
    +PyAutoHeart Dashboard — {word} + + + +{hero} +

    {word} · score + {board.score}snapshot {_html.escape(board.ts)} · + {age} · markdown version

    +{stale_html} +{reasons_html} +{''.join(rows)}
    +{_boards_nav_html()} + + + """ diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index e143aca..fcff740 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -466,7 +466,7 @@ def test_html_carries_copy_buttons_and_run_links(): v = make_verdict("red", 45, red_reasons=["autolens_workspace: Smoke Tests failure on main"]) out = dashboard.render(_failing_snapshot(), v, fmt="html", now=FRESH_NOW) - assert "data-copy=" in out and "cp(this)" in out + assert "data-cmd=" in out # the shared copy handler's payload hook assert "/bug Heart board: autolens_workspace" in out assert RUN_URL in out # the failing repo group row links the run too @@ -800,7 +800,7 @@ def test_performance_no_run_rows_are_capped_at_ten(): def test_html_carries_the_event_prompt_in_a_data_copy_attribute(): out = dashboard.render(_perf_snapshot(), make_verdict(), fmt="html", now=FRESH_NOW) - assert f'data-copy="{_html_escape(EVENT_PROMPT)}"' in out + assert f'data-cmd="{_html_escape(EVENT_PROMPT)}"' in out assert EVENT_URL in out @@ -827,3 +827,16 @@ def test_malformed_performance_slices_never_break_the_board(): for fmt in ("term", "md", "html", "json"): assert isinstance(dashboard.render(snap, make_verdict(), fmt=fmt, now=FRESH_NOW), str) + + +def test_html_wears_the_shared_family_theme(): + # The look is the Brain's `board/_theme.py`, not a stylesheet copied in + # here: the page must carry this board's hero (mark, wordmark, tagline) + # and its accent, or it has silently fallen out of the family. + t = dashboard.theme() + out = dashboard.render(_failing_snapshot(), make_verdict("red", 45), + fmt="html", now=FRESH_NOW) + assert t.MARKS[dashboard.BOARD_KEY] in out + assert t.ORGANS[dashboard.BOARD_KEY]["tagline"] in out + assert t.ORGANS[dashboard.BOARD_KEY]["ink_dark"] in out + assert "#58a6ff" not in out # the old hard-coded GitHub blue