Component styleguide

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.

listing-card

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.

1214 Rothsay Cir, Richmond, VA 23220 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

4417 Kensington Ave, Richmond, VA 23221 Under Contract

$512,000

4417 Kensington Ave

Richmond, VA 23221 · Museum District

4 beds · 3 baths · 2,940 sq ft

Single family · 37 days on market

1400 Summit Ave, Richmond, VA 23230 Sold

$274,500

1400 Summit Ave

Richmond, VA 23230 · Scott's Addition

1 beds · 1 baths · 980 sq ft

Condo · 88 days on market

facet-sidebar

props: facets[]: label, count, active

used on: search.html

source: LIFTED from production markup — make-pages.py:facet_option()/facet_group(). The Neighborhood group is trimmed off; the four groups shown carry the whole pattern.

Counts and the checked (active) state are static here. On search.html the generator emits 0 and linkFacetedSearch() writes the live count into each [data-facet-count] after every change — with no [data-results] grid on this page that function returns early, so the numbers would all read 0 and demonstrate nothing. Bedrooms match EXACT counts (plus a 5+ catch-all); bathrooms are cumulative n+ thresholds. The two groups are matched differently on purpose.

map-panel

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.

compare-table

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 comparison

Side-by-side comparison of your saved properties
Property 1214 Rothsay Cir 1214 Rothsay Cir Richmond, VA 23220 908 Hanover Ave 908 Hanover Ave Richmond, VA 23220 2201 E Broad St 2201 E Broad St Richmond, VA 23223
StatusActiveActiveActive
List price$425,000$389,500$298,000
Beds / baths4 / 33 / 22 / 2
Square feet3,4502,1801,510
Price per sq ft$123$179$197
Year built191519221936
Days on market21149
Bids received4High: $412,2503High: $377,8001High: $289,050
Annual property tax$3,660$3,350$2,560
Elementary schoolGeorge W. Carver ElementaryGeorge W. Carver ElementaryChimborazo Elementary
My notes
Actions

bid-panel

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".

Current bids

Bid now

Offer deadline: July 15, 2026 at 5:00 pm

Bids placed on 1214 Rothsay Cir
BidderBid dateBid amount
#2600000079193/4/26$412,250
#2600001126488/11/26$388,850
#2600002173771/18/26$365,500
#2600003221066/25/26$342,100

[ status="open" · 1214 Rothsay Cir · asking $425,000 ]

Current bids

This property has sold. Bidding is closed.

Bids placed on 1400 Summit Ave
BidderBid dateBid 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 ]

step-tabs

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.

rating-stars

props: 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

Rate this provider

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.

provider-card

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. ]

schools-nearby-list

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.

Nearby schools

Source: Virginia Department of Education and NCES public datasets.

  • George W. Carver Elementary

    Grades K–5 · 0.3 mi away · 420 students

  • Albert Hill Middle School

    Grades 6–8 · 1.6 mi away · 453 students

  • Open High School

    Grades 9–12 · 1.4 mi away · 183 students

Contact the school or district directly to verify enrollment eligibility.

showing-calendar

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

Upcoming showings

  • Mon 5/11/26 · 11:30 am1214 Rothsay Cir
  • Tue 5/12/26 · 9:00 am908 Hanover Ave
  • Thu 5/14/26 · 11:30 am2201 E Broad St
  • Sat 5/16/26 · 2:00 pm3300 Monument Ave

View all showings →

mode="seller" · month grid, accept/decline

May 2026 · week of the 10th

Key: Accepted Proposed WeekMonth
Showings scheduled and proposed, week of 10 May 2026
Sunday Monday Tuesday Wednesday Thursday Friday Saturday
10
    11
    • 11:30 am1214 Rothsay Cir
    12
    • 9:00 am908 Hanover Ave
    13
      14

      Warning: timeslot double booking

      • 11:30 am2201 E Broad St
      • 11:30 am1801 Grove Ave
      15
        16
        • 2:00 pm3300 Monument Ave

        [ 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. ]

        dashboard-stat-card

        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. ]

        status-badge

        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.

        Every status badge state at both sizes, soft and solid
        state soft · sm soft · md solid · sm solid · md
        status-activeActiveActiveFor SaleFor Sale
        status-pendingUnder contractUnder contractUnder ContractUnder Contract
        status-closedSoldSoldSoldSold
        status-alertConflictConflictWithdrawnWithdrawn
        status-highlightHighlightHighlightFeaturedFeatured

        doc-list

        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. ]

        Docs & disclosures

        message-thread

        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. ]

        Sample Buyer · Buyer

        1214 Rothsay Cir · you are the seller

        Active
        1. Hi — we walked through on Saturday and loved it. Before we put an offer in, do you know when the roof was last replaced? The listing says 8 years but the inspection summary reads differently.

          Sample Buyer · Mon 5/11/26 · 9:14 am

        2. Good catch. The roof was done in 2018, so 8 years is right — the inspection line you are reading is the water heater. I have uploaded the roofing invoice to the docs section just now.

          You · Mon 5/11/26 · 10:02 am

        3. Perfect, I see it. One more: are you flexible on a 45-day close? Our lender says 30 is tight.

          Sample Buyer · Mon 5/11/26 · 10:20 am

        4. 45 works. Put it in the bid terms and I will see it alongside the price.

          You · Mon 5/11/26 · 11:05 am

        [ Style only — no send behaviour is wired. ]

        alert-toast

        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.

        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

        No homes match those filters

        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.

        design-tokens

        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.

        Colour

        • --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

        Type scale

        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.

        Type scale, weights, and where each step is used
        StepSampleWeightUsed 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
        • Poppins 300 font-weight: 300
        • Poppins 400 font-weight: 400
        • Poppins 500 font-weight: 500
        • Poppins 600 font-weight: 600
        • Poppins 700 font-weight: 700

        Status colour map

        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.

        Canonical status colours and their semantic meanings
        TokenSoft pillSolid pillSemantic 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

        Layout

        • --maxw
        • --header-h