diff --git a/.cursor/rules/panel-design.mdc b/.cursor/rules/panel-design.mdc
new file mode 100644
index 0000000..5d04090
--- /dev/null
+++ b/.cursor/rules/panel-design.mdc
@@ -0,0 +1,39 @@
+---
+description: The panel's design is fixed by accepted mockups; implement it, do not reinterpret it.
+globs: internal/web/view/**,docs/assets/panel-redesign/**
+alwaysApply: false
+---
+
+# Panel design contract
+
+Source of truth, in this order: the mockups in
+[docs/assets/panel-redesign/panel/](docs/assets/panel-redesign/index.html),
+the [components page](docs/assets/panel-redesign/panel/components.html),
+`panel/outlines.json`, then
+[docs/plans/panel-redesign.md](docs/plans/panel-redesign.md). Read the plan's
+**The contract** and **Enforcement** sections before touching a template.
+
+You are implementing a finished design. You are not designing.
+
+- Build pages only from Bulma classes in the `BULMA` list of `panel/check.py`
+ and from the `sp-` components on the components page, through their
+ partials. No other class, no hand-written component markup, no inline style.
+- No selector in `panel.css` that names a page or an id, uses `!important`, or
+ sets a width outside `WIDTH_OK`. If a page looks wrong, the layout choice is
+ wrong — do not patch it locally.
+- If a screen seems to need a component, state or layout that does not exist:
+ **stop and ask.** Do not approximate it, and do not substitute a look of
+ your own.
+- Never edit a mockup, `outlines.json`, `check.py`, the vendored Bulma or a
+ guard test to make your work pass. A failing guard means the implementation
+ is wrong. Design changes go mockup-first in a separate `design:` commit on
+ the owner's explicit instruction.
+- "Done" is evidence, not a description: passing `go test ./internal/web/...`
+ output plus a screenshot of each restyled page from the running container,
+ saved under `docs/assets/panel-redesign/evidence/` beside its mockup's name.
+ Report every difference from the mockup you could not remove; do not
+ describe a different result as the requested one.
+
+After editing a fragment in `panel/src/`: `python panel/build.py`, then
+`python panel/check.py` (add `--write` only when the structure change is the
+point of the commit).
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7ff23cc..91de086 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -23,6 +23,18 @@ document is not part of the release.
### Added
+- Panel redesign, design contract:
+ [docs/plans/panel-redesign.md](docs/plans/panel-redesign.md) rewritten for
+ the accepted direction — three layers (Bulma as shipped, adapters, own `sp-`
+ components), three page layouts, what an implementer must not do, the guard
+ tests that land before any template, a `design-first` CI step and
+ evidence-based acceptance. A
+ [components page](docs/assets/panel-redesign/panel/components.html) shows
+ every building block in every state; `panel/check.py` lints the mockups
+ against the contract and writes `panel/outlines.json`, the per-screen
+ component skeleton an implementation is compared with. Own modifier classes
+ renamed to carry the `sp-` prefix so the vocabulary is machine-checkable.
+ Agent rule `.cursor/rules/panel-design.mdc`; roadmap row updated (`0/15`).
- Panel redesign, stage 2: the accepted direction drawn on every screen —
29 pages under [docs/assets/panel-redesign/panel/](docs/assets/panel-redesign/index.html).
Direction C (Bulma) is the base, with A's postal signature (postmark verdict,
diff --git a/docs/assets/panel-redesign/index.html b/docs/assets/panel-redesign/index.html
index f77de1d..c2b7e58 100644
--- a/docs/assets/panel-redesign/index.html
+++ b/docs/assets/panel-redesign/index.html
@@ -13,7 +13,7 @@
The accepted direction, every screen
Direction C (Bulma) was picked as the base: the important things in view, details one
or two clicks away. It takes the postal signature of A — the postmark verdict, numbered mono section
- heads, the stamp's perforated edge — and the icons of B. Twenty-nine screens live in
+ heads, the stamp's perforated edge — and the icons of B. Twenty-nine screens and a components page live in
panel/. Serve this folder from the repository root: the pages load the panel's own
fonts and mark by relative path.
@@ -36,6 +36,8 @@
Backup · Users · user form ·
delete user · Settings
| User menu | Account · Help |
+ | Contract | Components — every building block in every state ·
+ the plan · panel/check.py lints the mockups and writes panel/outlines.json |
diff --git a/docs/assets/panel-redesign/panel/check.py b/docs/assets/panel-redesign/panel/check.py
new file mode 100644
index 0000000..d099d78
--- /dev/null
+++ b/docs/assets/panel-redesign/panel/check.py
@@ -0,0 +1,227 @@
+"""Design-contract lint for the panel mockups, and the source of outlines.json.
+
+The contract (docs/plans/panel-redesign.md) is only worth something if a
+machine can say "no". This script is that machine for the mockups; the same
+rules are ported to a Go test over internal/web templates when the redesign is
+implemented, and that test compares rendered pages with outlines.json.
+
+ python docs/assets/panel-redesign/panel/check.py # lint
+ python docs/assets/panel-redesign/panel/check.py --write # lint + rewrite outlines.json
+
+Exit code 1 on any violation.
+"""
+import json
+import pathlib
+import re
+import sys
+from html.parser import HTMLParser
+
+HERE = pathlib.Path(__file__).parent
+VOID = {"input", "img", "br", "hr", "meta", "link"}
+
+# The subset of Bulma the panel uses. Reaching for a Bulma class outside this
+# list is a design decision, not an implementation detail: add it here and to
+# the components page in the same change, or do without it.
+BULMA = set("""
+box button buttons checkbox column columns control field file file-cta file-input file-label
+file-name has-addons has-icons-left has-name has-text-danger help icon input label menu menu-label
+menu-list progress select table tag textarea title
+is-3 is-4 is-5 is-6 is-7 is-9 is-active is-danger is-expanded is-fullwidth is-gapless is-half
+is-hoverable is-left is-light is-primary is-size-7 is-small is-success is-text is-warning
+mb-1 mb-2 mb-3 mb-4 ml-2 mt-4 mt-5
+""".split())
+# Classes the shell (build.py, later layout.html) owns; screens never write them.
+SHELL = {"sp-page", "sp-perf", "sp-subnav", "sp-current", "sp-foot"}
+# The signed-out screens have no shell, so they carry their own footer.
+SIGNED_OUT = {"sp-signin", "sp-signin-brand", "sp-signin-form", "sp-foot"}
+# Selectors allowed to set a width. Everything else gets its width from the
+# layout it sits in — that is the whole point of the redesign.
+WIDTH_OK = (".navbar-brand img", ".sp-head .sp-lead", ".sp-head .sp-grow", ".sp-postmark", ".table td.sp-clip",
+ ".sp-h-icon", ".sp-signin", ".sp-timeline li::before", ".sp-perf")
+
+
+class Node:
+ def __init__(self, tag, attrs, parent):
+ self.tag, self.attrs, self.parent, self.kids, self.text = tag, dict(attrs), parent, [], ""
+
+ @property
+ def classes(self):
+ return (self.attrs.get("class") or "").split()
+
+ def has(self, cls):
+ return cls in self.classes
+
+ def walk(self):
+ yield self
+ for k in self.kids:
+ yield from k.walk()
+
+ def find(self, cls):
+ return [n for n in self.walk() if n.has(cls)]
+
+
+class Tree(HTMLParser):
+ def __init__(self):
+ super().__init__()
+ self.root = Node("root", [], None)
+ self.cur = self.root
+
+ def handle_starttag(self, tag, attrs):
+ n = Node(tag, attrs, self.cur)
+ self.cur.kids.append(n)
+ if tag not in VOID:
+ self.cur = n
+
+ def handle_endtag(self, tag):
+ n = self.cur
+ while n is not self.root and n.tag != tag:
+ n = n.parent
+ if n is not self.root:
+ self.cur = n.parent
+
+ def handle_data(self, data):
+ self.cur.text += data
+
+
+def parse(text):
+ t = Tree()
+ t.feed(text)
+ return t.root
+
+
+def text_of(node):
+ return re.sub(r"\s+", " ", "".join(n.text for n in node.walk())).strip()
+
+
+def css_rules(css):
+ css = re.sub(r"/\*.*?\*/", "", css, flags=re.S)
+ css = re.sub(r"@media[^{]*\{", "", css)
+ for sel, body in re.findall(r"([^{}]+)\{([^{}]*)\}", css):
+ yield sel.strip(), body
+
+
+def box_kind(box):
+ kinds = []
+ for k in box.kids:
+ if k.has("sp-box-head") or k.has("sp-box-foot"):
+ continue
+ for cls, name in (("sp-record", "record"), ("sp-health", "health"), ("sp-empty", "empty"),
+ ("sp-log", "log"), ("sp-help-topic", "help"), ("sp-box-body", "body")):
+ if k.has(cls):
+ kinds.append(name)
+ break
+ else:
+ kinds.append("table" if k.tag == "table" else k.tag)
+ out = []
+ for k in kinds:
+ if not out or out[-1].split("×")[0] != k:
+ out.append(k)
+ else:
+ n = int(out[-1].split("×")[1]) + 1 if "×" in out[-1] else 2
+ out[-1] = f"{k}×{n}"
+ return "+".join(out)
+
+
+def outline(node):
+ """The component skeleton of a page: what a reviewer sees with the text blurred."""
+ out = []
+ for k in node.kids:
+ if k.has("sp-head"):
+ parts = ["head"] + [p for p, c in (("postmark", "sp-postmark"), ("actions", "buttons")) if k.find(c)]
+ if not k.find("buttons") and any(n.tag in ("a", "form") and n.parent is k for n in k.walk()):
+ parts.append("actions")
+ out.append("+".join(parts))
+ elif k.has("box"):
+ variant = next((v for v in ("sp-danger-zone", "sp-credential") if k.has(v)), "")
+ head = k.find("sp-box-head")
+ title = text_of(head[0].find("sp-no")[0].parent.kids[1]) if head else "?"
+ out.append(f"box{'.' + variant if variant else ''}[{title}]:{box_kind(k)}")
+ elif k.has("columns"):
+ cols = []
+ for c in k.kids:
+ size = next((x for x in c.classes if re.fullmatch(r"is-\d+", x)), "auto")
+ cols.append({size: outline(c)})
+ out.append({"columns": cols})
+ elif k.tag == "form" or k.has("menu") or k.tag == "aside":
+ out.append({"form": outline(k)} if k.tag == "form" else "side_menu")
+ elif k.has("buttons"):
+ out.append("submit_row")
+ return out
+
+
+def main():
+ errors = []
+ css = (HERE / "theme.css").read_text(encoding="utf-8")
+ defined = set(re.findall(r"\.(sp-[a-z0-9-]+)", css))
+
+ for sel, body in css_rules(css):
+ if "!important" in body:
+ errors.append(f"theme.css: !important in `{sel}`")
+ if re.search(r"(^|[\s,>+~])#[a-z]", sel):
+ errors.append(f"theme.css: id selector `{sel}` — style components, not one element on one page")
+ if re.search(r"(?")[0]
+ for bad in ("style=", "