Skip to content

TDD 0070: UX record library ([UI] grammar, index validate, isolated render, coverage) - #194

Merged
cahenesy merged 16 commits into
masterfrom
build/20261006-220142/0070-ux-record-lib
Oct 8, 2026
Merged

cahenesy merged 16 commits into
masterfrom
build/20261006-220142/0070-ux-record-lib

Conversation

@cahenesy

@cahenesy cahenesy commented Oct 7, 2026

Copy link
Copy Markdown
Owner

Implements TDD 0070 (rev 2): the UX record library behind the /ux-author phase (PRD FR-89, FR-90, FR-91, FR-94, FR-99, FR-101, NFR-4; ADR 0017). Plugin 3.51.0 → 3.52.0.

Bottom of a sequential stack: 0071 (/ux-author skill) and 0072 (/prd-author + /tdd-author integration) will be stacked on this branch.

What it adds

scripts/lib/ux.sh plus stdlib python3 helpers (ux_record.py, ux_render.py). python3 is needed only when the PRD contains [UI]; otherwise the wrappers short-circuit.

Parsing and record functions

  • tl_ux_ui_reqs: the strict **<ID> [UI] <title>** grammar. A malformed marker fails loudly (rc 2 with path:line), including one on a heading line. Inline code and fenced blocks are skipped.
  • tl_ux_delta: reports new / changed / orphaned requirements, detected by a per-requirement text hash.
  • tl_ux_validate: checks docs/ux/index.json schema v1, the required states, mock references (no network or file: schemes, absolute paths or docs/ux escapes), the tokens.css link, and symlinks. It rejects any unexpected image, so captures can't be committed. Missing screenshots are reported as screenshots k/total, never stored.
  • tl_ux_index_html: generates the flow page deterministically, with escaped values and a missing-screenshot list.
  • tl_ux_merged_index / tl_ux_coverage: read the merged index from the integration branch (best-effort fetch first) and report covered / stale / uncovered.

Rendering and capture

  • tl_ux_render <ids…> (rev 2 isolation; explicit screen ids only):

    1. Copies docs/ux to a private mkdtemp directory outside the repo.
    2. Writes <!DOCTYPE html> plus a CSP meta at byte 0 of every page.
    3. Serves the copy from a loopback-only ThreadingHTTPServer, which sends the CSP header on every response.
    4. Loads each page in Chrome with a dead proxy (only the server port bypasses it), --incognito, and a private profile and HOME.

    The browser, not a scanner, enforces the boundary. The run handles SIGTERM/SIGHUP/SIGINT and kills the browser process group, so nothing is left behind. rc 4 is a declared degrade.

  • tl_ux_capture: live-app reference screenshots, which refuse an in-repo output directory.

Gate history (run 20261006-220142)

The first review and three runtime-verifies FAILed on the same class: an out-of-repo local file baked into a committed PNG through the file:// render. A regex scanner kept being bypassed (script navigation, <!-->, unquoted / backtick / NBSP attributes, base64 CSS, url() in CSS files). That led to TDD rev 2 (#193), which makes the browser the boundary. The final runtime-verify found a SIGTERM cleanup gap, fixed in retry 4.

Gate Result
test-first PASS (each retry: test(failing) before fix:)
ci-checks PASS: 13 suites, 0 failed (ux-record 64/0, ux-render 91/0)
runtime-verify PASS: obs 1–13, 9b, 10, 10b with real Chrome; about 40 isolation vectors gave 0 canary pixels and 0 listener hits (positive control leaks); signal cleanup checked; downloads and profile stay contained
review PASS (security checklist): B1/B2 fixed, N1–N6 fixed, N7 accepted

Deviations from the TDD text (accepted in review; note at the next TDD revision)

  • Resolver rule: MAP * ~NOTFOUND, EXCLUDE 127.0.0.1. Chrome 154 otherwise maps the render's own 127.0.0.1 page.
  • Browser flags: --incognito plus private HOME/XDG/TMPDIR. These keep downloads and profile files contained and avoid a first-run hang.
  • Timeout: a Python-side 60 s timeout replaces timeout 60, with a process-group kill.
  • Eval registration: tests/implement-gate.test.sh registers the two new evals (declared in rev 2).
  • Size: ux_render.py is 439 lines against 420 estimated, and the render test 628 against 600. The total diff is 2143 against 2146.

Non-blocking residuals

  • A stale docstring mentions file:// (ux_record.py:439).
  • A hard link under docs/ux is copied by content.
  • A render/clear race needs a concurrent local writer to trigger.
  • A signal sent only to the ux.sh wrapper isn't forwarded, and there's a tiny pre-try signal window.
  • A found-but-unstartable browser returns rc 2 rather than rc 4.
  • git fetch is skipped when timeout is absent.
  • An unquoted meta-refresh file:// passes the scanner; the browser still blocks it.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NpGG21Y2VGGhTQaFr4oshB

