UI visual language¶
Canonical reference for RealtyPad web layout and surfaces. Source of truth page: deal detail Overview (web/src/pages/DealDetailPage.tsx), plus shared theme (web/src/theme.ts) and SectionCard.
Use this when building or restyling list/detail pages so they feel like the same product.
Brand / atmosphere¶
Quiet field desk / research atelier: forest-green brand on cooler stone-mint ground. Surfaces stay calm; color carries meaning without neon or dashboard candy.
Theme source of truth¶
All UI color should come from (or deliberately extend) web/src/theme.ts. Prefer theme.palette.* / MUI color props over one-off hex in components. When a chart needs a multi-series palette, derive muted variants from the table below — do not paste bright Tailwind defaults (#3b82f6, #16a34a, #dc2626, etc.).
Core tokens¶
| Token | Value | Theme path | Role |
|---|---|---|---|
| Primary | #1A4D3A |
palette.primary.main / success.main |
Forest green — CTAs, links, selected states, positive series |
| Primary dark | #123528 |
palette.primary.dark |
Deepest forest — closed / emphasis series |
| Primary light | #2D6B52 |
palette.primary.light |
Softer forest — secondary positive series, volume bars |
| Trends sage | #2F6B4F |
(Trends charts) | Rent / secondary line stroke (same family as primary light) |
| Ground | #E8EEE9 → #E2E9E4 |
palette.background.default (+ soft radial washes) |
Page background |
| Paper | #F3F6F4 |
palette.background.paper / primary contrast |
Card / dialog surfaces, chart tooltip fill |
| Text primary | #121714 |
palette.text.primary |
Body / headings |
| Text secondary | #5A6860 |
palette.text.secondary / secondary.main |
Meta, axis ticks, agent / muted series |
| Accent warn | #C45C26 |
palette.error.main / warning.main |
Error, warning chips, “passed” / negative outcomes (not purple, not terracotta-cream AI defaults) |
| Divider | rgba(18, 23, 20, 0.12) |
palette.divider |
Hairlines, chart grid |
| Axis line | rgba(18, 23, 20, 0.2) |
— | Recharts axis strokes |
| Hover / selected | rgba(26, 77, 58, 0.06) / 0.1 |
palette.action.* |
Interactive wash |
| Stone mute | #9AA59E |
— | Neutral / watch / system series (between secondary and paper) |
| Bronze mute | #8B6F47 |
— | Warm mid-tone (e.g. pursuing) — keep desaturated |
| Radius | 4 theme / 3 controls / 2 (8px) cards |
Quiet, slightly squared | |
| Elevation | Prefer elevation={0} + outlined borders |
No multi-layer shadows |
Typography:
- Display / headings: Lora (serif) —
h1–h6 - UI / body: Source Sans 3
- Buttons:
textTransform: "none", weight 600
Chart & data viz color¶
Reference implementations: Metrics (WorkspaceMetricsPage.tsx), Trends (MarketTrendsPanel.tsx), Appraisal & Tax.
Series fills / strokes — stay inside the forest–stone–warn family:
| Role | Prefer | Avoid |
|---|---|---|
| Primary / ranked / user / actual | #1A4D3A |
Bright green (#16a34a, #22c55e) |
| Soft positive / volume / researching | #2D6B52 or #2F6B4F |
Saturated teal (#0f766e, #14b8a6) |
| Negative / passed / warn | #C45C26 |
Bright red (#dc2626, #ef4444) |
| Neutral / watch / system | #9AA59E or #5A6860 |
Cool slate blue (#94a3b8) |
| Mid warm (pursuing, etc.) | #8B6F47 |
Bright amber / yellow (#ca8a04, #eab308) |
| Agent / secondary actor | #5A6860 |
Bright blue (#3b82f6, #2563eb) |
Chart chrome (match Trends):
- Grid:
stroke="rgba(18, 23, 20, 0.12)"(divider), usuallystrokeDasharray="3 3" - Axis ticks:
fill: "#5A6860", ~11–12px - Axis lines:
stroke: "rgba(18, 23, 20, 0.2)" - Tooltip / paper: background
#F3F6F4 - Area fills: primary at low opacity (e.g. stopOpacity
0.35→0), not neon gradients
Stacked histograms and multi-status bars should remain readable but quiet on the mint ground — if a series looks like a SaaS dashboard rainbow, desaturate toward the table above.
Example metrics pipeline palette¶
watch: "#9AA59E",
researching: "#2D6B52",
ranked: "#1A4D3A",
passed: "#C45C26",
pursuing: "#8B6F47",
closed: "#123528",
Page chrome¶
- Content width:
maxWidth: 1400,mx: "auto", horizontal paddingpx: { xs: 2, md: 3 }. - Back link: body2 secondary text (
← Deals), above hero / title block. - Hero (when media): full-bleed gallery, then a title band below it.
- Title band: serif ask price (
h3) with primary CTAs (View listing,Share) on the same row; then stacked address lines (see Addresses below), optional market meta, chip row for status/strategy/score/CF, and captured date under the chips. - Tabs / horizontal nav strips: use MUI
Tabswith site-wide theme defaults (variant="scrollable",scrollButtons="auto",allowScrollButtonsMobile). Left/right chevron buttons appear when items overflow; disabled arrows collapse (theme.tshides.Mui-disabledscroll buttons) so strips that fit stay flush. CompactminHeight: 40–44,textTransform: "none",fontWeight: 600. Do not invent a bareoverflow-x: autorow without scroll buttons — match deal detail / Trends / Account mobile nav. - Main body: same max-width container as the title band; stack or grid below tabs. On mobile, constrain grids with
minmax(0, 1fr)/minWidth: 0so maps and long values cannot blow past the viewport.
Avoid dashboard chrome in the first viewport (stat strips, pill clusters, floating badges on media).
Mobile-first (required)¶
Every authenticated and public UI surface must work on narrow viewports (~360–430px) without horizontal page scroll (charts/tables may scroll inside a constrained frame).
Rules:
- Viewport: root content and cards use
minWidth: 0/maxWidth: "100%"; prefergridTemplateColumns: { xs: "minmax(0, 1fr)", … }over unconstrained1frrows that can expand past the screen. - Toolbars: stack or two deliberate rows on
xs(e.g. count + view toggle; actions + pagination). Do not rely on raggedflexWrapof many unequal buttons. - Chart controls (
MarketTrendViewControlsand Metrics lookback/grain): full-width stack onxs— Lookback + Project on one row, Granularity buttons equal-width below. Same on Trends, deal Trends, share Trends, Metrics. - Tab / section strips: always the scrollable
Tabspattern with left/right controls (see above), including Account mobile nav. Never a clipping row of items with no way to reach the rest. - Tables: wrap in
TableContainerwithoverflowX: "auto"when columns can exceed the viewport. - Charts / legends: keep Recharts inside a width-bounded frame; put multi-item legends below the chart in a wrapping flex row (SectionCard
overflow: hiddenwill clip in-chart legends). - Copy: prefer wrapping captions; put long date ranges on a second line instead of truncating mid-sentence.
- Touch: keep primary controls tappable (≥ ~40px row height for tabs/buttons); avoid hover-only affordances for critical actions.
When shipping UI, check the layout at a phone width before calling it done — mobile is not optional polish.
Addresses¶
Always format property addresses as stacked lines, never as one comma-joined string — including deal headers, inventory/list rows, investor linked-deal rows, public share views, and every table cell.
| Line | Content | Helpers |
|---|---|---|
| 1 | House #, street name, unit # | streetLabel(property) |
| 2 | City, state ZIP | cityRegionLabel(property) |
| 3 (when space allows) | Beds · baths · sqft · built year | Compose from beds / baths / sqft / year_built |
Examples:
- Header / card / list row title:
h5(or bold link/body2) for street;body1/body2secondary for city/region; optional facts or metrics line beneath. - Tables and inventory lists: put street on the primary line and city/region on a secondary caption line in the same cell/row — do not cram
addressLabel()ordeal.titleinto a single wrapping string. UsestreetLabel+cityRegionLabelfromweb/src/api.ts.addressLabelremains for one-line contexts that are not UI (clipboard, exports) only. - API payloads for list UIs: when a surface shows deals without a full
DealDetail, include a structuredpropertysummary (at leastaddress_line1/address_line2/city/state/postal_code) so the client can stack lines — do not rely on parsingtitle.
<Box sx={{ minWidth: 0 }}>
<Link component={RouterLink} to={…} fontWeight={600} underline="hover">
{streetLabel(property) || titleFallback}
</Link>
{cityRegionLabel(property) ? (
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.15 }}>
{cityRegionLabel(property)}
</Typography>
) : null}
</Box>
Rules:
- Prefer structured
property.address_line1/address_line2/city/state/postal_codeover parsingdeal.title. - Unit / apt goes on the street line (
address_line2), not the city line. - State is uppercase; ZIP sits after state with a space (
TX 78644). - If street is missing, fall back carefully (title or market) — never invent an address.
- Reference implementations:
DealDetailPageheader,DealInventoryRow,LinkedDealListRow,TriagePage,PublicDealPage.
Section hierarchy¶
Three layers of labeling:
| Layer | Component | Use |
|---|---|---|
| Row / band title | Typography h6, fontWeight: 700 |
Groups of cards: Listing, Financials, Research & comments |
| Card title | SectionCard → overline secondary |
Panel name inside a bordered paper (Location, Strategy model) |
| Field label | caption uppercase, secondary, slight letter-spacing |
Key/value cells (Ask, Tax / yr) |
Empty states: body2 + color="text.secondary", one short sentence, optional secondary CTA.
Cards (SectionCard)¶
Shared wrapper: outlined Paper, p: 2.5, borderRadius: 2.
- Title left (overline), optional action right, baseline-aligned.
- Card actions that open another tab/modal for maintenance should be text-style Manage links, not contained buttons:
<Button
size="small"
onClick={…}
sx={{ textTransform: "none", minWidth: 0, px: 0.5 }}
>
Manage
</Button>
- Contained buttons are for primary commits (View listing, Post comment, dialog submits).
- Nested subsections inside a card (e.g. Investors under Contacts): hairline
borderTop+ overline label + optional Manage link — do not nest another fullSectionCard.
Loading¶
Two patterns share the same primary spinner (CircularProgress size 28, thickness 4.5, color="primary"). Pick by whether cards already exist on the page.
Card / region loading (cards exist)¶
In-page and tab content fetches use SectionCard’s loading overlay (or CardLoadingOverlay for a card-like subsection such as a chat pane):
<SectionCard
title="Summary"
loading={loading}
loadingLabel="Loading deal"
loadingMinHeight={280}
>
{content}
</SectionCard>
Rules:
- Keep existing card content mounted (or a stable empty shell sized with
loadingMinHeight) so layout does not jump. - Cover the card with a translucent paper wash and one centered primary spinner; dim and block interaction with the content beneath it.
- Provide a short
loadingLabelfor the overlay’srole="status"accessibility name. - Default temporary minimum loading height is
120px. RaiseloadingMinHeightwhen the eventual card is predictably taller (charts, triage Summary / Listing). - Load in place — do not leave a blank gap under chrome, and do not swap the region for
EmptyState, raw “Loading…” copy, skeleton rows, or an action-area spinner. EmptyStateis only for after load finishes with no data.- Do not nest a second loading overlay inside a child panel when the parent
SectionCardalready ownsloading(e.g. passloading={false}intoObservationPanelwhen the Observations card overlays). - Button submissions continue to use disabled/busy button states.
Page loading (no cards yet)¶
When the page body has not mounted its cards (initial deal detail, public share deal, investor/contact detail, Metrics / Getting started before first data, etc.), use PageLoadingOverlay — the same spinner, floating centered in the content area, with no paper wash panel, border, radius, or SectionCard shell:
if (!deal || !finForm) {
return (
<Box sx={{ px: { xs: 2, md: 3 }, py: 3, maxWidth: 1400, mx: "auto" }}>
<PageLoadingOverlay label="Loading deal" minHeight={360} />
</Box>
);
}
if (loading) {
return (
<Container maxWidth="md" sx={{ py: 4 }}>
<PageLoadingOverlay label="Loading deal workup" minHeight={360} />
</Container>
);
}
Rules:
- Spinner only — do not wrap it in a fake empty card so the overlay “has somewhere to sit.”
- Optional page chrome (back link, title, stepper) may stay above; the loading body is still
PageLoadingOverlay, not a loadingSectionCard. - Auth / billing gates (
RequireAuth,RequireBilling, login redirect) may still use a bare centered spinner before the app shell exists.
Chart view controls¶
Time-series toolbars (Trends, Appraisal & Tax, Metrics, and any similar chart band) share one left-to-right order:
- Lookback — how much history to show (Trends:
3y/5y/10y/All; Metrics: unit presets like days/weeks/months/years, plus optional custom From–To dates; max 365 days). - Project — forecast switch, with its optional horizon caption directly beneath it (Trends / Appraisal only; omit on Metrics).
- Granularity — period bucket toggle at the far right. Offer only the buckets the series / window supports (Trends:
Monthly/Quarterly/Yearly; Metrics: filter by lookback span — Daily always; Weekly ≥14d; Monthly ≥60d; Quarterly ≥180d; Yearly ≥365d; assessor history →Yearlyonly).
<MarketTrendViewControls
granularity={granularity}
onGranularityChange={setGranularity}
lookback={lookback}
onLookbackChange={setLookback}
showProjection={showProjection}
onShowProjectionChange={setShowProjection}
horizonYears={horizonYears}
/>
Rules:
- Keep the order stable: Lookback → Project (if present) → Granularity.
- Place the control cluster as one unit (usually right-aligned above the charts). Jump links / CTAs may sit beside it, but must not be inserted between Granularity, Lookback, and Project.
- Use small controls (
ToggleButtonGroupsize small, LookbackTextFieldselect, ProjectSwitch) — same density as Trends. - Stack the horizon text directly below Project as secondary caption text; do not place it inline beside the toggle.
Detail page grid (deal Overview pattern)¶
Prefer row bands over one tall two-column stack. Each band has its own heading, then a two-column card row.
Track math (desktop lg+)¶
Use an 11-column grid so one row can reverse wide/narrow without flipping every row:
display: "grid",
columnGap: 3,
rowGap: 1.5,
gridTemplateColumns: { xs: "1fr", lg: "repeat(11, minmax(0, 1fr))" },
alignItems: "start",
Default split (~7 / 4):
| Span | Approx share | Typical content |
|---|---|---|
1 / span 7 or 5 / span 7 |
Wide (~64%) | Long copy, fact grids, comments |
1 / span 4 or 8 / span 4 |
Narrow (~36%) | Map, strategy breakdown, contacts/docs |
Per-row placement is intentional. Deal Overview today:
- Listing — wide Listing details left · narrow Location (map) right
- Financials — narrow Strategy model left · wide Listing facts right
- Research & comments — narrow Contacts (+ Investors) + Documents left · wide Summary + Comments right
Assign explicit gridColumn / gridRow (and matching xs rows) so mobile stacks: heading → left/right content in reading order.
Inside a column¶
Stack spacing={2.5} of SectionCards. Keep columns independent; don’t force equal card heights across the row unless the content is paired (map ↔ expandable text).
Expandable long text¶
For narrative blocks that would otherwise stretch the page (listing copy, rationale):
- Collapsed:
maxHeight: 240(not fixed height),overflow: "hidden". - Show Show more / Show less only when content overflows (
scrollHeight > clientHeight). - Button: small text style,
px: 0, left-aligned under the clip. - Empty copy still uses the quiet empty-state pattern; no fake filler.
Reference: ListingDetailsCard / SummaryCard on deal detail.
Fact grids¶
Key/value fields in a responsive grid:
gridTemplateColumns: { xs: "1fr 1fr", sm: "1fr 1fr 1fr" },
gap: 2,
Field pattern: caption label + body2 fontWeight={500} value. Money/score use shared formatters (money, moneySigned, pct) — never invent numbers in UI.
Chips & status¶
Default chips: size="small", variant="outlined". Prefer a short chip row under the title over a separate “metrics” card in the hero.
Status chips (required)¶
Deal triage status, investor link status, and confidence must use the shared components in web/src/components/statusChips.tsx — not ad-hoc Chips with plain labels. Each chip carries a fixed color (forest–stone–warn family) and a leading outlined icon.
import {
ConfidenceChip,
DealBuyerStatusChip,
DealStatusChip,
} from "../components/statusChips";
<DealStatusChip status={deal.status} /> // "Status Ranked"
<DealBuyerStatusChip status={link.status} /> // "Status Linked"
<DealBuyerStatusChip status={link.status} prefixed={false} />
<ConfidenceChip level={deal.confidence} /> // "Conf High"
prefixed defaults to true (list/detail chip rows). Use prefixed={false} when the surrounding UI already says “status” (e.g. Overview investor summary).
Deal triage status (DealStatus)¶
| Status | Color | Icon | Role |
|---|---|---|---|
watch |
#9AA59E stone mute |
Visibility | Parking / monitoring |
researching |
#2D6B52 primary light |
Manage search | Active research |
ranked |
#1A4D3A primary |
Star border | Cleared economic + investor fit |
passed |
#C45C26 warn |
Block | Rejected / no-fit |
pursuing |
#8B6F47 bronze mute |
Trending up | In motion / offers |
closed |
#123528 primary dark |
Check circle | Done / acquired |
Same hex map as the Metrics pipeline palette — keep charts and chips aligned.
Investor link status (DealBuyerStatus)¶
| Status | Color | Icon |
|---|---|---|
linked |
#5A6860 secondary stone |
Link |
interested |
#2D6B52 |
Thumb up |
passed |
#C45C26 |
Block |
offered |
#8B6F47 |
Handshake |
under_contract |
#1A4D3A |
Assignment turned in |
Confidence (ConfidenceLevel)¶
| Level | Color | Icon |
|---|---|---|
low |
#C45C26 |
Signal 1 bar |
med |
#8B6F47 |
Signal 2 bars |
high |
#1A4D3A |
Full signal |
Other chips¶
- Strategy / source / score / yield: default outlined, no status tones.
- Positive CF: outlined
color="success"(or primary); negative CF:color="warning". - Estimated tax/insurance, rent capped, repairs unknown:
color="warning". - Freeform comparable
statusstrings stay neutral outlined (no enum → no shared tone).
Reference: deal detail title band, DealInventoryRow, LinkedDealListRow, Overview investors, Triage summary chips.
Tabs vs Overview panels¶
- Heavy workflows stay on their own tabs (Underwriting, Trends, Investors manage UI).
- Overview shows read-only summaries with Manage → tab/modal.
- Don’t duplicate full CRUD surfaces on Overview.
Do / don’t¶
Do
- Quiet outlined cards on the mint ground
- Theme / forest–stone–warn colors only (including charts)
- Serif band titles + overline card titles
- Manage as text links; primary actions as contained buttons
- Centered loading overlays that preserve card content and dimensions
PageLoadingOverlayfor initial page loads before cards exist (spinner only, no fake card)- Explicit grid spans per row when wide/narrow must flip
- Clip long prose with max-height + Show more
- Chart toolbars: Lookback → Project (horizon below) → Granularity
- Chart chrome: muted grid/ticks matching Trends
- Addresses as stacked street / city-state-ZIP lines (including table cells)
- Status / confidence chips via
DealStatusChip/DealBuyerStatusChip/ConfidenceChip - Mobile-friendly layouts (
minWidth: 0, stacked toolbars, scrollable Tabs with arrows) - Horizontal nav/tab strips via scrollable
Tabs+ left/right scroll buttons
Don’t
- Purple gradients, glow, heavy shadows, rounded-full pill clusters
- Bright Tailwind / default Recharts palette (blue–green–red–amber rainbow)
- Equal-width columns everywhere “for balance”
- Contained buttons for secondary Manage actions
- Replacing page or tab content with “Loading…” text (or
EmptyState>Loading…) - Wrapping a pre-card page load in an empty
SectionCard/ paper panel just to host the spinner - Granularity before Project, or horizon copy inline beside Project
- Fixed-height empty cards that leave dead space
- Nesting cards inside cards for subsections
- Single-line comma-joined addresses in UI (
"1111 Silent Valley Rd, Lockhart, TX 78644") — use the stacked address pattern instead - One-off hex that ignores
theme.tswithout documenting why - Ad-hoc status / confidence chips (wrong color, missing icon, or plain
Chipwith only a label) — useDealStatusChip/DealBuyerStatusChip/ConfidenceChip - Horizontal page scroll from unconstrained flex/grid children
- Overflowing tab rows without scroll buttons (raw
overflow-x: autoonly)
Implementation checklist (new detail page)¶
- Theme tokens only — no one-off brand colors; charts use the muted forest–stone–warn series table.
- Page shell: back link → optional hero → title band → tabs → body.
- Body: band headings + 11-col grid (or single column on
xs). - Panels via
SectionCard; Manage links for secondary navigation. - Long text: expandable max-height pattern.
- Match loading-overlay, empty-state, chart-toolbar, chart-chrome, status chips, stacked address, scrollable Tabs, and mobile-first conventions above.