Rendered evidence · PR #5994

The collections panel on the tenant page

Three blocks under /tenants/[id] — the aged strip, the delinquency lifecycle as one row, and a chronology that merges four sources. Captured from the branch's own dev server against the anonymized stage table.

Pull request
#5994
Branch
gera/tenant-collections-panel
Commit
0df51dd879+local
Captured
2026-08-20T23:03:45.414Z
Data source
propflow-stage — anonymized by construction
Contact scan
clean — 0 violations across 6 pages

Every string listed under a screenshot was checked against that page’s captured DOM in the state the picture shows — after its actions ran. A miss aborts the run before anything is rendered, so a page that exists is a page whose assertions held.


/tenants/tenant_2f98a9a3-f31b-40ac-a800-3d9a49030eeb·1600 × 1100·viewport crop

1600×1100 — an account with nothing recorded, which is every account in the product today

Zero COLLCASE# rows exist in production — a full-table scan found none — so the operator-set stage is empty for every resident. This is therefore not the empty state; it is the state, and the panel had to be useful and honest in it rather than a row of blanks implying missing data.

Read what is still on screen. The header strip is fully populated from the ledger: the aged sum, the four bands, the age bound and the as-of stamp all come from the balance snapshot, which every delinquent account has. The stage chip says “No stage set” in words rather than rendering an em-dash that looks like a loading failure. The language chip names the recorded preference — here, English. It reads “Language not asked” when none is on file, which is the state that matters and is not photographable on this bench because every synthetic resident has one: the outbound stack defaults an unset preference to English, which is right for a text and wrong for a legal notice, since in Colorado a demand must be in the resident's known primary language and that makes it a statutory element of a valid notice. The absent-preference branch is pinned by the component's own DOM suite, which asserts the chip reads “Language not asked” and never “English”.

The rail below draws the whole money spine from the bands and then shows the three branches as possibilities — dimmed, dashed, captioned “Nothing past the ledger has been recorded for this account, so where it goes next is not known — not that it has gone nowhere.” The difference between those two sentences is the entire design.

Note the age reads oldest in 91+ days — the band the oldest money sits in, never an exact age. The rent roll hands us four buckets and no charge dates, so the only honest reading of money in the oldest bucket is “at least 91 days”. A precise-looking age is the number a PM pastes into a notice, and one day short voids a demand — 19 defective demands across 12 units in this portfolio's own history.

The collections panel at desktop width: a header strip with the aged sum and four bands, and below it a one-row delinquency lifecycle ending in three dimmed branch possibilities

Asserted in the captured DOM, in the state shown

  • Where they are
  • Current
  • Late
  • Fee eligible
  • Past due
  • Payment plan
  • Rental assistance
  • Legal track
  • No stage set
  • past due 31+ days
  • Nothing past the ledger has been recorded for this account
  • Paying in full returns this account to Current at any point up to judgment
  • C.R.S. §13-40-115(4)
  • English
  • oldest in 91+ days

The four spine labels plus the three branch names prove the rail rendered its full vocabulary rather than a truncated row. “No stage set” is the honest-absence string that would have been an em-dash if the panel had taken the easy branch; “English” pins that the language chip resolved a real recorded preference rather than rendering a hardcoded default. “90+ days” is the bound; a build that printed the raw 91 fails here. And the reverse-edge sentence plus its statute citation are asserted on EVERY section of this artifact, because a rail that can lose them is asserting a pipeline.

/tenants/tenant_2f98a9a3-f31b-40ac-a800-3d9a49030eeb·390 × 844·viewport crop

390×844 — the panel on a phone

The narrow case, and the one layout decision in the rail that is not obvious. The rail scrolls horizontally; it does not wrap. A wrapped rail reads as two rows of unrelated chips, and the ORDER is the whole message — so on a phone the row keeps its order and the reader pushes it sideways, which is what overflow-x-auto on a w-max track buys.

The header strip does wrap, because there the order carries nothing: the chips are a set of independent standing facts, not a sequence. The bands go from a five-across row to a wrapped grid and stay legible.

The page body itself must not scroll horizontally — only the rail's own track does. That is visible here: the card edges are flush at both margins.

The collections panel at 390px wide, with the lifecycle rail on a horizontally scrolling track

Asserted in the captured DOM, in the state shown

  • Where they are
  • Current
  • Late
  • Paying in full returns this account to Current at any point up to judgment
  • Nothing past the ledger has been recorded for this account