cahenesy and others added 16 commits October 6, 2026 20:58
Evals for TDD 0070 obs 1-13 and 9b (ux-record.test.sh: grammar, delta,
validate, coverage, internal errors; ux-render.test.sh: render scoping and
degrade, capture, real render, index.html). Registered in the
implement-gate aggregator so ci-checks runs them. Red: scripts/lib/ux.sh
does not exist yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NpGG21Y2VGGhTQaFr4oshB
…age (TDD 0070)

scripts/lib/ux.sh: tl_ux_* wrappers (ui_reqs, delta, validate, render,
capture, index_html, merged_index, coverage) with the [UI] short-circuit,
the python3 check (rc 3) and integration-ref resolution via
_tl_integration_ref. ux_record.py: [UI] grammar with fence/inline-code
skipping, block hashing, delta, schema-v1 structural + PRD-consistency
validation, offline-URL / tokens-link / unreferenced-image / unlisted-mock
checks, merged-index coverage. ux_render.py: scoped headless-Chrome render
with whole-set status and rc-4 degrade, out-of-repo capture, deterministic
escaped index.html. Every parse failure exits non-zero with a named ux:
message; unexpected exceptions exit 2.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NpGG21Y2VGGhTQaFr4oshB
…ymlinks

Security review of the 0070 build found three gaps in render/validate:
- Mocks render from file://, so an <img>/<iframe> pointing at a file:// URL,
  an absolute path, or a ../ path escaping docs/ux/ would bake a local file
  into a committed PNG. validate now rejects those references, and render
  refuses an unsafe mock before the browser runs.
- --host-resolver-rules stops DNS names but not IP literals, localhost, or
  ws://. render now also routes all traffic to a dead proxy with loopback
  not bypassed, and validate rejects http(s)/ws(s)/ftp/file URLs anywhere
  in a mock, including script literals.
- A symlink under docs/ux/ (the dir itself, a screen dir, or a mock) let
  render delete or write outside the repo. validate reports every symlink,
  and render and index-html refuse to run while one exists.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NpGG21Y2VGGhTQaFr4oshB
…ll ref scan, heading [UI]

- B2: a [UI] on a markdown heading must fail loudly (rc 2, malformed marker).
- B1(b): meta refresh targets, every srcset candidate, image-set() strings,
  entity-encoded paths, legacy background=, data:text/html frames are refused.
- B1(a)/(c): the render points the browser at a private copy whose pages carry
  a script-src 'none' CSP; a real-Chrome eval renders a JS-navigating mock and
  asserts the outside canary never reaches the PNG (control proves detection).
- N1: inline SVG xmlns and plain-text URLs are valid.
- N3/N5/N6: proper file URI, no 'rendered' line for a deleted PNG, a mock
  missing at render time is rc 2 with nothing cleared; no bytecode written.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NpGG21Y2VGGhTQaFr4oshB
… heading [UI] fails loudly

B1: tl_ux_render now copies docs/ux to docs/.ux-render.XXXX/ and renders that
copy. Every HTML page in it gets a leading CSP meta (script-src 'none', plus
no frames, plugins, workers, fetch, <base> or form targets), so a mock cannot
build a path or navigate to a local file. --blink-settings=scriptEnabled=false
was tried first: with google-chrome 154 it stops --screenshot from writing the
PNG (a JS-blocking profile hangs it), so a CSP meta is used instead. The
symlink and mock checks run on the copy, which also closes the N4 TOCTOU.
Capture is unchanged (scripts on).
The scan covers every reference value: src/href/srcset (every candidate)/
action/poster/data/background/…, <meta http-equiv=refresh> targets, and CSS
url()/@import/image-set() strings. Values are entity/CSS-unescaped and
percent-decoded, and a backslash is read as '/'.
B2: a [UI] on a markdown heading is rc 2 "malformed [UI] marker".
N1: the scheme check applies only to reference values. xmlns and plain text
are no longer flagged.
N3: pathlib as_uri. N5: "rendered" lines print only after the call succeeds.
N6: a missing mock is rc 2, checked before anything is cleared.
Python runs with -I -B, so no __pycache__ is written into the plugin tree.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NpGG21Y2VGGhTQaFr4oshB
…-->, <!--->, BOM, CRLF)

Real-Chrome canary checks for six mock prefixes plus a stub-level check
that every rendered copy starts with exactly our doctype + CSP meta.
Against 508bdc3 the <!--> and <!---> cases leak the canary into the PNG.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NpGG21Y2VGGhTQaFr4oshB
Every HTML file in the private render copy is now written as
"<!DOCTYPE html>" + CSP_META + the original bytes (one leading UTF-8 BOM
dropped). _DOCTYPE_RE is gone: it did not know HTML's abruptly-closed
comments (<!--> / <!--->), so a script before the mock's doctype ran
ahead of the CSP and could pull a local file into the committed PNG.
Our doctype comes first, so pages still render in standards mode.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NpGG21Y2VGGhTQaFr4oshB
… matrix

