Projects under organizations with fixed URL name and feature ID prefix, three visibility levels, open contribution, per-project roles, settings and typed-confirmation delete. Hidden projects 404. Leaving an org drops its project roles. Project access rules live in intent/access.py alongside org access. Features numbered per project (INT-12) in a tree. Every change is a revision with a required note; history, old revisions, and side-by-side field and line diffs between any two. Cycles and superseded-without-replacement are rejected. Markdown renders safely with source-line attributes for Phase 3 line comments and headings shifted below the page h1. Server-side preview works without JS. Structural accessibility check runs on every page in tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
67 lines
2.5 KiB
Python
67 lines
2.5 KiB
Python
"""Structural accessibility checks for rendered pages.
|
|
|
|
Not a substitute for axe or a screen reader, but cheap enough to run on every page in unit tests.
|
|
"""
|
|
|
|
from html.parser import HTMLParser
|
|
|
|
_UNLABELLED_INPUT_TYPES = {"hidden", "submit", "button", "reset", "image"}
|
|
|
|
|
|
class _Collector(HTMLParser):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.html_lang = None
|
|
self.headings = []
|
|
self.ids = []
|
|
self.label_fors = set()
|
|
self.controls = []
|
|
self.images_without_alt = 0
|
|
self._label_depth = 0
|
|
|
|
def handle_starttag(self, tag, attrs):
|
|
attrs = dict(attrs)
|
|
if tag == "html":
|
|
self.html_lang = attrs.get("lang")
|
|
if "id" in attrs:
|
|
self.ids.append(attrs["id"])
|
|
if tag in {"h1", "h2", "h3", "h4", "h5", "h6"}:
|
|
self.headings.append(int(tag[1]))
|
|
if tag == "label":
|
|
self._label_depth += 1
|
|
if attrs.get("for"):
|
|
self.label_fors.add(attrs["for"])
|
|
if tag == "img" and "alt" not in attrs:
|
|
self.images_without_alt += 1
|
|
if tag in {"input", "select", "textarea"}:
|
|
if tag == "input" and attrs.get("type", "text") in _UNLABELLED_INPUT_TYPES:
|
|
return
|
|
wrapped = self._label_depth > 0
|
|
named = bool(attrs.get("aria-label") or attrs.get("aria-labelledby"))
|
|
self.controls.append((attrs.get("id"), attrs.get("name"), wrapped or named))
|
|
|
|
def handle_endtag(self, tag):
|
|
if tag == "label":
|
|
self._label_depth -= 1
|
|
|
|
|
|
def assert_accessible_html(html: str) -> None:
|
|
page = _Collector()
|
|
page.feed(html)
|
|
problems = []
|
|
if not page.html_lang:
|
|
problems.append("<html> has no lang")
|
|
if page.headings.count(1) != 1:
|
|
problems.append(f"expected one <h1>, found {page.headings.count(1)}")
|
|
for before, after in zip(page.headings, page.headings[1:], strict=False):
|
|
if after > before + 1:
|
|
problems.append(f"heading level skips from h{before} to h{after}")
|
|
duplicates = {i for i in page.ids if page.ids.count(i) > 1}
|
|
if duplicates:
|
|
problems.append(f"duplicate ids: {sorted(duplicates)}")
|
|
for control_id, name, labelled in page.controls:
|
|
if not (labelled or control_id in page.label_fors):
|
|
problems.append(f"form control without a label: id={control_id} name={name}")
|
|
if page.images_without_alt:
|
|
problems.append(f"{page.images_without_alt} <img> without alt")
|
|
assert not problems, "\n".join(problems)
|