Skip to content

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) — h1h6
  • 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), usually strokeDasharray="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.350), 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

  1. Content width: maxWidth: 1400, mx: "auto", horizontal padding px: { xs: 2, md: 3 }.
  2. Back link: body2 secondary text (← Deals), above hero / title block.
  3. Hero (when media): full-bleed gallery, then a title band below it.
  4. 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.
  5. Tabs / horizontal nav strips: use MUI Tabs with site-wide theme defaults (variant="scrollable", scrollButtons="auto", allowScrollButtonsMobile). Left/right chevron buttons appear when items overflow; disabled arrows collapse (theme.ts hides .Mui-disabled scroll buttons) so strips that fit stay flush. Compact minHeight: 4044, textTransform: "none", fontWeight: 600. Do not invent a bare overflow-x: auto row without scroll buttons — match deal detail / Trends / Account mobile nav.
  6. Main body: same max-width container as the title band; stack or grid below tabs. On mobile, constrain grids with minmax(0, 1fr) / minWidth: 0 so 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%"; prefer gridTemplateColumns: { xs: "minmax(0, 1fr)", … } over unconstrained 1fr rows 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 ragged flexWrap of many unequal buttons.
  • Chart controls (MarketTrendViewControls and Metrics lookback/grain): full-width stack on xs — Lookback + Project on one row, Granularity buttons equal-width below. Same on Trends, deal Trends, share Trends, Metrics.
  • Tab / section strips: always the scrollable Tabs pattern 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 TableContainer with overflowX: "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: hidden will 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/body2 secondary 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() or deal.title into a single wrapping string. Use streetLabel + cityRegionLabel from web/src/api.ts. addressLabel remains 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 structured property summary (at least address_line1 / address_line2 / city / state / postal_code) so the client can stack lines — do not rely on parsing title.
<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_code over parsing deal.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: DealDetailPage header, 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 SectionCardoverline 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 full SectionCard.

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 loadingLabel for the overlay’s role="status" accessibility name.
  • Default temporary minimum loading height is 120px. Raise loadingMinHeight when 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.
  • EmptyState is only for after load finishes with no data.
  • Do not nest a second loading overlay inside a child panel when the parent SectionCard already owns loading (e.g. pass loading={false} into ObservationPanel when 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 loading SectionCard.
  • 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:

  1. 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).
  2. Project — forecast switch, with its optional horizon caption directly beneath it (Trends / Appraisal only; omit on Metrics).
  3. 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 → Yearly only).
<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 (ToggleButtonGroup size small, Lookback TextField select, Project Switch) — 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:

  1. Listing — wide Listing details left · narrow Location (map) right
  2. Financials — narrow Strategy model left · wide Listing facts right
  3. 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.

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 status strings 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
  • PageLoadingOverlay for 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.ts without documenting why
  • Ad-hoc status / confidence chips (wrong color, missing icon, or plain Chip with only a label) — use DealStatusChip / DealBuyerStatusChip / ConfidenceChip
  • Horizontal page scroll from unconstrained flex/grid children
  • Overflowing tab rows without scroll buttons (raw overflow-x: auto only)

Implementation checklist (new detail page)

  1. Theme tokens only — no one-off brand colors; charts use the muted forest–stone–warn series table.
  2. Page shell: back link → optional hero → title band → tabs → body.
  3. Body: band headings + 11-col grid (or single column on xs).
  4. Panels via SectionCard; Manage links for secondary navigation.
  5. Long text: expandable max-height pattern.
  6. Match loading-overlay, empty-state, chart-toolbar, chart-chrome, status chips, stacked address, scrollable Tabs, and mobile-first conventions above.