Merge integration/rpa-dashboard-skills into ticket 04

Keeps both sides: frontend detection and bot wiring from tickets 02/03, optional
sections from ticket 04. The a-empty-sections eval now uses the shared "fixture" key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
VasanthandClaude Opus 5.5 committed 2026-10-16 15:04:34 +05:30
commit 3a11e55b58
29 files changed
+1964 -59

No files matched your search

+41 -16
View File
@@ -11,11 +11,14 @@ source projects.
| File | What it is |
|---|---|
| `templates/dashboard.html` | The single-file page: theme tokens, shell, KPI, donut, run panel, report, sample data. Works in PyQt (QWebChannel) or on the web (polling). |
| `templates/bridge.py` | Minimal PyQt6 host. It runs the bot as a child process and tails its output. |
| `templates/bridge.py` | Minimal PyQt6 host: the window, the bridge slots, and the saved dark-mode choice. |
| `templates/bot_runner.py` | The bot wiring, without Qt. It runs the bot as a child process, tails its output, and turns lines into step and log events. |
| `templates/contract.md` | The data contract: events the bot sends, actions the page sends. |
| `templates/themes.md` | Theme presets (Tebra Sky, Superbill Ember) and the custom-colour steps. |
| `templates/palette.py` | Derives a custom palette from one brand hex and checks contrast. |
| `templates/sections.py`, `templates/sections.md` | Keeps the chosen optional sections in the page and deletes the rest; what each section needs from the host. |
| `templates/frontend.md` | Existing frontend: detect it, report it, then keep or restyle. Shared with `rpa-dashboard-ai`. |
| `templates/detect_frontend.py` | Finds an existing frontend and prints its location, stack and token blocks. |
| `templates/reference/` | Raw Tebra extracts, for reference only. The optional sections are already in `dashboard.html`. |
Follow the steps in order. Ask one question at a time, and wait for the answer.
@@ -24,22 +27,24 @@ If the user's request already answers a question, don't ask it again.
## 1. Scan the project
Before asking anything, look at the project:
- **Frontend:** `*.html` files outside `node_modules`, `.venv` and build output; a
`web/`, `frontend/` or `static/` folder; `package.json` with react, vue, angular or
svelte; a PyQt `QWebEngineView`. For any you find, note the location, the stack and
the main colour tokens (CSS custom properties or the Tailwind theme).
- **Frontend:** run `python templates/detect_frontend.py <project root>`. It finds
plain HTML, React/Vue/Angular/Svelte apps and PyQt web views, and prints each
one's location, stack and colour tokens. `templates/frontend.md` says what it
covers and what to do with its report.
- **Bot:** the entry point (for example `main.py`, a `__main__` module or a run
script), what it prints (step markers, status and error lines), and how it starts.
## 2. Report the frontend, then ask keep or restyle
Follow `templates/frontend.md` sections 2 to 5. In short:
- **No frontend:** say "No existing frontend found." and go to step 3. Don't
describe a frontend that isn't there.
- **Frontend found:** say "Frontend found at `<path>`, stack `<stack>`, colours
`<main tokens>`." Then ask: **keep its look**, or **restyle** it?
- Keep: skip step 3. Build the page with the project's own tokens and fonts, and
change none of its existing styles.
- Restyle: go to step 3. Change only the theme tokens, never layout or logic.
change no existing file.
- Restyle: go to step 3. In the existing files, change only the token blocks,
never layout or logic, and show the user the diff.
## 3. Ask for colours
@@ -51,6 +56,8 @@ Skip this if the user chose to keep the existing look. Offer:
## 4. Ask desktop or web
Ask only if there is no frontend. If there is one, use its stack.
Don't ask if the project already uses PyQt (`PyQt6` or `PyQt5` in its requirements,
`pyproject.toml` or imports): choose desktop and say so.
- **Desktop:** a PyQt6 window with the page embedded. The page talks to Python
through a bridge, so no web server is needed.
- **Web:** the same page, served by a backend that it polls.
@@ -81,6 +88,7 @@ to answer.
- Desktop: put it next to the host, for example `dashboard/dashboard.html`.
- Web: put it in the backend's static folder. With no backend yet, use
`dashboard/dashboard.html`.
- Existing frontend: put it where `templates/frontend.md` section 3 says.
2. Fill every `{{...}}` placeholder:
| Placeholder | Value |
@@ -94,10 +102,12 @@ to answer.
3. Apply the theme by replacing the `body{...}` and `body.dark{...}` token blocks
with the preset from `templates/themes.md`. For Ember, also add its font link.
For a custom colour, run `python templates/palette.py "<hex>"`, paste the
tokens it prints, and show the user its contrast table.
4. Desktop: also copy `templates/bridge.py` next to the page, and fill
`{{BOT_MODULE}}`, the `STEPS` list (the same steps as the page), `{{ORG}}` and
`{{APP_NAME}}`.
tokens it prints, and show the user its contrast table. If the user chose keep,
fill the blocks from the project's tokens instead (`templates/frontend.md`
section 4).
4. Desktop: also copy `templates/bridge.py` and `templates/bot_runner.py` next to
the page. Fill `{{ORG}}` and `{{APP_NAME}}` in `bridge.py`, and `{{BOT_MODULE}}` in
`bot_runner.py` (with no bot yet, use the module the bot will have, for example `bot`).
5. Search the output for `{{`. None may be left.
## 7. Wire the data
@@ -108,10 +118,22 @@ both directions.
answers its own data actions, so it opens with sample batches and report rows,
and Run plays a demo run. Tell the user which events their bot must send later.
- **Bot exists:** set `SAMPLE_DATA = false` and delete the `SAMPLE DATA` block. Then:
- Desktop: in `bridge.py`, set `BOT_CMD` to start the bot as a child process.
Fill `STEP_MARKERS` and `FRIENDLY_RULES` from lines the bot really prints. Use
specific substrings (for example `[step 3]`, not a bare word); the first match
wins. Unmatched error lines must always reach the log.
- Desktop: all the wiring is in `bot_runner.py`.
1. Get the bot's real output: run it once (for example `python -m bot`), or read
its `print` and logging calls. Never invent lines it doesn't print.
2. Set `BOT_CMD` to start the bot as a child process, and `BOT_CWD` to the folder
it runs from.
3. Set `STEPS_JSON` in the page to the bot's real stages. Fill `STEP_MARKERS` with
`(substring, step label, pct)`: one specific substring per stage (for example
`[step 3]`, never a bare word like `login`), each label exactly one of the
page's STEPS, and pct rising to 100 on the bot's last line. The first match wins.
4. Fill `FRIENDLY_RULES` with `(substring, plain-English message, type)` for the
lines an operator should see. Unmatched lines are hidden, except error lines
(any line with a word from `ERROR_WORDS`), which are always shown as they are.
Add the bot's own failure words to `ERROR_WORDS` if it uses others.
5. Dry run without the window: `python dashboard/bot_runner.py`. It prints each
event the page would get. Check that the steps arrive in order and end at
100, and that the bot's error lines appear as `error` log events.
- Web: the backend serves `GET {API_BASE}/dashboard`, `GET {API_BASE}/run/status`
(returning `{running, events[]}`) and `POST {API_BASE}/<action-with-dashes>`.
- Apply the contract's derived rules: batch state from Pending plus date, and
@@ -119,7 +141,10 @@ both directions.
## 8. Verify
Open the page. On desktop run the host; otherwise open the file or the served URL.
Open the page. On desktop run the host (`python dashboard/bridge.py`), click Run,
and check that the Run button stays disabled until the bot finishes, and that the
dark-mode choice is still set after you close and reopen the window. Otherwise
open the file or the served URL.
With sample data, opening the file in a browser is enough. Check:
- the light theme, the dark theme (use the toggle), and a window 820px wide or less;
- KPI cards, donut and legend, the Run tab's steps and log, and the Report tab's
@@ -0,0 +1,64 @@
"""Bot wiring for the desktop host: runs the bot and turns its output into page events.
No Qt here, so it can be tested and dry-run on its own. Fill BOT_CMD, STEP_MARKERS and
FRIENDLY_RULES from lines the bot really prints.
Dry run, no window: python bot_runner.py (prints each event the page would get)
"""
import json, os, subprocess, sys
from pathlib import Path
BOT_CMD = [sys.executable, "-m", "{{BOT_MODULE}}"] # the bot, started as a child process
BOT_CWD = Path(__file__).resolve().parents[1] # folder BOT_CMD runs in (the project root)
RAW_LOG = Path(__file__).with_name("bot_raw.log") # every raw bot line, kept for audit
# (specific substring, step label from STEPS in dashboard.html, pct). First match wins.
# e.g. ("[step 2]", "Login", 40). Never a bare word like "login": it matches unrelated lines.
STEP_MARKERS = []
# (specific substring, plain-English message, type: info|success|warn|error). First match wins.
# e.g. ("[login] done", "Logged in.", "success"). Unmatched lines are hidden, except error lines.
FRIENDLY_RULES = []
# A line holding any of these is an error line. Unmatched error lines are shown raw, never hidden.
ERROR_WORDS = ("error", "exception", "traceback", "fatal", "failed")
def _first(rules, line):
low = line.lower()
return next((r for r in rules if r[0].lower() in low), None)
def events_for(line):
"""The page events, as (event, data) pairs, for one raw bot line."""
out = []
if marker := _first(STEP_MARKERS, line):
out.append(("step", {"label": marker[1], "pct": marker[2]}))
if rule := _first(FRIENDLY_RULES, line):
out.append(("log", {"msg": rule[1], "type": rule[2]}))
elif any(w in line.lower() for w in ERROR_WORDS): # never hide a real failure
out.append(("log", {"msg": line, "type": "error"}))
return out
def run(send):
"""Run the bot in its own process and send(event, data) for each line it prints, as it prints it."""
send("status", {"state": "running", "msg": "Running…"})
env = {**os.environ, "PYTHONUNBUFFERED": "1", "PYTHONIOENCODING": "utf-8"} # live lines; no cp1252 crash
try:
raw = open(RAW_LOG, "a", encoding="utf-8")
p = subprocess.Popen(BOT_CMD, cwd=BOT_CWD, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, encoding="utf-8", errors="replace")
except OSError as e:
send("log", {"msg": f"Could not start the bot: {e}", "type": "error"})
send("status", {"state": "error", "msg": "Could not start the bot"})
return
with p, raw:
for line in p.stdout:
raw.write(line)
for event, data in events_for(line.rstrip()):
send(event, data)
ok = p.returncode == 0
send("status", {"state": "done", "msg": "Completed"} if ok else
{"state": "error", "msg": f"Failed (exit code {p.returncode})"})
if __name__ == "__main__":
run(lambda event, data: print(json.dumps({"event": event, "data": data}), flush=True))
+26 -35
View File
@@ -1,25 +1,23 @@
"""Minimal PyQt6 host for dashboard.html (desktop mode). Pattern from Tebra main_window.py.
The bot runs as a child process that prints lines, and this window tails them.
Fill in STEP_MARKERS, FRIENDLY_RULES and BOT_CMD for the project.
The bot runs as a child process and this window shows its progress. The bot wiring
(command, step markers, friendly rules) lives in bot_runner.py, next to this file.
Run it with: python dashboard/bridge.py
"""
import json, subprocess, sys, threading
import json, sys, threading
from pathlib import Path
from PyQt6.QtCore import QObject, pyqtSlot, pyqtSignal, QUrl, QSettings
from PyQt6.QtWidgets import QApplication, QMainWindow
from PyQt6.QtWebEngineWidgets import QWebEngineView
from PyQt6.QtWebChannel import QWebChannel
import bot_runner
HTML_PATH = Path(__file__).with_name("dashboard.html")
BOT_CMD = [sys.executable, "-m", "{{BOT_MODULE}}"] # child process
STEPS = ["{{STEP_1}}", "{{STEP_2}}"] # same list as STEPS in dashboard.html
STEP_MARKERS = [("[step 1]", 0, 10), ("[all done]", len(STEPS) - 1, 100)] # (substring, step idx, pct)
FRIENDLY_RULES = [("[login] done", "Logged in.", "success")] # (substring, friendly msg | None, type)
def classify(line):
s = line.lower()
return "error" if ("error" in s or "traceback" in s) else "warn" if "warn" in s else "info"
def settings():
return QSettings("{{ORG}}", "{{APP_NAME}}")
class Bridge(QObject):
@@ -27,8 +25,10 @@ class Bridge(QObject):
def __init__(self, view):
super().__init__()
self.view = view
self.view, self._one_run = view, threading.Lock()
self._emit.connect(lambda js: self.view.page().runJavaScript(f"window.__pyMsg({js})"))
# Restore the saved dark-mode choice on every page load, sample-data mode included.
view.loadFinished.connect(lambda _ok: self.send_to_js("apply_dark", {"on": settings().value("dark", False, bool)}))
def send_to_js(self, event, data):
self._emit.emit(json.dumps(json.dumps({"event": event, "data": data})))
@@ -36,38 +36,29 @@ class Bridge(QObject):
@pyqtSlot()
def load_dashboard(self):
self.send_to_js("dashboard_data", {"batches": []}) # TODO: read from DB on a worker thread
self.send_to_js("apply_dark", {"on": QSettings("{{ORG}}", "{{APP_NAME}}").value("dark", False, bool)})
@pyqtSlot()
def load_latest_report(self):
self.send_to_js("report_data", {"rows": []}) # TODO: read the latest report rows
@pyqtSlot()
def download_report(self):
pass # TODO: export the report rows to .xlsx
@pyqtSlot(bool)
def set_dark(self, on):
QSettings("{{ORG}}", "{{APP_NAME}}").setValue("dark", on)
settings().setValue("dark", on)
@pyqtSlot()
def start_automation(self):
threading.Thread(target=self._run, daemon=True).start()
if self._one_run.acquire(blocking=False): # a second click can't start a second bot
threading.Thread(target=self._run, daemon=True).start()
def _run(self):
self.send_to_js("status", {"state": "running", "msg": "Running…"})
p = subprocess.Popen(BOT_CMD, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1)
for raw in p.stdout:
self._on_line(raw.rstrip())
ok = p.wait() == 0
self.send_to_js("status", {"state": "done" if ok else "error", "msg": "Completed" if ok else "Failed"})
def _on_line(self, line):
# ponytail: raw line only printed here; persist to file/DB if audit is needed
low, kind = line.lower(), classify(line)
for marker, idx, pct in STEP_MARKERS:
if marker in low:
self.send_to_js("step", {"label": STEPS[idx], "pct": pct})
break
for marker, msg, t in FRIENDLY_RULES:
if marker in low:
if msg:
self.send_to_js("log", {"msg": msg, "type": t})
return
if kind == "error": # never hide a real failure
self.send_to_js("log", {"msg": line, "type": "error"})
try:
bot_runner.run(self.send_to_js)
finally:
self._one_run.release()
if __name__ == "__main__":
@@ -2,6 +2,7 @@
All updates use one envelope: `{"event": "<name>", "data": {...}}`.
- **Desktop (PyQt):** Python calls `window.__pyMsg(json)` via `runJavaScript` (see `bridge.py`).
`bot_runner.py` turns the bot's output lines into `status`, `step` and `log` events, and keeps every raw line in `bot_raw.log`.
- **Web:** the page polls `GET {API_BASE}/dashboard` (returns `dashboard_data`) and
`GET {API_BASE}/run/status` → `{"running": bool, "events": [envelope, ...]}`. Poll fast (2s) only while running.
- **No bot yet:** with `SAMPLE_DATA = true`, the page answers its own data actions with sample envelopes.
@@ -0,0 +1,134 @@
"""Find an existing frontend in a project: where it is, its stack and its colour tokens.
Usage: python detect_frontend.py [project dir]
Prints "No existing frontend found.", or one "Frontend found at ..." line per
frontend followed by its token blocks (file, selector, tokens). The keep and
restyle steps in frontend.md work from those blocks.
"""
import json
import os
import re
import sys
from pathlib import Path
# Not the project's own frontend: dependencies, build output, coverage, docs. Dot folders
# (.git, .venv, .claude with the installed skills) are skipped too.
SKIP = {"node_modules", "venv", "env", "site-packages", "__pycache__", "dist", "build", "out",
"coverage", "htmlcov", "docs"}
PAGE = {".html", ".htm"}
STYLE = {".css", ".scss", ".sass", ".less"}
MARKUP = PAGE | {".vue", ".svelte"} # files whose <style> tags hold CSS
# A colour value: hex, a colour function, or a bare HSL triplet (shadcn/ui style "222 47% 11%").
COLOUR = re.compile(r"#[0-9a-f]{3,8}\b|\b(rgba?|hsla?|oklch|oklab|lab|lch|color-mix)\(|^[\d.]+(deg)?\s+[\d.]+%\s+[\d.]+%", re.IGNORECASE)
BRAND = re.compile(r"primary|brand|accent", re.IGNORECASE)
FONT_TOKEN = re.compile(r"font(?!.*(size|weight|height))", re.IGNORECASE)
FRAMEWORKS = [("@angular/core", "Angular"), ("vue", "Vue"), ("svelte", "Svelte"), ("react", "React")]
TOOLS = [("next", "Next.js"), ("nuxt", "Nuxt"), ("vite", "Vite"), ("tailwindcss", "Tailwind")]
def walk(root):
for d, dirs, names in os.walk(root):
dirs[:] = sorted(x for x in dirs if x not in SKIP and not x.startswith("."))
for n in sorted(names):
yield Path(d, n)
def css_of(path):
text = path.read_text(encoding="utf-8", errors="replace")
if path.suffix.lower() in STYLE:
return text
return "\n".join(re.findall(r"<style[^>]*>(.*?)</style>", text, re.DOTALL | re.IGNORECASE))
def token_blocks(css):
"""[(selector path, {token: value})] for each innermost rule that declares custom properties."""
css = re.sub(r"/\*.*?\*/", "", css, flags=re.DOTALL)
out, stack, last = [], [], 0
for m in re.finditer(r"[{}]", css):
if m[0] == "{":
if stack:
stack[-1][2] = True # has a nested rule
stack.append([css[last:m.start()].split(";")[-1].strip(), m.end(), False])
elif stack:
sel, start, nested = stack.pop()
# ponytail: declarations next to a nested rule (CSS nesting) are skipped; token blocks rarely nest
tokens = {} if nested else {k: v.strip() for k, v in re.findall(r"(--[\w-]+)\s*:\s*([^;]+)", css[start:m.start()])}
if tokens:
out.append((" ".join([s for s, *_ in stack] + [sel]), tokens))
last = m.end()
return out
def rel(root, p):
r = p.relative_to(root).as_posix()
return "./" if r == "." else r + "/" if (root / r).is_dir() else r
def tailwind_colours(path):
"""Quoted hex colours in a Tailwind v3 config, by key."""
# ponytail: regex, not a JS parser; nested shades keep only their own key (e.g. "500")
return dict(re.findall(r"""['"]?([\w-]+)['"]?\s*:\s*['"](#[0-9a-fA-F]{3,8})['"]""", path.read_text(encoding="utf-8")))
def styled(p):
return p.suffix.lower() in MARKUP | STYLE or p.name.startswith("tailwind.config.")
def frontend(root, path, stack, files):
"""files: the frontend's style-bearing files (see styled) plus any PyQt host."""
blocks = []
for f in files:
found = [("theme", tailwind_colours(f))] if f.name.startswith("tailwind.config.") else token_blocks(css_of(f))
blocks += [{"file": rel(root, f), "selector": sel, "dark": "dark" in sel.lower(), "tokens": t} for sel, t in found if t]
return {"path": rel(root, path), "stack": stack, "blocks": blocks}
def detect(root):
root = Path(root).resolve()
files = list(walk(root))
found = []
for p in files:
if p.name != "package.json":
continue
try:
pkg = json.loads(p.read_text(encoding="utf-8"))
except ValueError:
continue
deps = {**pkg.get("dependencies", {}), **pkg.get("devDependencies", {})}
name = next((n for k, n in FRAMEWORKS if k in deps), None)
if name:
stack = f"{name} ({', '.join([n for k, n in TOOLS if k in deps] + ['web'])})"
found.append(frontend(root, p.parent, stack, [f for f in files if p.parent in f.parents and styled(f)]))
apps = [root / f["path"] for f in found]
rest = [p for p in files if not any(a in p.parents for a in apps)]
pages = [p for p in rest if p.suffix.lower() in PAGE]
hosts = [p for p in rest if p.suffix == ".py" and "QWebEngineView" in p.read_text(encoding="utf-8", errors="replace")]
if pages or hosts:
styles = [p for p in rest if styled(p) and p not in pages]
# ponytail: one plain-HTML/PyQt frontend per project, at the common folder of its pages and styles
home = Path(os.path.commonpath([p.parent for p in pages + styles])) if pages else hosts[0]
stack = f"PyQt web view (desktop, host `{rel(root, hosts[0])}`)" if hosts else "plain HTML (web)"
found.append(frontend(root, home, stack, pages + styles + hosts))
return found
def report(found):
if not found:
return "No existing frontend found."
lines = []
for f in found:
light = [b["tokens"] for b in f["blocks"] if not b["dark"]]
colours = sorted(((k, v) for t in light for k, v in t.items() if COLOUR.search(v)), key=lambda kv: not BRAND.search(kv[0]))
fonts = [v for t in light for k, v in t.items() if FONT_TOKEN.search(k)]
shown = ", ".join(f"{k} {v}" for k, v in colours[:6]) + (f" (+{len(colours) - 6} more)" if len(colours) > 6 else "")
lines.append(f"Frontend found at `{f['path']}`, stack `{f['stack']}`, colours `{shown or 'none found'}`"
+ (f", fonts `{fonts[0]}`" if fonts else "") + ".")
for b in f["blocks"]:
lines.append(f" {b['file']} {b['selector']}{' (dark)' if b['dark'] else ''}")
lines.append(" " + "; ".join(f"{k}:{v}" for k, v in b["tokens"].items()))
return "\n".join(lines)
if __name__ == "__main__":
print(report(detect(sys.argv[1] if len(sys.argv) > 1 else ".")))
+114
View File
@@ -0,0 +1,114 @@
# Existing frontend: detect, report, keep or restyle
Shared by `rpa-dashboard-no-ai` and `rpa-dashboard-ai`. The files named here sit
next to this one, in `rpa-dashboard-no-ai/templates/`. From the AI skill, reach them
through `../rpa-dashboard-no-ai/templates/`.
## 1. Detect
Run this before asking anything:
```
python <rpa-dashboard-no-ai>/templates/detect_frontend.py <project root>
```
It finds:
- React, Vue, Angular and Svelte apps (a `package.json` that lists one);
- a PyQt `QWebEngineView` host and the pages it shows;
- plain HTML pages and their stylesheets.
It skips `node_modules`, virtualenvs, dot folders (including `.claude`), build
output, coverage and `docs`. For each frontend it prints:
- one line: location, stack (with `web` or `desktop`), main colours and font;
- then every token block: the file, the selector (dark blocks are marked) and
the tokens.
Tokens come from:
- CSS custom properties, including Tailwind v4 `@theme`;
- `<style>` tags in pages, components and PyQt hosts;
- the colours in a Tailwind v3 config.
It doesn't read theme objects in JS/TS (MUI, styled-components). If it prints
`colours none found`, look for one of those, or for colours hard-coded in rules,
before you report.
## 2. Report, then ask keep or restyle
- **No frontend:** say "No existing frontend found." Don't ask keep or restyle.
- **Frontend found:** say the script's first line: "Frontend found at `<path>`,
stack `<stack>`, colours `<main tokens>`." If it found more than one, report
each and ask which one the dashboard joins.
- Then ask one question: **keep its look, or restyle it?** Don't name presets in
this question. Colours are asked next, and only for restyle.
- Never ask desktop or web. The stack comes from the report: `web` means the
web path, `desktop` means the PyQt path.
- **No token blocks** (every colour is hard-coded): say so. A restyle can't be
token-only then, so recommend keep, or ask before you pull the colours into a
new token block.
## 3. Where the page goes
| Stack | Put the page |
|---|---|
| plain HTML | Next to the existing pages, for example `web/dashboard.html`. |
| React, Vue, Angular, Svelte | In the app's `public/` folder (`public/dashboard.html`), so it is served as-is. Port it into the app's components only if the user asks: best effort, with the page as reference. |
| PyQt web view | Next to the host's page. Use the desktop path (`bridge.py`). |
Don't edit existing pages to link to it. Tell the user the path to link from
their nav.
## 4. Keep
The new page uses the project's look. **No existing file changes.** Skip the colour
question.
- Fill the page's `body{}` block from the project's light tokens, and `body.dark{}`
from its dark tokens. Copy the **values**, not `var(--…)` references: the page
turns dark mode on with `body.dark`, which the project's own dark selector
doesn't match. Above each block, note where the values came from, for example
`/* kept from web/css/theme.css */`.
- Map each page token to the project token with the same role:
| Page token | Project token for |
|---|---|
| `--accent`, `--accent-2` | the primary/brand colour, and its lighter or hover shade |
| `--accent-dark` | the darkest brand shade (the log box background) |
| `--accent-soft`, `--accent-soft-2` | the brand tint (secondary buttons) and its border |
| `--bg-top`, `--bg-mid`, `--bg-bottom` | the page background; use the same value three times for a flat page |
| `--glass`, `--glass-strong` | the card surface, and the raised or alternate surface |
| `--border`, `--ink`, `--muted` | the border, the text and the muted text |
| `--ok`, `--warn`, `--bad` and their `-bg` | success, warning and danger; keep the template's values if the project has none |
| `--font`, `--font-display` | the body font and the heading font |
| `--header-*`, `--primary-bg`, `--nav-active-bg`, `--progress-bg` | wherever the project shows its brand. A solid brand top bar, for example, is `--header-bg:<brand>; --header-ink:#ffffff`. |
| `--blur` | `none`, unless the project uses frosted glass |
- **No match:** run `palette.py` on the brand colour to get the accent family.
Keep the Tebra Sky value for anything else.
- **No dark tokens:** put `palette.py`'s dark accents on the Tebra Sky dark block.
- **Web fonts:** if the project loads its font with a `<link>` or `@import`, add
the same line to the page's `<head>`.
## 5. Restyle
The existing files change **only inside their token blocks**.
1. Ask for colours (the skill's colour step): Tebra Sky, Superbill Ember or a
custom hex.
2. Edit only the token lines that the detect report listed. Give each project
token the chosen palette's value for its role (read the keep table from right
to left):
- the light block gets values from the palette's `body{}` block;
- the dark block gets values from its `body.dark{}` block.
Keep every token name, and keep any token the palette has no match for. Don't
add blocks the project doesn't have.
3. **Fonts:** change only the font tokens. If the new font needs loading (Ember),
add its `<link>` next to the project's other stylesheet links. That is the
only other line that may change.
4. Leave hard-coded colours outside the token blocks alone, and list them for
the user.
5. Build the new page with the palette's blocks from `themes.md`, the same way as
when there is no frontend.
6. Show the user the diff of the existing files: `git diff` in a repo, otherwise
each changed line. It must show only token lines, plus the font link if one
was added.