Files
selfpost/docs/assets/panel-redesign/panel/check.py
T
mixandClaude Fable 5.1 5e0adb2de5 docs: record the owner's decisions on routes, compatibility and inbound roles
panel-redesign.md gains four sections. Routes: paths follow the new names
and structure (/overview, /outbound/..., /inbound/..., /server/...,
/account), with one <page>/fragment name, the application form as a
single POST and TestRoutesFollowNavigation. No compatibility: the redesign
is 2.0.0, old paths are removed without redirects, the migration chain is
replaced by one baseline and 2.0 starts from an empty data directory (the
only live installation is the owner's); schema-squash ships as a step of
this plan without its upgrade gate. Who sees what: a domain administrator
manages assigned inbound domains, granted separately from outbound
(user_inbound_domains, per-domain authorization under /inbound/). Room for
roadmap items: where antispam lists, quarantine and preflight land, and
that a feature's screens are drawn mockup-first in this system.

Mockups: the Reload configuration box the first pass missed (now on
Server > Health), the flash component and the sp-help link that replaces
the help drawer, and the user form with two assignment lists. The
antispam plan no longer points its mockup at the superseded panel-ui
folder. Checklist 3/21.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 22:02:12 +03:00

229 lines
9.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 notification 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"(?<![-\w])(max-width|min-width|width)\s*:", body) and not any(sel.startswith(w) or w in sel for w in WIDTH_OK):
errors.append(f"theme.css: `{sel}` sets a width; widths come from the page layout (WIDTH_OK)")
used, documented, outlines = set(), set(), {}
for src in sorted((HERE / "src").glob("*.html")):
text = src.read_text(encoding="utf-8")
name = src.stem
bare = "shell: bare" in text.split("-->")[0]
for bad in ("style=", "<style", "<script", " onclick=", " id=\"page-"):
if bad in text:
errors.append(f"{name}: `{bad}` is forbidden in a screen")
root = parse(text)
for n in root.walk():
for c in n.classes:
if c.startswith("sp-"):
(documented if name == "components" else used).add(c)
if c in SHELL and not (bare and c in SIGNED_OUT):
errors.append(f"{name}: `{c}` belongs to the shell")
elif c not in defined:
errors.append(f"{name}: `{c}` is not defined in theme.css")
elif c == "ti" or c.startswith("ti-"):
continue
elif c not in BULMA:
errors.append(f"{name}: class `{c}` is neither in the Bulma subset nor an sp- component")
if bare:
continue
top = [k for k in root.kids if k.tag != "root"]
if not top or not top[0].has("sp-head"):
errors.append(f"{name}: the page must open with page_head (.sp-head)")
if len([n for n in root.walk() if n.tag == "h1"]) != 1:
errors.append(f"{name}: exactly one <h1>")
marks = root.find("sp-postmark")
if name != "components" and (len(marks) > 1 or any(not m.parent.has("sp-head") for m in marks)):
errors.append(f"{name}: at most one postmark, inside page_head")
for box in root.find("box"):
if not box.kids or not box.kids[0].has("sp-box-head"):
errors.append(f"{name}: a box must start with box_head")
for k in top[1:]:
# A flash is conditional, so it is allowed at the top level but never part of an outline.
ok = k.has("box") or k.has("columns") or k.has("notification") or (k.tag == "form" and k.has("sp-form"))
if not ok:
errors.append(f"{name}: top-level <{k.tag} class=\"{' '.join(k.classes)}\"> — a page is boxes, columns or one form")
if name != "components":
outlines[name] = outline(root)
for c in sorted(used - documented - SIGNED_OUT):
errors.append(f"components page does not show `{c}` — document it or stop using it")
shell_used = set(re.findall(r"sp-[a-z0-9-]+", (HERE / "build.py").read_text(encoding="utf-8")))
for c in sorted(defined - used - documented - shell_used):
errors.append(f"theme.css defines `{c}` but nothing uses it")
if "--write" in sys.argv and not errors:
(HERE / "outlines.json").write_text(json.dumps(outlines, ensure_ascii=False, indent=1) + "\n",
encoding="utf-8", newline="\n")
golden = HERE / "outlines.json"
if golden.exists() and "--write" not in sys.argv:
if json.loads(golden.read_text(encoding="utf-8")) != outlines:
errors.append("outlines.json is stale — a screen's structure changed; re-run with --write and say so in the commit")
for e in errors:
print("FAIL", e)
print(f"{len(outlines) + 1} screens, {len(used | documented)} sp- classes, {len(errors)} violation(s)")
return 1 if errors else 0
if __name__ == "__main__":
sys.exit(main())