Files
preauth/.ci/css-control-size.py
lyra 2064153cd3
Tests / test (pull_request) Successful in 1m10s
chore: adopt shared Guiding Light configs and fix conformance gaps
Brings preauth from 18/34 to 30/34 conformance (auth-gateway profile). The
remaining four checks all depend on files this branch cannot change (see below).

PHP toolchain (§1)
  - require.php  >=8.4 -> ^8.5, and pin config.platform to 8.5.0. The old
    constraint also permitted PHP 9, which is not a promise we can keep.
    composer.lock regenerated with --lock: content-hash + platform-overrides
    only, zero dependency version movement.
  - friendsofphp/php-cs-fixer  * -> ^3.95. A wildcard meant CI was not
    reproducible.

PHPStan (§2.2)
  - vendor the shared phpstan.neon.dist (level 6) + a generated baseline.
    187 errors are captured rather than fixed; the baseline should only shrink
    from here.
  - add phpstan/phpstan:^2.1 to require-dev.

Code style (§8.2)
  - vendor the shared .php-cs-fixer.dist.php (@Symfony + @Symfony:risky +
    declare_strict_types) and apply it: 59 of 67 files reformatted.
  - Verified this is a formatting change, not a behaviour change: all 313 tests
    pass after the reformat, all in_array() calls already passed strict=true,
    and the remaining edits are @Symfony:risky idiom (yoda conditions, \count(),
    self:: over the class name).

Repository layout (§4.4)
  - docs/{Caddyfile,compose.yaml,example.env} -> docs/examples/, with
    example.env becoming the conventional .env.example. This is the layout
    GUIDING-LIGHT already cites preauth as doing correctly — it just needed
    renaming.
  - update the four readme.md references and a stale compose.yaml comment.
  - docs/v1.1-plan.md references are left alone deliberately: it is a historical
    plan recording what was done at the time, not live documentation.

Licence and security policy (§7)
  - add LICENSE (uniform MIT, matching composer.json).
  - add SECURITY.md describing the actual threat model: per-request
    allow/intercept, no caching of the login flow, app-set security headers,
    TOTP, and the fact that REMOTE_USER is trusted input.

Mobile accessibility (§3.3a)
  - templates/base.html.twig: drop maximum-scale=1 and add viewport-fit=cover.
    preauth was the one app already past the font-size precondition (controls
    render at 21.6px = 0.9em x 24px), so removing the lock is safe here and
    restores pinch-zoom for Android users.

Conformance tooling (§8.2)
  - vendor .ci/conformance.sh and .ci/css-control-size.py so the check runs
    from a checkout rather than fetching from the LAN-only private/ci.
  - .editorconfig synced from the version that keeps the Caddyfile tab rule.

Not included (blocked by the .gitea/workflows pre-receive hook):
  - ci-composer-audit, ci-composer-validate, ci-reusable-workflows.
    Workflow files may only change via a trusted ref, so the caller files are
    staged but not committed.

Also not included: dockerfile-nonroot (§6.4). Adding USER to an image with
VOLUME [/config, /data] changes volume ownership and needs an actual container
build/run to verify, so it goes in its own change.
2026-09-23 20:13:45 +00:00

229 lines
7.6 KiB
Python
Executable File