obs 7: the stub fetches its http://127.0.0.1 URL while render's server is
live and logs body + headers (doctype + exact CSP meta, CSP header on pages
and 404s, text/html; charset=utf-8), no file:// in argv, and the render copy
lives in $TMPDIR and is gone afterwards. obs 9b: capture argv has no proxy
flags. obs 10: a tokens.css-styled mock and an inline-SVG mock render
non-blank. obs 10b: real-Chrome isolation probe matrix with a magenta
out-of-repo canary — CSS-file / base64 data:text/css / loopback-port /
%2e%2e CSS vectors must pass validate, render rc 0, 0 canary px; script,
unquoted-=/backtick/NBSP img, meta refresh, base file:, loopback img and
%2e%2e img vectors must not leak; a direct file:// control shows the canary.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NpGG21Y2VGGhTQaFr4oshB
…enforced CSP

render no longer opens repo files over file://. It checks docs/ux for
symlinks, copies it with copytree(symlinks=True) into a mkdtemp() dir outside
the repo, re-checks the copy, prefixes every .html/.htm/.xhtml with
<!DOCTYPE html> + CSP_META (one UTF-8 BOM stripped), and serves it from a
127.0.0.1:0 ThreadingHTTPServer (GET only, no listings, silent, CSP header on
every response incl. errors, HTML as text/html; charset=utf-8). Chrome shoots
http://127.0.0.1:<port>/screens/<sid>/<state>.html behind the resolver rule
and a dead proxy bypassed only for the server's port. The policy is rev 2's
default-src 'self' data: one, so CSS files, base64 data: CSS, unquoted /
backtick / NBSP img values, %2e%2e and other loopback ports can no longer bake
an out-of-repo file into a PNG. The server and the copy are torn down in
finally. The old docs/.ux-render.* path is gone; capture keeps no proxy flags.

Deviation: the resolver rule is "MAP * ~NOTFOUND, EXCLUDE 127.0.0.1" —
Chrome 154 maps IP literals too, so the TDD's bare rule makes the page itself
fail ERR_NAME_NOT_RESOLVED (rc 0, no PNG). Other hosts stay unresolvable and
other loopback ports still hit the dead proxy (probe css-loopback-port).

Also: obs 7 compares the Content-Type header case-insensitively (http.server
sends "Content-type"), and ux-record's data:/#fragment fixture escapes '#'
in its sed (it silently never applied).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NpGG21Y2VGGhTQaFr4oshB
…or browser; private browser profile/HOME

- [C] stub browser that blocks: SIGTERM / SIGHUP to `ux_render.py render`
  mid-shot must give rc 143 / 129, leave no ux-render.* in $TMPDIR, and
  leave no stub browser (or its child) running.
- [7] / [C] / [9b]: every shot's argv carries one --user-data-dir= inside
  the private mkdtemp tree (ux-render.* for render, ux-capture.* for
  capture), and the browser's HOME and TMPDIR are inside it too; all gone
  after the call.
- [C] real Chrome: a mock that meta-refreshes to a downloadable file writes
  nothing under a fake $HOME (no ~/Downloads), leaves nothing in $TMPDIR and
  no Chrome running after the render is stopped.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NpGG21Y2VGGhTQaFr4oshB
…rowser profile, HOME, TMPDIR

- SIGTERM, SIGHUP and SIGINT (unless inherited as ignored, e.g. nohup) raise
  SystemExit(128+signum), so every `finally` runs: the browser group is
  killed, the loopback server shut down, the private mkdtemp tree removed.
  Further signals are ignored while cleanup runs. A render signalled mid-call
  also deletes the PNGs it wrote, as on any other failure (N5).
- The browser runs in its own session via Popen; on timeout (rc 124) or any
  exception its whole process group gets TERM then KILL, so Chrome and its
  helpers are never orphaned. The `timeout` wrapper is no longer used.
- Every shot gets --incognito and --user-data-dir=<tmp>/b/p, with HOME,
  XDG_* and TMPDIR under <tmp>/b: downloads land in the private tree, not
  ~/Downloads, and no profile files reach ~/.config, ~/.cache or $TMPDIR.
  Render uses one profile per call inside its ux-render.* tree; capture
  uses its own ux-capture.* mkdtemp, removed afterwards. (--incognito: a
  fresh --user-data-dir otherwise stalls headless Chrome on first run.)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NpGG21Y2VGGhTQaFr4oshB
@cahenesy
cahenesy merged commit 76dc6b3 into master Oct 8, 2026
1 check passed
@cahenesy
cahenesy deleted the build/20261006-220142/0070-ux-record-lib branch October 8, 2026 00:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant