For Sale
$425,000
1214 Rothsay Cir
Richmond, VA 23220 · The Fan
4 beds · 3 baths · 3,450 sq ft
Single family · 21 days on market
Sixteen components, one contract.
Every component in build-manual tab 13-SDC, demonstrated once, labelled with its machine name and its props. Twelve are shown in the exact markup the prototype already emits — if one of those disagrees with a page, the demo is the bug. Four were authored twice — once here and once on the screens that shipped in parallel — and were reconciled on 2026-08-06 into components.py, which both this page and those screens now import. Each says so in its source: line and carries a [ RECONCILED ] note recording which authoring won and why.
Sample values come from listing_data.py, the same module the search grid, the eighteen detail pages and the dashboards read, so nothing on this page can quote a price or a school the rest of the prototype does not have. Regenerate with python3 make-styleguide.py && python3 sync-shared.py.
props: image, price, address, beds, baths, sqft, status, url, flag_buttons
used on: search.html (18-card result grid) · listing-*.html (similar-listings rail) · dashboard-seller-listings.html (seller manage variant)
source: LIFTED from production markup — make-pages.py:listing_card(). Three status states shown: active, pending, closed.
flag_buttons is the heart affordance top-right — a real <button aria-pressed>, not a link, wired by linkFavorites() in js/main.js. It is painted --color-favorite, deliberately NOT --color-status-alert: an error and a saved home must never read as the same signal. The status pill is the solid status-badge variant; see that section for the full map.
For Sale
$425,000
Richmond, VA 23220 · The Fan
4 beds · 3 baths · 3,450 sq ft
Single family · 21 days on market
Under Contract
$512,000
Richmond, VA 23221 · Museum District
4 beds · 3 baths · 2,940 sq ft
Single family · 37 days on market
Sold
$274,500
Richmond, VA 23230 · Scott's Addition
1 beds · 1 baths · 980 sq ft
Condo · 88 days on market
props: images[], virtual_tour_url
used on: listing-*.html (all 18 detail pages)
source: LIFTED from production markup — make-listings.py:gallery() + lightbox(). Live — click the hero or a thumbnail.
virtual_tour_url has no rendering yet. The prop is in the contract and the comp shows a 3D-tour thumbnail, but no tour asset exists, so the slot is annotated rather than faked over a stock photo. [ VERIFY: tour provider and embed policy before wiring. ]
For Sale
Photograph 1
[ Placeholder photographs. A real listing also carries floor plan, video walkthrough, virtual tour and drone media types (comp v3a) — those thumbnails are omitted rather than labelled over a stock photo. ]
props: geojson pins, center, zoom, variant search/mini/schools
used on: search.html (search) · listing-*.html (mini) · dashboard-seller-listing-edit.html (mini)
source: STATIC PLACEHOLDER panels. The frame, the border and the mono label are the production fallback markup; the Leaflet layer is deliberately not loaded here.
Leaflet is NOT loaded on this page. Three live maps on one screen would pull three sets of tiles for a component whose behaviour is already exercised on search.html, so the panels below use the mono-label convention the prototype used before the maps went live. Never Google Maps for listing browse (cost rule) — the comp showed Google Maps + Street View and lost. [ VERIFY: variant="schools" is in the tab 13-SDC contract but linkMap() implements only search and mini. Flag for Christian. ] [ PRODUCTION: tile.openstreetmap.org is fine locally but its usage policy forbids it as an app tile source at volume — the Drupal build needs its own provider. ]
variant="search"
search.html — one pin per result card, re-filtered live on every facet change
[ map-panel: Leaflet + OpenStreetMap · variant="search" ]
variant="mini"
listing-*.html — a single pin for this property
[ map-panel: Leaflet + OpenStreetMap · variant="mini" ]
variant="schools"
contract variant — school catchment overlay. NOT IMPLEMENTED in linkMap() yet.
[ map-panel: Leaflet + OpenStreetMap · variant="schools" ]
See the live map on search.html → · and the mini variant on any listing detail page.
props: listings[], notes; sticky first column, max 4
used on: compare.html
source: LIFTED from production markup — compare.html. Trimmed to three of the four permitted columns; every figure is re-derived from listing_data.py so this demo cannot disagree with the detail pages.
Max 4 listings. The first column is sticky left-0 and the header row is sticky top-0; the corner cell needs a higher z-30 than either, or it is painted over on a diagonal scroll. Scroll the panel sideways and down to see both hold. The notes prop is the per-listing textarea row — it is the only editable cell in the table.
| Property |
1214 Rothsay Cir
Richmond, VA 23220
|
908 Hanover Ave
Richmond, VA 23220
|
2201 E Broad St
Richmond, VA 23223
|
|---|---|---|---|
| Status | Active | Active | Active |
| List price | $425,000 | $389,500 | $298,000 |
| Beds / baths | 4 / 3 | 3 / 2 | 2 / 2 |
| Square feet | 3,450 | 2,180 | 1,510 |
| Price per sq ft | $123 | $179 | $197 |
| Year built | 1915 | 1922 | 1936 |
| Days on market | 21 | 14 | 9 |
| Bids received | 4High: $412,250 | 3High: $377,800 | 1High: $289,050 |
| Annual property tax | $3,660 | $3,350 | $2,560 |
| Elementary school | George W. Carver Elementary | George W. Carver Elementary | Chimborazo Elementary |
| My notes | |||
| Actions |
props: status, price, expiration_countdown, actions
used on: listing-*.html (read-only public variant, all 18) · dashboard-seller-offers.html (seller accept/decline variant)
source: LIFTED from production markup — make-listings.py bid-panel block. Both status states shown; bid references and amounts are derived by listing_data.py:details().
expiration_countdown renders as a static deadline line today, not a ticking counter. A live countdown needs a server-authoritative deadline — a client-side timer on a bidding surface is a dispute waiting to happen. [ VERIFY: whether the contract's countdown means a live timer or the deadline stamp. Flag for Christian. ] On the closed state the primary action is removed rather than disabled: a disabled control still reads as "you may be able to do this".
Offer deadline: July 15, 2026 at 5:00 pm
| Bidder | Bid date | Bid amount |
|---|---|---|
| #260000007919 | 3/4/26 | $412,250 |
| #260000112648 | 8/11/26 | $388,850 |
| #260000217377 | 1/18/26 | $365,500 |
| #260000322106 | 6/25/26 | $342,100 |
[ status="open" · 1214 Rothsay Cir · asking $425,000 ]
This property has sold. Bidding is closed.
| Bidder | Bid date | Bid amount |
|---|---|---|
| No bids have been placed on this property yet. | ||
[ status="closed" · 1400 Summit Ave · the Bid now action and the deadline line are both withheld, not disabled ]
props: steps[]: label, state done/active/todo
used on: components.py — shared with bid.html and provider-onboarding.html
source: SHARED SOURCE — rendered by components.py, the same function the pages call, so this demo and the implementation cannot drift apart. Exactly one step per list may carry aria-current="step".
[ RECONCILED 2026-08-06: this demo and the page implementation were authored independently, then merged. Both now render from components.py, so the markup below IS the markup the pages emit. ] The underline treatment from provider-onboarding.html won: two of the three authorings already used it, and the pill treatment could not express a wizard of unknown length without wrapping mid-row. State is driven entirely by data-step-state, so one markup serves both bid.html's clickable wizard and provider-onboarding's server-rendered row. Not to be confused with .progress-tracker on dashboard-buyer.html, which is the interlocking-chevron treatment (clip-path in css/extras.css) for a fixed five-stage transaction. step-tabs is the general, wrapping, any-length version: chevrons cannot wrap without the notches colliding, so a wizard of unknown length needs this instead. Colour alone never carries the state — the marker glyph changes too, and each step names its state to a screen reader.
state reference
done — completed, tinted, check markeractive — solid navy, carries aria-current="step"todo — outline only, mutedprops: avg, count, show — HIDDEN when count < 5, project rule DR-8
used on: services.html and provider.html render the avg state inline, but not as a named component · provider-rating.html (landed in parallel with this page) is the first named instance
source: SHARED SOURCE — rendered by components.py, the same function the pages call, so this demo and the implementation cannot drift apart. The avg and hidden states match the markup already inlined in make-pages.py:provider_card(); the input variant is designed here.
[ RECONCILED 2026-08-06: this demo and the page implementation were authored independently, then merged. Both now render from components.py, so the markup below IS the markup the pages emit. ] provider-rating.html won. This demo previously rendered five full stars for a 4.6 average; glyphs are now proportional — that was a defect, not a style preference. DR-8 is a display rule, not a data rule. The average is still computed and stored below five reviews — it is only withheld from the page. Any surface that sorts or filters by rating has to honour the same threshold, or the ordering leaks the score the badge is refusing to show. Ratings come from completed LiNK transactions only; that is what makes the threshold meaningful.
show="avg" · count ≥ 5
4.6 23 reviews Rated 4.6 out of 5 from 23 reviews.
The published state. Glyphs are proportional — round(avg) filled, the remainder hollow — and the row is aria-hidden; the accessible reading is the number and the count, never the stars.
show="hidden" · count < 5 · DR-8
Not enough reviews yet 2 of 5 reviews collected Rating withheld — 2 of 5 reviews collected.
Below 5 reviews the score is withheld entirely — no stars, no average, no “3.0 (1 review)”. One glowing note from a friend must not be able to inflate a provider’s score.
show="input" · interactive
Real radios, so it is keyboard-operable and submits a value. Each label carries its own sr-only “N of 5 — Word”. Painting is driven by provider-rating.html’s script; unpainted here is correct.
props: logo, business_name, category, rating, coverage, promoted
used on: services.html (6-card directory) · provider.html renders the same fields as a page head
source: LIFTED from production markup — make-pages.py:provider_card(). Three instances: promoted, plain, and one under the DR-8 threshold.
promoted is an outlined blue chip beside the name, never a different card background or a pinned position in the demo — a promoted listing must stay visibly labelled as advertising. The third card carries 4 reviews and so falls under DR-8: no score at all. [ SAMPLE DATA — provider names, ratings and review counts are placeholders. ]
props: schools[]: name, type, distance
used on: listing-*.html (all 18 detail pages)
source: LIFTED from production markup — make-listings.py:school_rows(), keyed to The Fan via listing_data.py:SCHOOLS.
type renders as the grade band (K–5 / 6–8 / 9–12), which is what a buyer actually scans for. The source line and the verify-eligibility line are part of the component, not page copy — they travel with it. Never GreatSchools: that score is their licensed product. VDOE and NCES public datasets only, and no rating column here at all.
Source: Virginia Department of Education and NCES public datasets.
Grades K–5 · 0.3 mi away · 420 students
Grades 6–8 · 1.6 mi away · 453 students
Grades 9–12 · 1.4 mi away · 183 students
Contact the school or district directly to verify enrollment eligibility.
props: events[], mode seller/buyer/provider
used on: dashboard-seller.html and dashboard-seller-offers.html (list) · dashboard-buyer.html (list) · dashboard-seller-showings.html (month grid)
source: LIFTED from production markup — make-dashboards.py OVERVIEW_CARDS list block and cal2_cells() month grid. The month grid is trimmed to one week; addresses come from listing_data.py.
mode="provider" is in the contract but ships nowhere — no provider dashboard exists yet. [ VERIFY: whether a provider sees their own bookings or the seller's calendar. Flag for Christian. ] The accept/decline buttons only appear on proposed events; accepted ones are read-only in the cell.
mode="buyer" · list
mode="seller" · month grid, accept/decline
| Sunday | Monday | Tuesday | Wednesday | Thursday | Friday | Saturday |
|---|---|---|---|---|---|---|
| 10 |
11
|
12
|
13 |
14 Warning: timeslot double booking
|
15 |
16
|
[ DR-1 made visible: the outlined day holds two 11:30 am showings. In production the link_booking constraint plus the core lock service rejects the second at save time, regardless of entry point — this cell is the case that rule exists for. ]
props: label, value, delta, link
used on: dashboard-seller.html (icon variant) · dashboard-seller-offers.html and dashboard-seller-showings.html (plain variant)
source: LIFTED from production markup — make-dashboards.py OVERVIEW_CARDS / dashboard-seller.html icon variant, EXTENDED here with the contract's delta and link props, which ship nowhere yet.
Production renders only label and value. The delta and link props above are canonized here: delta is status-active when it improves and status-alert when it worsens — note that "worsens" is metric-dependent, so the direction is a prop, never inferred from the sign. Both are optional; the first card shows the component with neither. [ SAMPLE DATA — portfolio figures are placeholders. ]
props: state, size — ONE canonical color map for all statuses
used on: every page in the prototype: listing cards, compare-table, offers and showings tables, seller listing management, notifications
source: LIFTED from production markup — the two badge treatments already in production — soft (compare.html, dashboards) and solid (listing-card overlay). The size scale is formalized here.
One map, no exceptions. Moderation states, bid states, showing states and toast variants all resolve to the same five tokens — see the design-tokens section for what each one means. Introducing a second status palette is a defect, not a design choice. Soft is for badges inside content (a table cell, a card body); solid is for badges overlaid on photography, where a tint has no contrast to work against. Label text is a prop, not derived from the token: the same status-active badge reads "Active" to a seller and "For Sale" to a buyer.
| state | soft · sm | soft · md | solid · sm | solid · md |
|---|---|---|---|---|
status-active | Active | Active | For Sale | For Sale |
status-pending | Under contract | Under contract | Under Contract | Under Contract |
status-closed | Sold | Sold | Sold | Sold |
status-alert | Conflict | Conflict | Withdrawn | Withdrawn |
status-highlight | Highlight | Highlight | Featured | Featured |
props: docs[]: type, label, date, url
used on: listing-*.html (all 18 detail pages) · dashboard-seller-listing-edit.html (upload variant)
source: LIFTED from production markup — make-listings.py doc-list block, with the contract's type and date props added to the secondary line — production renders label and url only.
Production shows the label and the download link. type and date are in the contract and are rendered here on the secondary line; a buyer needs to know a disclosure is six months old. [ VERIFY: whether disclosure PDFs are public or gated behind a signed-in buyer. This is the one component where getting access control wrong has legal consequences. Flag for Christian. ]
props: messages[], participants — style only
used on: dashboard-buyer-messages.html (two-pane inbox) and bid.html — both landed in parallel with this page
source: SHARED SOURCE — rendered by components.py, the same function the pages call, so this demo and the implementation cannot drift apart. Two-party bubbles, participants header, per-message timestamps. No behaviour, per the contract.
[ RECONCILED 2026-08-06: this demo and the page implementation were authored independently, then merged. Both now render from components.py, so the markup below IS the markup the pages emit. ] dashboard-buyer-messages.html won: its timestamp sits under the bubble on the page ground rather than inside a navy bubble at paper/75, the more legible of the two. That page wraps this thread in a conversation-list pane and bid.html renders a composer with no transcript — both are compositions around this component, not rival versions. Sides are distinguished by three signals, not one: alignment, ground (navy vs shell), and the named author on every timestamp line. Alignment alone fails on a narrow viewport and means nothing to a screen reader, which is why the author name is repeated on each bubble rather than implied by the column. The list is an <ol> — message order is meaning here. Avatars are initials on a token ground; no user photography is assumed. [ VERIFY: retention and export policy for messages tied to a transaction. Flag for Christian. ]
props: alert-toast: message, variant · empty-state: icon, headline, cta
used on: alert-toast: bid.html (landed in parallel with this page). empty-state: search.html ([data-empty-state], shown by linkFacetedSearch() when a filter returns nothing).
source: SHARED SOURCE — rendered by components.py, the same function the pages call, so this demo and the implementation cannot drift apart. for alert-toast. The empty-state below is lifted from production markup — search.html, with the contract's optional icon prop added.
[ RECONCILED 2026-08-06: this demo and the page implementation were authored independently, then merged. Both now render from components.py, so the markup below IS the markup the pages emit. ] This four-variant treatment won, widened with a dismissible flag and a role override. bid.html's two one-off panels — a persistent tip and an inline validation error — now express as this component; neither is dismissable, and the tip is role="note" because a permanent callout is not a live region. Variants map onto the canonical status colours — success=status-active, warning=status-pending, error=status-alert, info=blue — so a toast and a badge describing the same thing agree on colour. Each toast carries an icon and a headline as well as the tint: colour is never the only signal. Live-region roles differ by variant. success, warning and info use role="status" (polite); error uses role="alert" (assertive), because it interrupts. Do not make everything assertive. The empty-state on search.html ships without an icon; the icon prop is optional and shown here.
alert-toast · variant success / warning / error / info
Showing confirmed
Sample Buyer is booked for 1214 Rothsay Cir on Thu 5/14 at 11:30 am.
Listing needs more data
Add a heating and cooling type before this listing can be promoted.
Timeslot double booked
Two showings are set for 11:30 am on Thu 5/14. Decline one to continue.
Price drop on a saved home
908 Hanover Ave dropped to $389,500.
Tip: attaching a pre-approval letter makes a stronger offer.
A persistent callout, not a live region: role="note", not dismissable. This is the form bid.html uses beside its upload field.
empty-state · icon, headline, cta
Try widening your price range or removing a neighborhood. You can also save this search and we'll email you the moment something matching comes on the market.
source: css/tokens.css, stamped inline by sync-shared.py. Every swatch below is painted by a Tailwind utility that reads the theme variable — no hex is written on this page, and the printed value is read back off the live CSS at runtime.
--color-navy-darkest
…
deepest ground
--color-navy
…
headings, primary buttons
--color-navy-footer
…
footer ground
--color-navy-mid
…
secondary fills, hovers
--color-blue
…
links, accents, active states
--color-ink
…
body text
--color-ink-soft
…
secondary text, captions
--color-rule
…
hairlines, card borders
--color-shell
…
page ground
--color-paper
…
cards, surfaces
--color-status-active
…
published / active / for sale
--color-status-pending
…
under contract / needs review
--color-status-closed
…
sold / off market / expired
--color-status-alert
…
rejected / conflict / error
--color-status-highlight
…
stat pills, star ratings
--color-favorite
…
save/favorite heart only
Poppins is canonical for both --font-head and --font-body (resolved 2026-08-05, superseding the Roboto + Libre Franklin in style-guide-link-20240928.pdf). Roboto stays as the first fallback only.
| Step | Sample | Weight | Used for |
|---|---|---|---|
| Display | Sell your house without a realtor | 700 | page-hero statement |
| Section heading | Sell your house without a realtor | 700 | section h2 |
| Card heading | Sell your house without a realtor | 700 | panel h2 |
| Eyebrow | Sell your house without a realtor | 700 | page-hero eyebrow |
| Lede | Sell your house without a realtor | 400 | intro paragraph |
| Body | Sell your house without a realtor | 400 | prose |
| UI | Sell your house without a realtor | 400 | labels, table cells |
| Caption | Sell your house without a realtor | 400 | secondary detail |
| Mono note | Sell your house without a realtor | 400 | [ SAMPLE DATA ] / [ VERIFY ] annotations |
font-weight: 300
font-weight: 400
font-weight: 500
font-weight: 600
font-weight: 700
ONE canonical map for all statuses. Moderation states, bid states, showing states and toast variants all resolve here. A second status palette is a defect.
| Token | Soft pill | Solid pill | Semantic names |
|---|---|---|---|
--color-status-active |
Active | For Sale | published · active · for sale · accepted showing · success toast |
--color-status-pending |
Under contract | Under Contract | pending · needs review · proposed · warning toast |
--color-status-closed |
Sold | Sold | sold · off market · expired · archived |
--color-status-alert |
Conflict | Withdrawn | rejected · double booking · error toast · destructive action |
--color-status-highlight |
Highlight | Featured | stat pills · star ratings · sample-data banner tint |
--maxw …--header-h …