#!/usr/bin/env python3
"""
css-control-size.py — finds form controls whose rendered font-size is below 16px.
This is the precise version of the check. A naive `grep font-size` on the source
produces both false negatives and false positives:
FALSE NEGATIVE: vital-pulse declares `font-size: 0.95rem` on its inputs.
0.95rem x 16px = 15.2px — under the iOS auto-zoom threshold
— but it never appears as a `px` literal.
FALSE POSITIVE: task-weaver has many `font-size: 11px` rules for table
headers and labels. Those are fine. It's *form controls*
that matter, so only rules whose selector targets a control
should be judged.
FALSE POSITIVE: preauth declares `button, input { font-size: 0.9em }`, which
looks small as an `em` value — but its root is
`html { font-size: 1.5em }` = 24px, so the real rendered
size is 21.6px. `em` must be resolved against the parsed root.
So this walks CSS rules, keeps only control-targeting selectors, resolves
px / rem / em to a pixel value, and reports anything under the threshold.
Usage: css-control-size.py <threshold-px> <file> [file...]
Output: one line per violation: "<file>:<line-ish>\t<selector>\t<raw>\t<computed-px>"
Exit: 0 = clean, 1 = violations found
"""
from __future__ import annotations
import re
import sys
# Selectors that style an actual interactive form control.
CONTROL_RE = re.compile(
r"""(?ix)
(?:^|[\s,>+~\[.(#]) # boundary
(?:
input | select | textarea | button
| \.input\b | \.form-control\b | \.btn\b
| \[type= | \.tag-input\b | \.schedule-number\b
)
""",
)
# font-size: 14px | font-size: 0.95rem | font-size: 0.9em
FONT_SIZE_RE = re.compile(
r"font-size\s*:\s*(?P<val>[0-9]*\.?[0-9]+)\s*(?P<unit>px|rem|em|pt)\b",
re.IGNORECASE,
)
# font: 14px/1.5 ... (shorthand — sets font-size implicitly)
FONT_SHORTHAND_RE = re.compile(
r"font\s*:\s*(?:[^;{}]*?\s)?(?P<val>[0-9]*\.?[0-9]+)\s*(?P<unit>px|rem|em|pt)\b",
re.IGNORECASE,
)
# html { font-size: 1.5em } — establishes the em base.
ROOT_SELECTOR_RE = re.compile(r"(?i)^\s*(?:html|:root)\s*$")
DEFAULT_ROOT_PX = 16.0
PT_TO_PX = 4.0 / 3.0
def strip_comments(css: str) -> str:
return re.sub(r"/\*.*?\*/", " ", css, flags=re.DOTALL)
def parse_rules(css: str):
"""Yield (selector, body, index) for every rule, including nested ones."""
css = strip_comments(css)
stack: list[str] = []
acc = ""
for i, ch in enumerate(css):
if ch == "{":
stack.append(acc.strip())
acc = ""
elif ch == "}":
selector = stack.pop() if stack else ""
if selector:
# Body is what accumulated inside this rule; recover it from
# the source between the opening brace and here.
yield selector, "", i
acc = ""
else:
acc += ch
def parse_rules_with_bodies(css: str):
"""Yield (selector, body). Handles nesting (@media) by tracking depth."""
css = strip_comments(css)
stack: list[str] = []
out: list[tuple[str, str]] = []
acc = ""
body_start: list[int] = []
for i, ch in enumerate(css):
if ch == "{":
stack.append(acc.strip())
body_start.append(i + 1)
acc = ""
elif ch == "}":
if stack:
selector = stack.pop()
start = body_start.pop() if body_start else 0
out.append((selector, css[start:i]))
acc = ""
else:
acc += ch
return out
def resolve_px(value: float, unit: str, root_px: float) -> float:
unit = unit.lower()
if unit == "px":
return value
if unit == "rem":
return value * DEFAULT_ROOT_PX
if unit == "em":
# Resolved against the root that we parsed. This is an approximation
# (true `em` is parent-relative) but it is correct for the real case
# that matters: a page-level `html { font-size: N }` scaling controls.
return value * root_px
if unit == "pt":
return value * PT_TO_PX
return value
def find_root_px(rules) -> float:
"""Find an explicit html/:root font-size to use as the em base."""
for selector, body in rules:
sel = selector.split(",")[0].strip()
if ROOT_SELECTOR_RE.match(sel):
m = FONT_SIZE_RE.search(body)
if m:
val = float(m.group("val"))
unit = m.group("unit")
# Root em is relative to the 16px default.
return resolve_px(val, unit, DEFAULT_ROOT_PX)
return DEFAULT_ROOT_PX
def check_file(path: str, threshold: float) -> list[tuple[str, str, str, float]]:
violations: list[tuple[str, str, str, float]] = []
try:
with open(path, "r", encoding="utf-8", errors="replace") as fh:
css = fh.read()
except OSError:
return violations
# Only look at things that plausibly contain CSS.
if "{" not in css:
return violations
rules = parse_rules_with_bodies(css)
root_px = find_root_px(rules)
for selector, body in rules:
# Skip at-rule wrappers; their inner rules are yielded separately.
head = selector.strip()
if head.startswith("@"):
continue
# A rule may have several comma-separated selectors; judge each.
for one in head.split(","):
one = one.strip()
if not one:
continue
is_inherited_base = bool(re.match(r"(?i)^(?:html|body)$", one))
targets_control = bool(CONTROL_RE.search(one))
if not targets_control and not is_inherited_base:
continue
# Ignore rules that only set colours etc. — we want font-size.
m = FONT_SIZE_RE.search(body)
if m:
val = float(m.group("val"))
unit = m.group("unit")
raw = f"{m.group('val')}{unit}"
elif is_inherited_base:
# `body { font: 14px/1.5 }` sets the base every unstyled control
# inherits. Only the shorthand carries a size here.
m2 = FONT_SHORTHAND_RE.search(body)
if not m2:
continue
val = float(m2.group("val"))
unit = m2.group("unit")
raw = f"font-shorthand {m2.group('val')}{unit}"
else:
# A control-targeting rule with no size of its own inherits
# whatever body provides, which is reported separately.
continue
computed = resolve_px(val, unit, root_px)
if computed < threshold:
violations.append((path, one, raw, computed))
return violations
def main() -> int:
if len(sys.argv) < 3:
print(__doc__, file=sys.stderr)
return 2
try:
threshold = float(sys.argv[1])
except ValueError:
print(f"threshold must be a number, got {sys.argv[1]!r}", file=sys.stderr)
return 2
total = 0
for path in sys.argv[2:]:
for vpath, selector, raw, computed in check_file(path, threshold):
print(f"{vpath}\t{selector}\t{raw}\t{computed:.1f}px")
total += 1
if total:
print(
f"\n{total} control style(s) render below {threshold:.0f}px. "
"iOS Safari auto-zooms any focused control under 16px — this is the "
"trigger that `user-scalable=no` was masking (GUIDING-LIGHT §3.3a).",
file=sys.stderr,
)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())