Only what is INSIDE the 390x844 crop. The header strip sits just above this fold and the later rail nodes are off the right edge of a horizontally-scrolling track — asserting either would be publishing a claim the picture does not show, which is the one thing this gate refuses. What is asserted is the rail's first two nodes in order plus both of the notes underneath, which is the phone-sized version of the claim: the row keeps its order and the reader pushes it sideways.

/tenants/tenant_2f98a9a3-f31b-40ac-a800-3d9a49030eeb·1600 × 1100·viewport crop

A state opened — what is true when an account is here, and who says so

Every node on the rail is a button. Clicking one opens its meaning underneath — the lo-fi's “click any state to see what it means” — and, crucially, the sentence naming which of the three sources put the account there.

This is “Past due”, opened. It carries the enrolment fact a PM needs (“A full month behind. This is where the reminder cadence starts.”) and then “Measured from the aged balance on the rent roll.” That second line is the panel's answer to a question the old cards could not be asked: is this something the system observed, something a person typed, or something we read in someone else's email? A rail that shows a state without saying where the state came from invites a PM to act on a parse of a Zendesk autoresponder as though a human had confirmed it.

The legend at the foot of the card carries all four marks, including Not known — which is a reach state, not a source.

The lifecycle rail with the Past due node opened, showing its meaning and the sentence attributing it to a ledger measurement

Asserted in the captured DOM, in the state shown

  • A full month behind. This is where the reminder cadence starts.
  • Measured from the aged balance on the rent roll.
  • Measured
  • From the inbox
  • Recorded by a person
  • Not known

The first two strings only exist together once a node is OPEN, which is what makes this a state assertion rather than a page assertion — the drawer-that-never-opened failure mode this tool was built for. The last four are the legend, asserted here rather than on page 1 because they are only worth reading beside a node that is using one of them.

/tenants/tenant_84616aa4-c0f3-496b-b25a-8bbcb47d64f8·1600 × 1100·viewport crop

An account deep in the legal path

The other end of the range. A person recorded a served demand on this account in July and escalated it to counsel in August, so the rail now draws the legal branchDemand ready → Served → At the firm → Filed → Judgment — with “At the firm” marked as where they are and a pencil glyph saying a human put it there. The last two nodes sit past the right edge of the horizontally-scrolling track in this crop, so they are not asserted here: this tool cannot see clipping by an inner scroll container, and a green ✓ beside a string the picture does not show is worse than not checking. Their reach and their accessible names are pinned in the component's DOM suite instead.

Two things it deliberately does NOT do. First, Demand ready and Filed stay dim even though a later node is lit: reaching “at the firm” says nothing about a demand having been prepared in the product, and the depth order is an ordering, not a path. Second, the stage that lit this is flagged_for_eviction, which maps to At the firm and never to Filed — that value explicitly means escalated, not filed, and nothing in PropFlow files an eviction. A rail that read the enum's name and drew a court filing would be putting a claim in front of a PM that no writer in the system can back.

The reverse edge is still on screen, and now the assistance stop-edge is too — because the legal branch is the branch it kills. Approved assistance has to stop every chase, demand and filing on approval, not on receipt of the money.

What is not on this page, and why. The chronology's third source — the parsed law-firm and assistance mail — renders nothing here, and that is a true reading rather than a bug: a sweep of every operational signal on the stage table found 0 rows carrying collections facts across all three properties, because the parser only claims mail for a property with collectionsCorrespondents configured and #5939 has only just landed. There is no environment in which that half is capturable today. It is covered instead by DOM tests that render the real component over real fact shapes — including the identity caveat, the verbatim dateRefs, and the pinning of an assistance approval read off the programme's own email.

The lifecycle rail drawing the legal branch for an escalated account, with At the firm marked as the current state

Asserted in the captured DOM, in the state shown

  • Where they are
  • Demand ready
  • Served
  • At the firm
  • Approved rental assistance stops the eviction branch specifically
  • Paying in full returns this account to Current at any point up to judgment

The three legal-branch labels IN FRAME prove the branch swapped in — none of them is on the previous pages, where the same rail ended at the three dimmed possibilities. `Filed` and `Judgment` are deliberately absent from this list; see the prose. The operator-recorded stage itself is asserted on the chronology page below, where it is actually in frame. The assistance stop-edge string is the one that must appear HERE and must NOT appear on page 1, and both halves are asserted.

/tenants/tenant_84616aa4-c0f3-496b-b25a-8bbcb47d64f8·1600 × 1400·viewport crop

The chronology on that account — claims and receipts, kept apart

The same account's history. Three operator rows here, each with a pencil glyph and a “Recorded by the property team” line — a CLAIM. A dun row or a cadence-end row would carry a receipt glyph and read “Automatic”. The new inbox rows read “From the inbox · <sender>” and never “Automatic”, because the product did not do the thing; it read a message from someone who did. Resident rows are the resident's own words in quotes.

That distinction is the card's whole reason for existing and it now spans four sources instead of two. A PM deciding whether to escalate needs to know which lines they can lean on.

Note what is not here: a second stage chip. The operator-set stage rides the header strip at the top of this section, and the chip this card used to carry was removed in the same change — two chips stating the same stage a few hundred pixels apart is how someone ends up checking whether they agree.

The collections chronology showing three operator-recorded rows, each attributed to the property team

Asserted in the captured DOM, in the state shown

  • Collections activity
  • Flagged for eviction
  • Recorded by the property team
  • Record action

The two stage labels are rendered from the STAGE vocabulary rather than the mechanical kind — a stage-carrying row names what it moved to, not “Stage changed”, which would make an operator open the row to learn anything. “Recorded by the property team” is the claim-side attribution; a build that leaked the system voice onto an operator row fails here.

/tenants/tenant_2f98a9a3-f31b-40ac-a800-3d9a49030eeb·1600 × 1100·viewport crop

The same account, dark — the last capture, because the theme persists

Run last on purpose: the theme preference is stored per browser, so a flip in the middle of this sequence would have silently tinted every page after it. Same account, same scroll position, theme switched to dark through the product's own control in the side nav — not a CSS override applied to the screenshot. Every colour in the two new components is a var(--color-*) token, so this page is the check on that claim rather than a restatement of it: a hardcoded hex would survive the light capture and show up here.

Two things worth looking at specifically. The dashed borders on the dimmed branch heads still read as dashed rather than dissolving into the panel — that is the only visual difference between “not reached” and a lit node, so it has to survive both themes. And the indigo ring on the current node is --color-primary, the one accent this panel uses.

The same collections panel rendered in the dark theme

Asserted in the captured DOM, in the state shown

  • Where they are
  • No stage set
  • Nothing past the ledger has been recorded for this account
  • Paying in full returns this account to Current at any point up to judgment

Deliberately the same strings as the light capture, minus the ones the light page already pinned. A theme switch must change nothing about what the panel SAYS — if these four drifted, the dark path is rendering different content, not different colours.


What these captures prove, and what they cannot

Where the pixels came from. Every page above was captured from this branch's own dev server, which proved from its own /api/health that it is bound to propflow-stage — anonymized by construction (ADR-0097/0110) — before a browser was allowed to launch. The property is appfolio-45, the synthetic test bench, not a customer book. No production data was read or written for this artifact.

The legal-path account was staged deliberately, and by the product's own hand. Nothing in any environment sits deep in the legal path, because the operator stage has never been used: zero COLLCASE# rows exist in production and none existed on stage either. So the three rows behind that page were recorded through the panel's own POST /api/collections/[id]/actions endpoint on the test bench — the same call the “Record action” button makes. That is a write, and it is disclosed rather than buried: it is on stage, on a synthetic property, and it doubles as an end-to-end exercise of the write path the panel depends on.

The third chronology source is genuinely not capturable today. A sweep of every operational signal on the stage table returned 0 rows carrying collections facts across all three properties. The parser from #5939 only claims mail for a property with collectionsCorrespondents configured, so there is nothing to photograph — not on stage and not in prod. Rather than fabricate a fixture and photograph it, that half is proven by DOM tests that render the REAL component over real fact shapes: the inbox attribution never reading “Automatic”, dateRefs quoted verbatim including a same-date pair in two spellings, the identity caveat appearing on a message that named nobody, and an assistance approval surviving the five-row collapse.

Positive-controlled, not merely green. Two guards were broken on purpose with the failure count predicted first. Collapsing the rail's unknown reach into “not reached” — the exact bug the four-state model exists to prevent — was predicted to fail 1 case and failed 1. Colouring in branch nodes the account was never observed at was predicted to fail 3 and failed 3. Both restored; 659 tests green across the affected lane, 78 of them new.

What a capture cannot settle. This tool asserts text in the captured DOM, not pixels, so a layout that drifted while keeping its labels would still publish. The dashed-vs-solid border that separates “not known” from “not reached” is a rendered-CSS claim carried by the pictures; its machine-checkable half is the accessible name, which says “not known” in words and is asserted in the component's own DOM suite.

Provenance. Generated by npx tsx scripts/ui-evidence.ts evidence/pr-5994.json --publish. Every name, unit and figure on these pages is synthetic test-bench data.

PropFlow Docs