Accessibility of every surface this tool emits
Audited 2026-09-02, whole, for the first time. Individual fixes had landed
before this — a screen-reader user had no heading route to the failures in the
longest section of a report and got one; explain was stopped from forging
headings; the index label convention avoids task_id N because the
underscore is announced. Nobody had gone through every surface once.
This page is the record: what each surface does, what was broken, what was fixed, what was handed back, and the places where accessibility and precision pull against each other and were not silently resolved.
The requirement is not decorative. The founder reads everything this tool emits through a screen reader, and it is also the product's own argument: a forensic report is read aloud, quoted in a letter, and pasted into a filing, so text that only works visually is broken output rather than ugly output.
The properties are pinned by engine/tests/test_accessibility.py (the text
surfaces) and engine/tests/test_report_accessibility.py (the HTML report).
Every check in both names, in its docstring, the mutation that makes it fail.
The surfaces
| Surface | Where it lives | Verdict |
|---|---|---|
| Markdown report | forensic/disclosure.py Report.render |
Sound. Two defects found and fixed; one handed back |
| HTML report | forensic/html.py |
Sound. Nothing new found |
| JSON record | Report.to_json, every to_dict |
Sound, and gained a word for a state that had only a sign |
explain trace |
cpmcore/explain.py, cli._explain |
The best surface here. Nothing found |
index listing |
cli._index |
Sound |
check, validate, record, analyse, concurrency, tia |
cli.py |
All render through Report.render; audited as one |
--help, top level and per subcommand |
cli.build_parser |
Sound. One tension, below |
| Errors and refusals | cli.py, LoadError, InputError |
Six of eight read as sentences; two are broken and handed back |
forensic.evidence verify |
forensic/evidence.py |
One warning dumps a Python repr; a stated tension, not fixed |
demo/ |
demo/ |
Renderings of the above. Out of this agent's scope; inherits both the fixes and the current staleness |
What each surface does well
Recorded because a later change can lose it, and because most of it was deliberate rather than lucky.
Headings. All five report-emitting commands open at # and never skip a
level. Verified on the rendered output of check, index, validate, record
and explain, not on the renderer's source. A bold line is never used where a
heading is meant.
Figures carry their populations, adjacent. The three shapes below are examples of form, and their digits are historical and must not be quoted as this engine's figures -- 67.80% in particular is a superseded agreement headline, kept here because the sentence is about layout. engine/quality/FIGURE-BINDING.md names the owner of every live figure and the owner wins. 88% of the 32 clauses that were
scored, and those 32 are 27% of the 120 clauses that apply. 67.80% of 584,687
measured field comparisons. score 89%, over 9 clauses decided of 107
applicable (coverage 8%).
Every percentage in the five rendered surfaces was read. One place does not
restate its own population, and it is defensible rather than a defect: the P6
agreement limitation names 396,440 of 584,687 at the head of the paragraph and
then traces a four-step movement — 61.89% to 67.50% to 67.54% to 67.71%
to 67.80% — whose terms are all over that same corpus. Restating the
denominator five times would make the sentence about the movement harder to
follow, and the movement is what that sentence is about. ~~Recorded here because
a listener arriving mid-paragraph by list navigation does not get the head of
it. Not changed.~~ Superseded 2026-09-03: the chain is now its own bullet,
so a listener does arrive at it by list navigation and the concern is no
longer hypothetical. The denominator is still not restated inside it, and that
judgement stands unchanged — the entry opens This figure rose from 61.89% in
four steps, which names what the terms are of. See §"The check that could not
see it".
Verdicts are words. passed, failed, NOT_EVALUATED, Referred,
[from your file], [from our arithmetic]. Nothing is a tick, a badge or a
colour. ~~The HTML stylesheet declares no colours at all, so there is nothing a
colour rule could be the sole carrier of.~~ Superseded 2026-09-03: the
stylesheet now declares a palette and a dark mode, and the guarantee is
structural instead of an absence — the renderer emits no class and no style
attribute, so a failing clause and a passing one are the same markup and there
is no selector by which a colour could differ. Argued in full in §"The report
as a document" below, including why this is a genuine trade rather than a free
win.
No tables. Not one surface emits a table — the report is headings, prose and
nested lists throughout, including the index listing, where a table would have
been the obvious choice and each row is instead a sentence that stands alone.
html.py implements no table construct, and test_report_accessibility.py
fails if tabular material reaches the reader as a paragraph of pipes.
Runs of speech do not grow with the schedule. test_report_accessibility.py
asserts that the longest evidence line does not lengthen with the activity
count — the property that stops a 20,000-activity file emitting a single
6,414-character list item naming 756 identifiers.
explain is the model the rest should follow. Numbered steps, one fact per
line, and every link stated in words: "A finishes 2026-01-09. The link A to B
is FS with no lag. That permits B to start no earlier than 2026-01-10."
Refusals say what to do next. p6-23.12-xval.oracle.xer holds no project
named 'NOPE'. It holds 13: … Name one of those with --project. 'TIA' could
mean MIP 3.6, 3.7. Was the delay modelled against one base schedule (3.6) or
against a separate contemporaneous update per event (3.7)?
The HTML report. lang="en"; one <main> holding everything; real
h1–h4 with stable, derived ids; a <nav aria-label="Contents"> built from
the headings actually emitted; the provenance block as a <dl> so "Project" is
announced as a label; the governing documents as an <ol> because their order
is the precedence; no fixed pixel font sizes and no locked viewport; no
floats, no positioning, no flex or grid reordering, so source order is reading
order; no scripts, no external assets, and it reads correctly with CSS off
because it is semantic HTML with a stylesheet, not styled divs.
What was broken, and fixed
1. The direction of a relation was carried by -> alone
relationship 9203 -> 9204 references an activity outside this project.
XV01: A -> B -> C. Circular logic: A -> B -> C -> A.
-> is two ASCII characters. A screen reader announces them as "dash greater"
at one punctuation setting and as nothing at another — at which point the
sentence becomes "relationship 9203 9204", and the loop becomes an unordered
list of three activity names. The direction is the whole of what the glyph
carries, and in a forensic finding the direction is the finding.
explain had already been written the other way — "The link A to B" — so the
fix is the existing house form applied to the surfaces nobody had read aloud.
Changed to to for a relation and then for a chain, at the thirteen sites
that reach a rendered report:
cpmcore/xer.py(4),cpmcore/p6xml.py,cpmcore/mspdi.py(2),cpmcore/sdef.py,cpmcore/portfolio.py— the importer's notes, which every report reproduces under Schedules consideredcpmcore/graph.py—ScheduleCycleError's messageforensic/rules/aace29.py(2) — the §2.1.B(4) longest-path trail and the §4.3.C controlling chainsforensic/rules/ufgs.py— the §3.3.11 cycle evidence
Pinned by test_no_report_carries_a_relation_or_a_count_in_a_symbol, over all
five Markdown surfaces. tests/test_graph.py pinned the old string and was
updated with a comment saying why.
This makes the committed demo/ renderings stale, because every report
reproduces the importer's notes. demo/ was owned by another agent during this
audit and was not regenerated here. test_demo was already failing on six
staleness cases for unrelated reasons; the arrow change adds to that set and the
fix is the same one — python demo/run.py, once the other edits land.
2. A negative concurrency figure was the sole marker of a failed premise
forensic.multibase.WindowImpact.concurrency_days is the sum of the individual
impacts minus the combined one. On a mixed-calendar network it comes out
negative — which is not an overlap running backwards but the premise of the
subtraction failing.
Until this pass the minus sign was the only thing that said so.
MultiBaseResult.total_concurrency_days summed the windows and published the
result, and nothing in the Markdown or the HTML mentioned it at all: a
report over a failed premise and a report over four real days of concurrency
read identically to a listener, and to a sighted reader who did not notice one
character. Worse, a positive total can hide a failed window inside it — two
windows at +6 and −4 publish 2, which reads as two days of concurrency
and is not a concurrency.
cpmcore.modelled.ModelledResult and
forensic.subtractive_multibase.PeriodCollapse — the other two callers of the
same subtraction — already name the state in a word and already carry
concurrency_prose, which was written for exactly this reason and says so:
"because a report is read by screen reader as often as by eye, and a
distinction carried by one character in a table is not a distinction."
multibase was the third caller and had neither.
Added, mirroring subtractive_multibase exactly:
WindowImpact.concurrency_reading→CONCURRENT/NONE/PREMISE_FAILEDWindowImpact.concurrency_note→ the premise failure in prose,""otherwiseMultiBaseResult.concurrency_reading,PREMISE_FAILEDas soon as any window's is, because a total is no better than its worst termMultiBaseResult.limitations, one entry per failing window — whichcli._analysis_bodyalready prints, so the sentence reaches the Markdown and the HTML at onceconcurrency_readingin bothto_dicts, so--jsoncarries the word too
Pinned by test_a_negative_residual_is_named_in_words_and_not_only_by_its_sign
and test_a_report_over_a_failed_window_carries_the_reason_the_reader_hears,
which includes the +6, −4 → +2 case.
What was found and not fixed, and why
engine/src/forensic/cli.py was being edited by another agent for the whole of
this audit — it went dirty between the first git status of this session and
the first edit, and stayed dirty. Both remaining defects live in it. They are
one line each; the diffs are below, and both are pinned by
xfail(strict=True) tests, so whoever applies a fix is told to delete the
marker.
3. A repetition count carried by ×
cli._import_notes, around line 1149:
count = f" ×{len(entries)}" if len(entries) > 1 else ""
renders `XER.TASKPRED.DANGLING` ×11 (warning): …, which announces as
"code times eleven" — a count wearing an arithmetic operator, and at some
punctuation settings a count with no operator at all. Suggested:
count = f" ({len(entries)} occurrences)" if len(entries) > 1 else ""
Pinned by test_no_report_carries_a_repetition_count_in_a_multiplication_sign,
xfail-strict over check, record and validate.
4. A Windows path reaching the reader with every separator doubled
Two sites interpolate an OSError directly:
# cli.py ~313, in _load
raise LoadError(f"{path.name}: {exc}") from exc
# cli.py ~1359, in _read_json
raise InputError(f"could not read {what} from {path}: {exc}") from exc
str(OSError) puts the filename through repr, so the message is
missing.xer: [Errno 2] No such file or directory: 'C:\\Users\\charl\\...\\missing.xer'
— announced backslash by backslash, twice per separator, before the file name. This is the defect the forgery fixes elsewhere in this engine avoid by flattening rather than escaping. It is also the only refusal in the tool that does not say what to do next.
The path is already named by both call sites, so the part of the exception worth carrying is its reason:
raise LoadError(f"{path.name}: {exc.strerror or exc}") from exc
raise InputError(f"could not read {what} from {path}: {exc.strerror or exc}") from exc
Pinned by test_a_refusal_reads_as_a_sentence_and_does_not_escape_a_path,
xfail-strict on the missing-file and missing-delays cases; the other six
refusals pass.
5. -> as "before became after", outside the report surfaces
Twenty-odd further sites write X -> Y meaning was X, now Y — the worse
class, because read aloud duration 5 -> 7 working days becomes "duration 5 7
working days" and the two numbers are indistinguishable. They are in
cpmcore/compare.py, cpmcore/modelled.py, forensic/dynamic_observational.py
and the update-comparison rules in forensic/rules/aace29.py.
Not fixed here for two reasons and both are stated rather than argued away:
modelled.py and several of the test files that pin those strings were owned by
other agents tonight, and none of these sites reaches a report unless two or
more schedule files are supplied, which the audit fixture does not do. They
are a real defect and they are still there. The fix is the same one word:
{was} -> {now} becomes {was}, now {now}.
6. forensic.evidence verify dumps a Python repr into a line read aloud
The report tells a third party to check the record without trusting us:
python -m forensic.evidence verify <record> <schedules>. That command is a
surface, and it prints:
WARNING REPRO.MANIFEST.UNCHECKED the record's manifest states
calendars={'8001': {'clndr_type': 'CA_Base', 'hours_per_day': 8.0, 'id': '8001',
'name': 'XVAL-MONFRI', 'pattern': {'extra_work_days': [], 'holidays': [],
'working_weekda... (829 chars), options={'data_date_convention': 'through', ...
(1105 chars), and this verifier does not check any of it
Read aloud, that is several hundred announcements of quote, brace, colon
and underscore before the sentence that matters. It is barely usable by eye
either.
Not fixed, because the values are the point. _abbreviated was written the
way it is deliberately: the warning exists because a record forging
jurisdiction: "texas" or options: {"retained_logic": false} reproduced
cleanly, and the fix was to name what was claimed and not checked. Summarising
options as "nine keys" or calendars as "three calendars" would delete the
half of the warning that does the work. Naming the field and its value is
right for method, where the values are short; it produces a wall for
options and calendars, where they are not.
A defensible split — values in full for method, and for the two long fields
the keys plus a pointer to the record — would need the judgment of whoever wrote
that warning, and changing it on accessibility grounds alone risks weakening a
control this repository built after measuring a forgery pass. Recorded as a
tension, deliberately unresolved.
Tensions between accessibility and precision
Written down rather than resolved. This product's output is evidence, and precision outranks fluency; each of these is a place where making the text read better would make it say less.
Relationship type codes. The link A to B is FS with no lag announces as
"F S". finish-to-start reads better. It is not obviously better output: FS
is the string P6 shows, the string the file stores, and the string a reviewer
matches on when checking our finding against their own schedule. Expanding it
puts our vocabulary between the reader and the file. Unresolved. Left as
FS.
The section sign. §3.3.11 is announced as "section" by most readers and
skipped by some, which would leave "3.3.11". Spelling it "section 3.3.11" is
more listenable — and every finding in this tool is written to be pasted into a
letter, where §3.3.11 is the form the specification itself uses and the form
the recipient will look up. Unresolved. Left as §.
Digests. sha256:1b98626707918c46898550e5ae276f87763d859dfa7fd666fb4ffb722fe3b50f
is 64 characters of hexadecimal read aloud one letter at a time, and there is no
version of that which is pleasant. Truncating it would falsify the evidence: the
whole point of the input digest is that a third party can recompute it. It is at
least announced — every occurrence is preceded by a label (Input digest,
file digest, model digest, answer digest) and the algorithm prefix, so a
listener knows what is coming and can skip the line. Unresolved, and the
current form is judged correct.
--help length. check --help is about sixty lines because every option's
help is a full sentence explaining what is not assumed. That is the right
trade for a reader who can skim and an expensive one for a reader who cannot.
No fix attempted: shortening it would delete the disclosure the tool exists to
make. Worth revisiting as a --help / --help-full split, which is a product
decision and not an accessibility one.
-- used as an em dash in prose. Several rule messages punctuate with --
where the report elsewhere uses —. Both are announced as nothing, so this is
consistency rather than accessibility, and it is not worth a sweep through
twenty files.
Could a blind reviewer use this product end to end today?
Yes, with two exceptions, both of them in error messages rather than in reports.
The evidence is five surfaces rendered and read end to end — check, index,
validate, record and explain, in Markdown, HTML and JSON — plus the
section bodies of analyse, concurrency and tia read in source, since all
eight render through the same Report.render. On that evidence: every report is
navigable by heading, states every figure with its population,
carries no meaning in colour, position, indentation or a glyph, emits no tables,
and does not grow an unbroken run of speech with the size of the schedule. The
HTML report is a self-contained semantic document that works with CSS off. The
explain trace is genuinely good: a listener gets the same argument, in the
same order, as a reader.
The exceptions are the two refusals in §3 and §4 above, and the verifier's
repr wall in §6, which is a usability defect for everyone and worse for a
listener. A reviewer who mistypes
a filename gets a message with a doubled-backslash path and no next step, and
every report reproducing a repeated importer note gets ×11. Neither withholds
information; both cost the listener something a sighted reader does not pay.
Both are one-line fixes waiting on a file being edited elsewhere.
The report as a document
Added 2026-09-03, a separate pass with a different question. The audit above asks whether the report can be used. This asks whether it is a document a scheduling expert would be glad to receive and a partner would attach to a claim. Nothing above is undone; three of its properties are strengthened and one is deliberately weakened, and that one is argued at length in §4 because it is the only thing this pass gave up.
The premise is that the HTML report is the only thing a customer sees. It is printed, emailed, quoted in letters and put in front of an opposing expert. The stylesheet grew from 24 lines to about 130. There is still no JavaScript, no network fetch, no external font and no second file.
Not one word of what the report says changed. Report.render() remains the
single place that decides what a report contains.
test_html.py::test_the_markdown_and_the_html_carry_the_same_heading_structure
still fails if a section exists in one renderer and not the other; it was
changed to excise the contents <nav> before comparing, which makes it a
stricter statement of that property rather than a looser one.
1. Typography and measure
| Before | After | Why |
|---|---|---|
max-width: 46em |
40em |
46em of Georgia is ~95 characters. The return sweep costs accuracy past about 75, and this document is read straight through rather than scanned. |
| no type scale | h1 1.9em, h2 1.4em, h3 1.12em, h4–h6 1em |
Four levels were rendering at browser defaults, where h4 is smaller than body text. The verdict classes — Blocking failures, Referred — are h4. |
| serif throughout | serif for prose, sans for headings, dt and the contents |
A reader skimming for a heading is doing a different job from a reader reading a finding. |
dl runs into the text |
dl closed by a 2px rule |
The provenance block is a masthead. Which schedule this is, is the first thing a reviewer checks. |
dd unbroken |
overflow-wrap: anywhere |
The input digest is sha256: plus 64 hex characters with no break opportunity in it. It ran off the right edge on paper and on a phone. |
code unmarked |
tinted, padded, overflow-wrap: anywhere |
The rule id opening a finding is the scan target. |
Every size is in em. test_the_reader_keeps_control_of_text_size fails on any
px or pt in a font-size and still passes.
2. Print, measured rather than asserted
Rendered to PDF with msedge --headless=new --print-to-pdf over the real
conformance report on oracle/cases/p6-23.12-xval.oracle.xer. 14 pages with
the browser's own header and footer and 14 without, so the furniture does not
reflow the content. The stylesheet was then stripped one rule at a time and
re-rendered, because a print rule that does not move the artefact is
decoration and this is the only way to find out which ones do:
| variant | pages | bytes |
|---|---|---|
| as shipped | 14 | 263,097 |
whole @media print block removed |
15 | 293,766 |
@page margin removed |
13 | 261,463 |
nav { break-after: page } removed |
14 | 263,097 |
h2 { break-before: page } added |
20 | 267,315 |
The first three are the rules working. The fourth is the one worth writing down, because a byte-identical PDF is what an inert rule looks like: the nav page break is live but currently redundant on this report — the contents card happens to fill page one, so the forced break falls exactly where the page would have broken anyway. The fifth arm proves forced breaks reach this engine at all, and a sixth settles it directly: with the contents shortened to three entries, keeping the break gives 251,897 bytes and removing it 251,306, so the rule does move the layout as soon as it has anything to move. Recorded rather than asserted, because on this fixture the check "does removing it change the PDF" answers no for a rule that is nonetheless correct.
@page { margin: 20mm 18mm }. The browser default is about 10mm, inside the punch margin of a filing.orphans: 3; widows: 3onp,lianddd.break-after: avoidon every heading level, so a heading cannot print alone at the foot of a page, away from the finding it introduces.break-before: avoidon a nested list, so a finding's evidence prints on the page with the finding.- The dark palette is overridden back to black on white. A reader who has chosen a dark screen has not chosen a black page, and without the override the PDF attached to a filing inherits the screen. This is the print rule that is invisible until it is wrong and expensive when it is.
codeloses its tint andnavits card, so neither costs toner.
li, p { break-inside: avoid } was in the print block and is removed. One
limitation in this report ran to 1,878 characters, taller than a printed page,
and break-inside: avoid on a block taller than its fragmentainer is either
ignored or honoured by ejecting a near-empty page ahead of it, depending on the
engine. Orphan and widow control is the rule that applies to prose of that
length.
Updated 2026-09-03. That limitation is now nested bullets of at most 771
characters, so the block this rule was removed for no longer exists. The
removal stands: identifier_limitation is still 900 characters, and the next
long limitation somebody writes would put it back.
The page footer that cannot be built
No browser implements generated content in @page. @bottom-center { content: … }
is a Paged Media feature that Prince and Paged.js implement and Chrome, Edge,
Firefox and Safari do not. A footer written in the stylesheet would not appear,
and nothing would report an error.
What browsers do print in their own header and footer is the document
<title> and the page number. So the identity went where the browser will
actually print it: <title> is now Schedule conformance review — XV01,
composed by html._document_name from the Project field of the provenance
block. The <h1> is untouched — this is the document's name, not a line of
the report.
The cost is stated rather than hidden. That footer is off by default in
Edge's print dialog, and a user who leaves it off gets pages carrying no
identity at all. The alternative — a repeating fixed-position element, which is
what a JavaScript paginator would give — is refused twice over: position:
fixed fails test_the_source_order_is_the_reading_order, and it is exactly the
visual-only furniture this report does not carry.
The contents prints, which the brief asked it not to
Stated as a deviation. This <nav> is not screen furniture; it is a static
contents list at the top of the document, which is what a document attached to a
claim is expected to carry. It prints as page one, followed by
break-after: page. It also gained a visible <h2>Contents</h2>, because
aria-label renders as nothing and on paper an unlabelled indented list of
section names has to be inferred. nav { display: none } in the print block
reverses this in one line if a reviewer disagrees.
3. Citing a finding
Ninety-two findings in the conformance report now carry an id derived from the rule id and from nothing else:
<li id="finding-ufgs-034"><code>UFGS-034</code> §3.3.8.1 — the schedule contains 0 …
Position is the trap, and it is a quiet one. #finding-4 still resolves
after one more clause fails and the report is re-run. The letter then cites a
different finding and nothing anywhere reports an error — the same shape as the
scd_end_date defect CLAUDE.md records: a right answer for no reason, which
is worse than a wrong one.
Three checks hold it, in test_report_accessibility.py:
test_every_finding_can_be_cited_by_its_clause— every finding has an id, the ids are unique, and each isfinding-plus its own rule id slugged.test_a_citation_survives_the_set_of_findings_changing— rendered under two jurisdictions, which decide two different sets of clauses over one file; every rule decided under both must be addressed identically under both. A positional scheme passes the first check and fails this one.test_two_runs_on_one_input_produce_byte_identical_html— two fresh interpreters atPYTHONHASHSEED0 and 12345, each asserting whichforensic.htmlit loaded before rendering, must agree on the SHA-256 of the whole document.
4. Colour, and the property that was given up
Before today the stylesheet declared no colours at all, and the predicate asserted exactly that. It was the strongest available form of "colour never carries meaning" for as long as it held. It stopped holding the moment the report gained a palette and a dark mode, and an absence cannot be strengthened; it can only be kept or lost. So the replacement is a property:
render_htmlemits noclassattribute and nostyleattribute — not one, anywhere in the document. A failing clause and a passing one are therefore the same markup, and there is no selector by which a colour could differ between them.
That is structural rather than a promise. A stylesheet that wanted to paint
failures red would first have to invent a hook that does not exist. Both halves
are checked: colour_problems fails if the markup grows a class or style
attribute, and fails if any CSS rule sets a colour through a class, id or
attribute selector. Pseudo-classes (:hover, :focus-visible) are excluded
deliberately — they describe the reader's pointer, not the document's state.
Because colour is now load-bearing for legibility though it carries no meaning, a third arm was added: every foreground the document puts on a ground clears WCAG contrast on it, in each of the three themes. The ratios are computed by the test, not asserted from this table:
| ink on paper | muted on paper | ink on tint | muted on tint | |
|---|---|---|---|---|
| light | 16.90 | 7.75 | 14.97 | 6.86 |
| dark | 14.13 | 6.95 | 12.64 | 6.22 |
| 21.00 | 12.63 | 18.76 | 11.29 |
Floors are 7.0 (AAA) for --ink and 4.5 (AA) for --muted. --rule, the
hairline under a heading, is deliberately not held to a floor: it separates a
heading from what follows and carries nothing, and a floor invented for it would
be a rule written to be passed — the anti-pattern this repository has found four
times.
Is this weaker than what it replaced? On its face yes: a ban on colour
cannot be argued around. In substance it is stronger against the failure that
matters, which was never "a colour exists" but "a colour is the only thing
saying this failed". The old check could not have caught a report that grew
class="fail" and was coloured elsewhere — a user stylesheet, an email
client, a copy someone edited. The new one catches the hook. It is also the only
version that survives the report having a dark mode at all, and the alternative
was to refuse dark mode, which is a legibility feature for the low-vision reader
this audit was written for. Recorded as a genuine trade.
5. Scanability
"What failed" is answerable in about ten seconds by four carriers, none of them
colour: the contents card listing Blocking failures (4) with its count in
the heading text; real heading levels, now visually distinct, so h4 verdict
classes stop rendering smaller than body text; each finding opening with its
rule id in <code>, so the left edge of the list is a column of identifiers;
and the words — failed, Referred, NOT_EVALUATED, [from your file].
6. Dark mode
prefers-color-scheme: dark redefines five tokens and nothing else. It costs
the print work nothing because the print block redefines them again, and it
costs the accessibility work nothing because it is the same markup — see §4 for
why that is a structural claim and not a reassurance. color-scheme: light dark
was already declared; the palette makes the document itself follow.
What was measured
- The real conformance report rendered to PDF: 14 pages, identical count with and without the browser's print furniture; six stylesheet variants re-rendered to establish which print rules actually move the artefact (table in §2).
- 17 mutations, 17 caught, against the source as committed. Run against a copy of
engine/srcin a scratch tree, never against this tree, with aconftest.pythat removes everyconstructpath fromsys.pathbefore inserting the copy and then assertsforensic.html.__file__is under it — and with each plant asserted to have changed the file before the tests ran, because astr.replacethat matches nothing returns the string unchanged and silently. The 16: a finding with no id; a positional id; ahash()-derived id; aclassattribute on a finding; a colour set through a class;--muteddropped to#999999; the dark palette removed; the print palette override removed;break-after: avoidremoved;orphans/widowsremoved;@pageremoved; the project dropped from<title>; the contents' visible heading removed; itsaria-labelremoved; the contents moved back inside the masthead; afloat; afont-size: 12px. - The three-arm mutation on colour is the one worth naming separately, because the predicate it tests replaces a stronger one: the markup arm, the stylesheet arm and the contrast arm each fail independently.
Tensions this pass did not resolve
The browser print footer is not under our control. The identity is in
<title> because that is where a browser will print it; whether it prints is
the user's dialog setting, and in Edge the default is off. There is no way for a
self-contained, script-free HTML file to guarantee a page footer.
The contents prints, and the brief asked for it not to. Argued in §2. One line reverses it.
A finding's evidence lines are not separately addressable. Only the
finding's own <li> carries an id, so a letter can cite #finding-ufgs-098 but
not the third line of its evidence. Inventing ids for evidence lines would mean
deriving them from position — the exact defect §3 is about — because an evidence
line has nothing of its own to derive from.
Handed back — content, not presentation
unvalidated_engine_limitation().whatis one 1,878-character chain of clauses. It is the limitation an opposing expert reads most carefully, and as a single<li>it is one uninterrupted run of speech — the same shape as the 6,414-character UFGS-041 line thattest_no_run_of_speech_in_the_report_grows_with_the_size_of_the_schedulewas written for, except that this one is hand-written, so that test cannot fire on it. Not fixed here: changing it changes what the report says. Taken up and fixed 2026-09-03 — see "The check that could not see it" below. It had reached 2,263 characters by then.#### Not evaluated — one input away from deciding (74)is 74 items, about 30% of the document, and the least load-bearing section in it. Nothing is wrong with it; whether it belongs in the body or in an appendix is a content decision. Measured 2026-09-03 and handed back again, with a different recommendation than the obvious one — see "Grouping the abstentions" below.
Neither is a defect in the renderer.
The check that could not see it
Added 2026-09-03. Item 1 above was taken up. The reword was the smaller half of it; the check was the point, because a reword with no check regresses on the next correct addition, which is exactly how the paragraph reached 2,263 characters.
What the growth check covers, and what it cannot
test_no_run_of_speech_in_the_report_grows_with_the_size_of_the_schedule is
sound and stays. It is blind to hand-written text in two independent ways,
and both are deliberate:
- Its selector.
_longest_evidence_linetook only lines indented-— the levelcli._evidence_lineswrites — because the top level is the report's own prose and the longest line there is 1,581 hand-written characters. Including it would have put the bar above anything a 4,000-activity schedule can emit. The excluded set is exactly where the defect was. - Its predicate. It asserts a delta between a 300-activity and a 4,000-activity report, not a length. A constant 2,263-character blob has a delta of zero. Nothing about it grows, so nothing about it can be caught by a check about growth.
So a check written for over-long runs of speech, in the file that owns that property, could not fire on the longest run of speech in the report. It was not a weak check; it was a check about a different half of the same defect, and nobody had written the other half.
The other half
run_of_speech_problems in test_report_accessibility.py, run over both
jurisdictions and included in the broken-document control: no li, p or
dd in the rendered HTML carries more than MAX_RUN_OF_SPEECH = 1,000
characters of its own text.
The number is derived from the listener rather than from the corpus, because a bar set by measuring what is already there is a bar written to be passed. A screen reader at a default rate runs about 180 words a minute and English averages about 5.5 characters a word including the space, so 1,000 characters is one minute of speech before the listener reaches a point they can stop, step back or skip from.
A ceiling is the right shape here and the wrong shape for generated text — the growth test's own docstring says why, and that reasoning is not being contradicted: a ceiling for generated output would have to sit above anything a large schedule can emit, at which point it stops being able to fail. A ceiling for typed prose is falsified by a person adding a clause, which is a real input and the one that produced this defect. The two checks hold two halves and each now says which.
What survives it is stated rather than hidden. The longest run left in the
conformance report is identifier_limitation at 900 characters, 100 short of
failing. That is a warning, not a margin.
The fix silently weakened the growth check, and that is written down
The repair for the content — nested - bullets, below — put a
775-character hand-written line at the indent the growth check selects on.
Measured on the two fixtures, with nothing else changed:
| selector | small (300) | large (4,000) | delta | bar |
|---|---|---|---|---|
| indent-only, as it was | 775 | 775 | 0 | 16 |
| lines the other report does not carry | 143 | 146 | 3 | 16 |
A delta pinned at 0 is not total blindness, and the distinction is measured
rather than assumed: re-planting the ', '.join(...) blob in rules/ufgs.py
still fails under the indent-only selector, because at 4,000 activities that
blob is longer than 775. What the floor bought was silence about any growth
staying under 775 characters.
The selector is now what it always meant — lines this schedule size produced and the other did not. Prose is excluded because it does not change with the input, which is the property under test, rather than because of where it sits on the page.
The structure chosen for the limitation, and why not the other two
Limitation gained detail: tuple[str, ...], rendered as nested bullets under
consequence at the indent removed_by already uses. Five entries: how the
corpus is composed, what the gated subset says and why it is not the headline,
where the residual disagreement is, the four-step chain, the MPXJ trade, and
what the whole of it means for a figure taken out of this report.
The other two options were considered and are refused for stated reasons.
- Multiple paragraphs inside one
Limitation. This is the option that reads best, and it is not renderable.forensic/html.py's_render_blockscallsclose_all()on a blank line, which closes every open list, so a Markdown list item with a second paragraph in it becomes a list, a paragraph and a second list. Making it renderable means changing the renderer that was rewritten tonight and is owned elsewhere. A nested bullet is the structure that already works on both surfaces. - Several
Limitationentries. More honest in one respect — the level, the chain and the MPXJ trade are three statements — and it cannot be done from here:unvalidated_engine_limitation()returns oneLimitationandcli.pyputs the call in a tuple positionally, so changing the arity means editingcli.py, which is owned. It is also worse in one respect that is not about ownership: five sibling limitations read as five limitations, and there is one. Nesting says these belong to that, which is true. - Shortening it. Refused. Every clause is load-bearing, and the two the report would most want to lose — the gate figure it does not quote as the headline, and the MPXJ fall that came with the rise — are precisely the two that stop the paragraph flattering itself.
Not one word was removed and no figure moved. Two semicolons became full
stops where a clause became its own entry. The chain's intermediate figures —
61.89 → 67.50 → 67.54 → 67.71 — stay frozen as literals and only the final
step interpolates, unchanged by the split; test_no_live_agreement_figure_is_also_written_as_a_literal passes.
The rendered run went from one 2,263-character run to a 394-character
headline (the what, the em dash and a 318-character consequence) and five
entries of 261, 289, 771, 323 and 220. The longest run in the report is now
identifier_limitation at 900.
Grouping the abstentions — measured, and the obvious design is refused
Item 2 above suggested grouping Not evaluated by the missing input, so a reader sees "supply the contract completion date and eleven of these answer". That was worth measuring before building, and the measurement does not support it in the form proposed. On the conformance fixture:
| abstentions | distinct missing inputs | inputs that decide exactly one clause | |
|---|---|---|---|
us_federal |
74 | 55 | 43 |
us_state_or_private |
55 | 37 | 28 |
Grouping 74 items under 55 headings makes the section longer, not shorter, and 43 of those headings would introduce a single item. The story the section has is real but smaller than it looked: the five largest inputs decide 28 of the 74, and the largest single one decides 13.
So the shape that pays is not "group by what is missing". It is:
- order the abstentions by their missing input rather than by rule id, so
the thirteen clauses that all say
needs asce.delay_eventssit together and read as one request instead of thirteen; - lead the section with the counts —
74 clauses turn on 55 inputs; five of them decide 28; - leave the long tail flat, because a heading over one item is furniture.
forensic/respond.py's _supply — now wired in behind --respond, so the
"zero importers" note is out of date — is the right place for the contractor
half of this and does not group either. Both it and the two render sites in
cli.py were owned by other agents tonight and cli.py moved twice under this
one, so nothing was changed and the sites are named by their code rather than
by a line number. Handed back:
# cli._conformance_body -- currently `for f in report.not_evaluated:`
for f in sorted(report.not_evaluated, key=lambda f: (f.missing, f.rule_id)):
# the second renderer -- currently `... for f in skipped` in a comprehension
for f in sorted(skipped, key=lambda f: (f.missing, f.rule_id))
# forensic/respond.py `_supply` -- currently `for f in findings:`
for f in sorted(findings, key=lambda f: (f.missing, f.rule_id)):
Finding.missing is a sequence, so it sorts as a tuple and identical requests
land together; the lead sentence stating the counts is the other half and is a
sentence somebody has to write, not a sort.
A second finding, and it is content rather than presentation.
asce.delay_events (13 clauses) and
terms['aace.delay_events'] -- the delay events the analysis identified
(5 clauses) are two spellings of one input the reader has to supply once. Under
any grouping they become two requests for the same thing, 18 clauses apart.
That lives in forensic/rules/, which is owned; not changed, and not a defect
this pass created.
Demo staleness
This changes the rendered report and the committed demo/ renderings are now
staler by one more cause. test_demo was already failing on six cases and the
arrow change added to that set; the limitation split adds to it again. The
diff on those six is currently **Date:** becoming **Date prepared:**, which
is somebody else's change, so the set is unchanged in number and longer in
causes. The fix is the same one — python demo/run.py, once the other edits
land. The demo
was not regenerated here.
What I would do next
- Read a printed report for a 4,000-activity schedule. Every judgement in §1
was made against a 3-activity fixture. A long report has a much longer
contents list, and the card may want a
columnsrule at wide viewports —columnsreorders nothing, so it survivesreading_order_problems. Not done because it was not measured. - Ask a scheduler to read a printed one. Every judgement in §1 is one agent's.
- A
@media (prefers-contrast: more)arm. Cheap; the tokens are the only thing that would change. - A per-finding back-link to the contents. Refused for now: 92 more anchors, each announced, for a reader who already has a landmark and a heading list.
Source: docs/ACCESSIBILITY.md. Source commit date: 2026-09-09.