Docs / General Prism Design System Specification Updated Apr 3, 2026
Version: 1.0
Platform: Commercial Real Estate Intelligence
Stack: React 19, Mantine 7, MapLibre GL, ECharts, TanStack Router/Query/Table
Migration Notes
This specification targets the following changes from the current codebase:
Property Current Target Font IBM Plex Sans Inter Structural accent Indigo #4f46e5 Navy #1e3a8a Action accent Indigo #4f46e5 Teal #0d9488 Background #f9fafb#f8fafcToken naming Ad-hoc CSS vars Structured semantic tokens
A. Design Token Table
A.1 Color Tokens — Neutrals
Token Hex Usage color.background.default#f8fafcPage canvas color.background.subtle#f1f5f9Zebra rows, filter bars, inset areas color.background.muted#e2e8f0Disabled fills, skeleton base color.surface.card#ffffffCards, panels, modals, drawers color.surface.elevated#ffffffDropdowns, popovers (same fill, separated by shadow) color.border.default#e2e8f0Card borders, dividers, table lines color.border.strong#cbd5e1Input borders, active card borders color.border.focus#0d9488Focus ring color color.text.primary#111827Headings, body, table content color.text.secondary#64748bCaptions, helper text, breadcrumbs color.text.muted#94a3b8Placeholders, disabled text color.text.inverse#ffffffText on filled buttons/badges
A.2 Color Tokens — Structural & Action
Token Hex Role color.navy.base#1e3a8aSidebar bg, nav active, structural identity color.navy.dark#172554Sidebar hover, pressed color.navy.light#dbeafeNav active background tint (if light sidebar variant) color.navy.text#eff6ffSidebar text on navy color.teal.base#0d9488Primary CTA, links, active toggles color.teal.hover#0f766eButton hover, link hover color.teal.pressed#115e59Button active/pressed color.teal.light#ccfbf1Teal badge fill, highlight rows color.teal.subtle#f0fdfaLight teal wash for selected states
A.3 Color Tokens — Semantic
Token Hex Usage color.success.base#059669Positive deltas, success badges color.success.light#d1fae5Success badge fill color.success.text#065f46Success text on light fill color.warning.base#d97706Warning badges, attention states color.warning.light#fef3c7Warning badge fill color.warning.text#92400eWarning text on light fill color.error.base#dc2626Validation errors, destructive actions color.error.light#fee2e2Error badge fill, error row highlight color.error.text#991b1bError text on light fill color.info.base#2563ebInformational badges, links color.info.light#dbeafeInfo badge fill color.info.text#1e40afInfo text on light fill
A.4 Color Tokens — Interaction States
Token Value Usage color.hover.row#f1f5f9Table row hover, list item hover color.hover.controlrgba(14, 148, 136, 0.08)Button ghost hover (teal tint) color.active.controlrgba(14, 148, 136, 0.14)Button ghost pressed color.selected.row#f0fdfaSelected table row color.selected.strong#ccfbf1Active filter chip, strong selection color.disabled.bg#f1f5f9Disabled input/button fill color.disabled.text#94a3b8Disabled label text color.disabled.border#e2e8f0Disabled input border color.focus.ring0 0 0 2px #ffffff, 0 0 0 4px #0d9488Focus ring (box-shadow)
A.5 Color Tokens — Chart Palette
Token Hex Usage color.chart.series.1#0d9488Primary series (teal) color.chart.series.2#1e3a8aComparison series (navy) color.chart.series.3#7c3aedTertiary (violet) color.chart.series.4#ea580cQuaternary (orange) color.chart.series.5#0284c7Quinary (sky) color.chart.series.6#c026d3Senary (fuchsia) color.chart.area.fillrgba(13, 148, 136, 0.10)Area fill under lines color.chart.grid#e2e8f0Gridlines color.chart.axis#94a3b8Axis labels, tick marks color.chart.tooltip.bg#ffffffTooltip background color.chart.tooltip.border#e2e8f0Tooltip border
A.6 Color Tokens — Map Layer Palette
Token Hex Usage color.map.building#0d9488Building markers/fills color.map.comp#2563ebComp markers/fills color.map.tim#7c3aedTIM markers/fills color.map.cluster.bg#1e3a8aCluster circle fill color.map.cluster.text#ffffffCluster count label color.map.highlight#f59e0bHovered/selected feature color.map.heat.low#ccfbf1Heatmap low value color.map.heat.mid#0d9488Heatmap mid color.map.heat.high#115e59Heatmap high
B. Typography System
B.1 Font Stack
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
Enable OpenType features for tabular numerals:
.tabular-nums {
font-variant-numeric: tabular-nums lining-nums;
font-feature-settings: 'tnum' 1, 'lnum' 1;
}
Apply .tabular-nums to: KPI values, table numeric cells, chart axis labels, stat bars, pagination counts.
B.2 Type Scale
Token Size Weight Line Height Letter Spacing Usage font.size.pageTitle20px 600 1.3 (26px) -0.01em Page headings font.size.sectionTitle16px 600 1.35 (21.6px) 0 Card headings, section labels font.size.subsection14px 600 1.4 (19.6px) 0 Form section titles, sub-headings font.size.body14px 400 1.5 (21px) 0 Paragraph text, form labels, table cells font.size.bodyStrong14px 500 1.5 (21px) 0 Emphasized body text, table header cells font.size.caption12px 400 1.4 (16.8px) 0.01em Helper text, timestamps, badges, stat labels font.size.captionStrong12px 500 1.4 (16.8px) 0.01em Table column headers (uppercase) font.size.overline11px 600 1.4 (15.4px) 0.05em Section overlines (uppercase, rare) font.size.kpi28px 700 1.2 (33.6px) -0.02em Dashboard KPI values font.size.kpiSmall22px 600 1.25 (27.5px) -0.01em Secondary stat values
B.3 Hierarchy Rules
Size and weight establish hierarchy before color.
Do not use color alone to distinguish heading levels.
Maximum three weight levels on any single page: 400, 500/600, 700.
Uppercase is reserved for: table column headers (captionStrong) and overlines.
No italic usage in the interface except user-generated content.
C. Spacing, Radius, Density
C.1 Spacing Tokens
Token Value Usage spacing.22px Icon internal padding only spacing.44px Micro: between badge icon and label, between inline elements spacing.66px Input internal vertical padding (compact) spacing.88px Default gap between sibling fields, card internal padding minimum spacing.1212px Stack gap between form rows, between stat items spacing.1616px Card padding, section gap (comfortable) spacing.2020px Page padding (main content) spacing.2424px Between major page sections spacing.3232px Between top-level layout regions
C.2 Radius Tokens
Token Value Usage radius.xs4px Badges, small chips, inline tags radius.sm6px Buttons, inputs, selects, toggles radius.md8px Cards, panels, modals, drawers radius.lg10px Large containers (rare)
C.3 Border Specification
Property Value Default border 1px solid color.border.default (#e2e8f0)Strong border 1px solid color.border.strong (#cbd5e1)Focus ring box-shadow: 0 0 0 2px #ffffff, 0 0 0 4px #0d9488Divider 1px solid color.border.default (horizontal rule within cards)
Borders are the primary visual separation mechanism. Shadows are not used for cards or sections.
C.4 Shadow Tokens
Token Value Usage shadow.dropdown0 4px 12px rgba(0,0,0,0.08), 0 1px 3px rgba(0,0,0,0.06)Dropdowns, popovers, combobox lists shadow.modal0 8px 30px rgba(0,0,0,0.12), 0 2px 8px rgba(0,0,0,0.06)Modals, drawers shadow.tooltip0 2px 8px rgba(0,0,0,0.10)Tooltips
No other shadows. Cards, panels, and sections use borders only.
C.5 Density Modes
Two modes: Comfortable (default) and Compact . Toggle available on Explore and Operations pages.
Element Comfortable Compact Table row height 44px 32px Table cell vertical padding 10px 4px Table font size 14px 13px Input height 36px 30px Input font size 14px 13px Button height (sm) 30px 26px Button height (md) 36px 30px Card padding 16px 12px Filter bar padding 12px 8px Stack gap (form fields) 12px (xs) 8px Badge height 22px 18px Badge font size 12px 11px
Implementation Strategy
Density is managed via a CSS class on the content area and a React context:
// DensityContext.tsx
type Density = "comfortable" | "compact";
const DensityContext = createContext<{
density: Density;
setDensity: (d: Density) => void;
}>({ density: "comfortable", setDensity: () => {} });
CSS applies via [data-density="compact"] attribute on the page wrapper:
[data-density="compact"] .prism-table tbody tr { height: 32px; }
[data-density="compact"] .prism-table tbody td { padding: 4px 12px; font-size: 13px; }
[data-density="compact"] .mantine-TextInput-input { height: 30px; font-size: 13px; }
[data-density="compact"] .mantine-Button-root[data-size="sm"] { height: 26px; }
[data-density="compact"] .mantine-Button-root[data-size="md"] { height: 30px; }
[data-density="compact"] .mantine-Card-root { padding: 12px; }
Persist density preference per-page to localStorage under prism:density:{pageKey}.
D. Layout Grid System
D.1 Container Specification
Property Value Max content width 1440px (centered when viewport exceeds)Page padding 20px all sidesPage padding (mobile <980px) 16px all sides
D.2 Grid System
12-column grid using Mantine SimpleGrid and Grid components.
Property Value Columns 12 Gutter 16px (comfortable) / 12px (compact)Column minimum Fluid, no fixed minimum
Common Layouts
Pattern Columns Breakpoints KPI row 4 equal { base: 2, md: 4 }Metric cards 3 equal { base: 1, sm: 2, lg: 3 }Explore split 8 + 4 (table + sidebar) { base: 12, lg: 8 } + { base: 12, lg: 4 }Map + panel 12 (full width, panel overlays) N/A Form two-column 6 + 6 { base: 1, sm: 2 } via SimpleGridDashboard chart 6 + 6 { base: 12, md: 6 }
D.3 Breakpoints
Name Value Notes xs576px Phone portrait sm768px Tablet portrait md992px Sidebar collapses to drawer lg1200px Standard desktop xl1440px Wide desktop
D.4 Collapse Behavior
Breakpoint Sidebar Grid Page Padding >= 992px Visible (240px or 72px collapsed) Full multi-column 20px < 992px Hidden (drawer) Single column stacks 16px < 576px Hidden (drawer) Single column, reduced gaps 12px
D.5 Alignment Rules
Charts and tables always align to the same left edge within a card or section.
Map components are always full-width within their container (no internal margin).
Form fields in two-column layout use equal-width columns; labels are above inputs, never side-by-side.
KPI cards in a row all have equal height (Mantine Grid with grow).
Tables span full card width with no internal horizontal padding beyond cell padding.
E. Z-Index & Layering Model
E.1 Z-Index Scale
Layer z-index Elements Base content 0Page content, cards, tables Map canvas 0MapLibre canvas (rendered in DOM order) Map overlays 100Map legends, layer toggles, floating controls Map markers 100Custom marker overlays Map popups 200MapLibre popups Sticky header 300Table sticky thead, filter bar sticky Sidebar 400Navigation sidebar Topbar 500App header bar Dropdown 600Select menus, combobox lists, popovers Drawer 700Side drawers (create/edit panels) Modal backdrop 800Modal overlay Modal 801Modal content Tooltip 900Tooltips Command palette 950Command launcher overlay Notification 1000Toast notifications
E.2 MapLibre Layering Rules
MapLibre renders its own canvas at z-index 0. UI elements that overlay the map must:
Use position: absolute or position: fixed relative to the map container.
Set pointer-events: none on overlay containers; pointer-events: auto on interactive children.
Never use z-index values that conflict with Mantine's internal popover stack (Mantine popovers use z-index 300 by default — override via theme to 600).
Map controls (zoom, layer toggles, legend) sit at z-index 100 within the map container's stacking context. Since the map container creates its own stacking context, these values don't conflict with page-level z-indices.
E.3 Mantine Z-Index Overrides
// In Mantine theme
{
other: {
zIndex: {
dropdown: 600,
drawer: 700,
modal: 801,
tooltip: 900,
notification: 1000,
}
}
}
Override via component defaultProps:
Popover: { defaultProps: { zIndex: 600 } },
Select: { defaultProps: { comboboxProps: { zIndex: 600 } } },
Modal: { defaultProps: { zIndex: 801 } },
Drawer: { defaultProps: { zIndex: 700 } },
Tooltip: { defaultProps: { zIndex: 900 } },
Notifications: { defaultProps: { zIndex: 1000 } },
F. Shell Architecture
Property Value Expanded width 240pxCollapsed width 72pxBackground color color.navy.base (#1e3a8a)Text color color.navy.text (#eff6ff)Item height 36pxItem horizontal padding 12pxItem border radius radius.sm (6px)Icon size 20pxIcon color (default) rgba(255,255,255,0.65)Icon color (active) #ffffffActive item background rgba(255,255,255,0.12)Active item text color #ffffffActive item left accent 3px solid color.teal.baseHover item background rgba(255,255,255,0.08)Section label font.size.overline (11px), uppercase, rgba(255,255,255,0.45)Section gap spacing.24 (24px) between groupsDivider 1px solid rgba(255,255,255,0.10)Collapse transition width 200ms easeAdmin section Collapsible, with chevron indicator Collapse toggle button Bottom of sidebar, 20px icon, border-top separator
F.2 Topbar
Property Value Height 52pxBackground color.surface.card (#ffffff)Border Bottom: 1px solid color.border.default Padding horizontal 20pxLeft section Brand mark (logo, not wordmark) — 28px height Center-left section Breadcrumb trail + page title Center section Global search trigger (min 280px, max 400px) Right section Notification bell + Clerk UserButton
Breadcrumb Specification
Font: font.size.caption (12px), color color.text.secondary
Separator: / character, color color.text.muted
Current page: font.size.body (14px), weight 600, color color.text.primary
Breadcrumb and title stack vertically in a single line-height-adjusted container
Global Search Trigger
Property Value Height 32pxBackground color.background.default (#f8fafc)Border 1px solid color.border.defaultBorder radius radius.sm (6px)Placeholder text "Search buildings, comps, TIMs..." Keyboard hint Right-aligned ⌘K badge On click/keyboard Opens Command Launcher overlay
F.3 Command Launcher
Property Value Activation ⌘K / Ctrl+KWidth 560px centeredTop offset 20% from top of viewportBackground color.surface.cardBorder 1px solid color.border.defaultShadow shadow.modalBorder radius radius.md (8px)Input height 44px, font font.size.bodyResult item height 40pxResult group header font.size.caption, uppercase, color.text.mutedActive result color.background.subtle backgroundDebounce 200ms for data search mode Max visible results 8 (scrollable)
G. Interaction Pattern Standards
G.1 Hover States
Element Hover Effect Table row Background color.hover.row (#f1f5f9) Button (filled) Darken fill by one step (e.g., teal.hover) Button (outline) Background color.hover.control Button (subtle/ghost) Background color.hover.control Sidebar item Background rgba(255,255,255,0.08) Card (clickable) Border color color.border.strong, no shadow, no transform Link Underline, color color.teal.hover
No hover lift/translate effects. No scale transforms. No shadow additions on hover.
G.2 Focus States
All interactive elements use a consistent focus ring:
:focus-visible {
outline: none;
box-shadow: 0 0 0 2px #ffffff, 0 0 0 4px #0d9488;
}
2px white inner ring for contrast on any background.
2px teal outer ring for visibility.
Applied via :focus-visible only (not :focus) to avoid showing on mouse click.
Override Mantine's default focus styles in theme.
G.3 Keyboard Navigation
Context Keys Behavior Global ⌘K / Ctrl+KOpen command launcher Global N (when not in input)Open "New" menu Global EscapeClose topmost overlay Table ↑ ↓Navigate rows (when table has focus) Command launcher ↑ ↓ EnterNavigate and select results Form ⌘Enter / Ctrl+EnterSubmit form Form EscapeCancel/close form Sidebar TabNavigate between items
State Visual Treatment Default Border color.border.strong, no helper text Error Border color.error.base (#dc2626), error text below in color.error.base, font.size.caption Helper text Below input, color.text.secondary, font.size.caption Required indicator Red asterisk after label Disabled Background color.disabled.bg, border color.disabled.border, text color.disabled.text
Validation is shown on blur or on submit, never on keystroke.
G.5 Destructive Action Pattern
Destructive buttons use color.error.base as fill.
Single-step destructive actions (delete a row): Confirmation popover with explicit action name. E.g., "Delete building 'Park Tower'? This cannot be undone." with "Cancel" (outline) and "Delete" (filled red) buttons.
Bulk destructive actions: Modal with count and explicit confirmation. E.g., "Delete 12 buildings? This cannot be undone."
Never use browser confirm() dialogs.
G.6 Disabled State
Opacity 0.5 is not used. Instead:
Background: color.disabled.bg
Text: color.disabled.text
Border: color.disabled.border
Cursor: not-allowed
No hover/focus effects
G.7 Motion
Animation Duration Easing Usage Drawer slide 200msease-outDrawer open/close Collapse expand 200mseaseAccordion, collapsible sections Fade in 150mseaseModal/drawer backdrop, tooltips Sidebar collapse 200mseaseSidebar width transition None — — No card hover animations, no page transitions, no skeleton shimmer
No spring, bounce, or physics-based easing. No transition-delay. No animation keyframes except loading spinners.
H. Page Pattern Standards
H.1 Dashboard
┌──────────────────────────────────────────────────────┐
│ PageHeader: "Dashboard" │
├──────────────────────────────────────────────────────┤
│ Filter Bar (collapsible) │
│ [Property Type ▾] [Class ▾] [Status ▾] [Market ▾] │
├──────────────────────────────────────────────────────┤
│ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │
│ │ KPI │ │ KPI │ │ KPI │ │ KPI │ SimpleGrid 4 │
│ └──────┘ └──────┘ └──────┘ └──────┘ │
├──────────────────────────────────────────────────────┤
│ ┌────────────────────┐ ┌────────────────────┐ │
│ │ Chart Card │ │ Map Card │ 2-col │
│ │ │ │ │ │
│ └────────────────────┘ └────────────────────┘ │
├──────────────────────────────────────────────────────┤
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
│ │ Activity │ │ Activity │ │ Activity │ 3-col │
│ └────────────┘ └────────────┘ └────────────┘ │
└──────────────────────────────────────────────────────┘
Filter bar sits above KPIs, collapsible via Collapse component.
KPI cards: font.size.kpi for value, font.size.caption for label, tabular-nums.
KPI delta indicator: green up arrow or red down arrow, font.size.caption.
Map card: Fixed 340px height within card.
Activity cards: time-relative labels ("2h ago"), entity link.
H.2 Explore Pages (Buildings, Comps, TIMs)
┌──────────────────────────────────────────────────────┐
│ PageHeader: "Buildings" [+ New Building] │
├──────────────────────────────────────────────────────┤
│ DataTableToolbar │
│ [🔍 Search...] [Density ▾] [View: Table | Map] │
├──────────────────────────────────────────────────────┤
│ StatBar: Total: 1,234 | Filtered: 567 | Page 1 of 57│
├──────────────────────────────────────────────────────┤
│ ┌──────────────────────────────────────────────────┐ │
│ │ Table (full width, sticky header) │ │
│ │ ┌────┬────────┬──────┬───────┬────────┬────────┐ │ │
│ │ │ │ Name │ Type │ RBA │ Market │ Status │ │ │
│ │ ├────┼────────┼──────┼───────┼────────┼────────┤ │ │
│ │ │ │ │ │ │ │ │ │ │
│ │ └────┴────────┴──────┴───────┴────────┴────────┘ │ │
│ └──────────────────────────────────────────────────┘ │
├──────────────────────────────────────────────────────┤
│ Pagination: ← Page 1 of 57 → │
└──────────────────────────────────────────────────────┘
Table view is default. Map view swaps table for a 580px map.
Create/edit opens a right-side Drawer (480px width).
Drawer has its own header (title + close), scrollable body, sticky footer (Cancel + Save).
Search and filters persist to URL via TanStack Router search params.
Density toggle available in toolbar.
H.3 Map Explore Page (Immersive)
┌──────────────────────────────────────────────────────┐
│ Topbar (52px) │
├──────────────────────────────────────────────────────┤
│ ┌──────────────────────────────────────────────────┐ │
│ │ MAP (fills remaining viewport height) │ │
│ │ │ │
│ │ ┌─────────────────┐ ┌──────────────┐ │ │
│ │ │ Filter Panel │ │ Layer Toggle │ │ │
│ │ │ (floating left) │ │ (floating rt) │ │ │
│ │ └─────────────────┘ └──────────────┘ │ │
│ │ │ │
│ │ ┌──────────────────────┐ │ │
│ │ │ Legend (bottom left) │ │ │
│ │ └──────────────────────┘ │ │
│ └──────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────┘
Map is NOT inside a card. It IS the page.
Map height: calc(100vh - 52px) (viewport minus topbar).
Sidebar can be collapsed on this page for maximum map area.
Floating panels have background: rgba(255,255,255,0.92), backdrop-filter: blur(8px), border, radius.md, shadow.dropdown.
Feature click opens a right-side drawer or bottom panel with entity details.
H.4 Analytics Pages
┌──────────────────────────────────────────────────────┐
│ PageHeader: "Vacancy Analytics" │
├──────────────────────────────────────────────────────┤
│ Controls Bar │
│ [Quarter ▾] [Dimension ▾] [Metric ▾] [Export ▾] │
├──────────────────────────────────────────────────────┤
│ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │
│ │ KPI │ │ KPI │ │ KPI │ │ KPI │ │
│ └──────┘ └──────┘ └──────┘ └──────┘ │
├──────────────────────────────────────────────────────┤
│ ┌──────────────────────────────────────────────────┐ │
│ │ Primary Chart (trend line, 400px height) │ │
│ └──────────────────────────────────────────────────┘ │
├──────────────────────────────────────────────────────┤
│ ┌────────────────────┐ ┌────────────────────┐ │
│ │ Comparison Chart │ │ Detail Table │ 2-col │
│ └────────────────────┘ └────────────────────┘ │
└──────────────────────────────────────────────────────┘
KPIs use tabular-nums and font.size.kpi.
Charts are inside cards with padding: 16px and a card title.
Chart canvas minimum height: 300px.
SegmentedControl for dimension switching, not tabs.
H.5 Operations Pages (Queue, Saved Views)
┌──────────────────────────────────────────────────────┐
│ PageHeader: "Needs Review" [Bulk Actions ▾] │
├──────────────────────────────────────────────────────┤
│ StatBar: Pending: 45 | In Progress: 12 | Done: 234 │
├──────────────────────────────────────────────────────┤
│ DataTableToolbar │
│ [🔍 Search...] [Status ▾] [Assigned ▾] [Density ▾] │
├──────────────────────────────────────────────────────┤
│ Table with priority indicators (left border accent) │
│ Row actions: [View] [Assign] [Complete] [Dismiss] │
├──────────────────────────────────────────────────────┤
│ Pagination │
└──────────────────────────────────────────────────────┘
Priority rows: 3px left border in color.error.base (high), color.warning.base (medium).
Row actions via RowActionsMenu (three-dot menu).
Density toggle available.
H.6 Data Management Pages
┌──────────────────────────────────────────────────────┐
│ PageHeader: "Owners" [+ New Owner] │
├──────────────────────────────────────────────────────┤
│ DataTableToolbar │
│ [🔍 Search...] │
├──────────────────────────────────────────────────────┤
│ Table (standard, no density toggle) │
├──────────────────────────────────────────────────────┤
│ Pagination │
└──────────────────────────────────────────────────────┘
Simpler than Explore pages — no map view, no density toggle.
Create/edit via right-side Drawer.
Row actions: Edit, Delete (with confirmation).
H.7 Admin Pages
┌──────────────────────────────────────────────────────┐
│ PageHeader: "System Setup" │
├──────────────────────────────────────────────────────┤
│ Tab navigation: [General] [Database] [Integrations] │
├──────────────────────────────────────────────────────┤
│ Card-based settings groups │
│ ┌──────────────────────────────────────────────────┐ │
│ │ Section Title │ │
│ │ Key-value settings with inline edit │ │
│ └──────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────┘
Tabs for sub-sections (Mantine Tabs component).
Settings displayed as labeled value pairs with Edit buttons.
Admin-only access badge in page header.
Destructive settings use color.error.base buttons with confirmation modal.
H.8 Drawer vs Modal Decision Matrix
Action Component Width Notes Create entity Drawer (right) 480px Scrollable body, sticky footer Edit entity Drawer (right) 480px Pre-filled form View entity detail Drawer (right) 560px Read-only sections Delete confirmation Modal 440px Centered, short content Bulk action confirmation Modal 480px Count + confirmation Global settings Modal 560px Centered Import wizard Modal 640px Multi-step Export settings Modal 480px Options + action
I. Map Standards (MapLibre)
I.1 Basemap Philosophy
Light, desaturated basemap. Streets and labels visible but not prominent.
Use a light-themed vector tile style (e.g., MapTiler Positron, Carto Positron, or custom OSM style).
Raster tile fallback: Current OSM tiles are acceptable as interim; migrate to vector tiles for style control.
Basemap text: #64748b for labels, #e2e8f0 for roads, #f1f5f9 for land fill.
The basemap must recede behind data layers.
I.2 Marker Styling Hierarchy
Entity Shape Size Fill Border Building Circle 10px radius color.map.building (#0d9488)2px #ffffff Comp Diamond (rotated square) 10px color.map.comp (#2563eb)2px #ffffff TIM Triangle 10px color.map.tim (#7c3aed)2px #ffffff Selected Same shape 12px color.map.highlight (#f59e0b)2px #ffffff Hovered Same shape 11px Original + 20% lighter 2px #ffffff
MapLibre Style JSON (Circle Layer Example — Buildings)
{
"id": "buildings-circle",
"type": "circle",
"source": "buildings",
"paint": {
"circle-radius": [
"interpolate", ["linear"], ["zoom"],
8, 3,
12, 6,
16, 10
],
"circle-color": "#0d9488",
"circle-stroke-color": "#ffffff",
"circle-stroke-width": 2,
"circle-opacity": 0.85
}
}
I.3 Cluster Styling
Property Value Cluster fill color.map.cluster.bg (#1e3a8a)Cluster text #ffffff, font.size.caption (12px), weight 600Cluster border 2px solid #ffffffCluster sizes Small (< 10): 24px, Medium (10-99): 32px, Large (100+): 40px Cluster opacity 0.9
MapLibre Cluster Style JSON
{
"id": "clusters",
"type": "circle",
"source": "buildings",
"filter": ["has", "point_count"],
"paint": {
"circle-color": "#1e3a8a",
"circle-radius": [
"step", ["get", "point_count"],
12, 10,
16, 100,
20
],
"circle-stroke-width": 2,
"circle-stroke-color": "#ffffff",
"circle-opacity": 0.9
}
}
I.4 Layer Toggles Panel
┌────────────────────────┐
│ LAYERS │
│ ┌────────────────────┐ │
│ │ ● Buildings ☑ │ │
│ │ ◆ Comps ☑ │ │
│ │ ▲ TIMs ☐ │ │
│ │ ─ Heatmap ☐ │ │
│ └────────────────────┘ │
└────────────────────────┘
Position: Top-right of map, below zoom controls.
Background: rgba(255,255,255,0.92), backdrop-filter: blur(8px).
Border: 1px solid color.border.default.
Border radius: radius.md (8px).
Padding: 12px.
Toggle: Mantine Checkbox or Switch, sm size.
Entity symbol matches marker shape and color.
Property Value Background color.surface.card (#ffffff)Border 1px solid color.border.defaultBorder radius radius.md (8px)Shadow shadow.dropdownPadding 12pxMax width 280pxTitle font.size.body, weight 600Body text font.size.caption, color color.text.secondaryArrow None (MapLibre popups without arrows are cleaner)
Enable clustering at all zoom levels below 14.
Use source.cluster: true with clusterMaxZoom: 14, clusterRadius: 50.
Limit visible features to viewport bounds via MapLibre's built-in frustum culling.
Avoid re-creating map sources on filter change — update source data in place via source.setData().
Use requestAnimationFrame for batch marker updates.
For heatmap layers, downsample to max 5,000 points.
Debounce moveend event handlers by 150ms.
Never force a full map re-render on React state change — use refs for map instance.
J. Table Standards
J.1 Structure
┌────────────────────────────────────────────────────┐
│ Sticky Header │
│ ┌──────┬──────────┬──────────┬──────────┬────────┐ │
│ │ ▲ │ Name ▲ │ Type │ RBA ▼ │ Status │ │
│ ├──────┼──────────┼──────────┼──────────┼────────┤ │
│ │ │ Park Tw │ Office │ 456,000 │ Active │ │
│ │ │ Metro C │ Industr │ 234,000 │ Draft │ │
│ │ │ │ │ │ │ │
│ └──────┴──────────┴──────────┴──────────┴────────┘ │
├────────────────────────────────────────────────────┤
│ Showing 1-25 of 1,234 ← Page 1 of 50 → │
└────────────────────────────────────────────────────┘
Property Value Font font.size.captionStrong (12px), weight 500, uppercaseColor color.text.secondaryBackground color.background.default (#f8fafc)Height 40px Padding 8px 12pxPosition sticky, top: 0, z-index: 2 (within table scroll context)Sort indicator Tabler arrow icon, 14px, color.text.muted (inactive), color.teal.base (active) Border Bottom: 1px solid color.border.strong
J.3 Cell Alignment Rules
Data Type Alignment Format Text (name, address) Left As-is Number (SF, price) Right Comma-separated, tabular-nums Percentage Right One decimal, tabular-nums Currency Right $ prefix, comma-separated, tabular-numsDate Left MMM DD, YYYY or MM/DD/YY (compact)Status / Badge Left (or Center) Badge component Actions Right Icon buttons or RowActionsMenu
J.4 Row Styling
State Background Border Even row color.surface.card (#ffffff)Bottom: 1px solid color.border.default Odd row color.background.default (#f8fafc)Bottom: 1px solid color.border.default Hover color.hover.row (#f1f5f9)— Selected color.selected.row (#f0fdfa)— Priority (high) — Left: 3px solid color.error.base Priority (medium) — Left: 3px solid color.warning.base
Property Value Position Below table, full width Layout Left: "Showing X-Y of Z" (font.size.caption). Right: ← Page N of M → Button style ActionIcon, outline variant, sm size Disabled Muted color, not-allowed cursor Page size selector Optional Select, sm size, options: 25, 50, 100
J.6 Loading State
Use Mantine Skeleton components in place of rows.
Skeleton rows: 8 rows matching comfortable row height (44px).
Skeleton shimmer: Use Mantine's default skeleton animation (subtle pulse).
Header remains visible during loading.
J.7 Empty State
Single row spanning all columns.
Centered text: font.size.body, color color.text.secondary.
Message: "No [entities] found" or "No results match your filters".
Optional: Reset filters link below message.
J.8 Error State
Alert component above table (not replacing it).
color.error.light background, color.error.text text.
Retry button inline.
J.9 URL State Sync
All table state syncs to URL search params via TanStack Router:
Param Key Example Page page?page=3Page size pageSize?pageSize=50Sort column sort?sort=rbaSort direction dir?dir=descSearch q?q=park+towerFilters filter_{name}?filter_type=Office
Read from URL on mount, write to URL on change via router.navigate({ search }).
J.10 Virtualization Readiness
For tables exceeding 100 visible rows:
Use @tanstack/react-virtual for row virtualization.
Maintain a fixed row height (no dynamic content expansion within rows).
Measure container height and calculate visible window.
Keep sticky header outside virtualized container.
Overscan: 5 rows above and below viewport.
K. Chart Standards (ECharts)
K.1 Global ECharts Theme
const PRISM_ECHARTS_THEME = {
color: [
'#0d9488', // teal — primary series
'#1e3a8a', // navy — comparison
'#7c3aed', // violet
'#ea580c', // orange
'#0284c7', // sky
'#c026d3', // fuchsia
],
backgroundColor: 'transparent',
textStyle: {
fontFamily: "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
color: '#64748b',
fontSize: 12,
},
title: {
textStyle: {
fontFamily: "'Inter', sans-serif",
fontSize: 16,
fontWeight: 600,
color: '#111827',
},
subtextStyle: {
fontSize: 12,
color: '#64748b',
},
},
};
K.2 Axis Styling
const axisCommon = {
axisLine: {
lineStyle: { color: '#e2e8f0', width: 1 },
},
axisTick: {
lineStyle: { color: '#e2e8f0' },
length: 4,
},
axisLabel: {
color: '#94a3b8',
fontSize: 11,
fontFamily: "'Inter', sans-serif",
fontVariantNumeric: 'tabular-nums',
},
splitLine: {
lineStyle: { color: '#e2e8f0', type: 'dashed', width: 1 },
},
nameTextStyle: {
color: '#64748b',
fontSize: 12,
fontWeight: 500,
},
};
X-axis: Labels aligned to center below ticks.
Y-axis: Labels right-aligned, padding 8px from axis line.
Grid padding: { top: 40, right: 24, bottom: 40, left: 56 } (adjust left for label width).
K.3 Gridlines
Horizontal gridlines only (on Y-axis splitLine).
Dashed, 1px, color.chart.grid (#e2e8f0).
No vertical gridlines.
const tooltipStyle = {
backgroundColor: '#ffffff',
borderColor: '#e2e8f0',
borderWidth: 1,
borderRadius: 8,
padding: [8, 12],
textStyle: {
color: '#111827',
fontSize: 13,
fontFamily: "'Inter', sans-serif",
},
extraCssText: 'box-shadow: 0 4px 12px rgba(0,0,0,0.08);',
};
Tooltip trigger: 'axis' for line/bar, 'item' for pie/scatter.
Tooltip format: Series color dot + series name + value.
Number formatting: comma-separated, 1-2 decimal places max.
K.5 Legend
const legendStyle = {
type: 'scroll',
bottom: 0,
left: 'center',
textStyle: {
color: '#64748b',
fontSize: 12,
fontFamily: "'Inter', sans-serif",
},
icon: 'roundRect',
itemWidth: 12,
itemHeight: 8,
itemGap: 16,
pageTextStyle: { color: '#94a3b8' },
};
Position: Bottom center, below chart.
Scrollable when > 6 series.
Interactive: click to toggle series visibility.
K.6 Series Color Mapping
Slot Color Hex Usage Primary Teal #0d9488Main metric series Comparison Navy #1e3a8aBenchmark or comparison 3rd Violet #7c3aedSupplementary 4th Orange #ea580cSupplementary 5th Sky #0284c7Supplementary 6th Fuchsia #c026d3Supplementary
Area fills: 10% opacity of series color.
Bar chart: No gradient fills. Solid color at 85% opacity.
Line chart: 2px stroke width. No dots except on hover.
Type Format Example Square feet Comma-separated, 0 decimals 456,789Large SF Abbreviated with suffix 1.2M SFPercentage 1 decimal + % 8.3%Currency $ + comma-separated$24.50Currency (large) $ + abbreviated$1.2MCount Comma-separated 1,234Date (axis) Q1 '24 or Jan '24—
K.8 Large Dataset Handling
For > 10,000 data points: use ECharts sampling: 'lttb' (Largest Triangle Three Buckets).
For > 50,000 data points: server-side aggregation before charting.
Enable large: true and largeThreshold: 2000 for scatter plots.
Use dataZoom (inside type) for time-series with > 100 x-axis points.
K.9 Loading / Empty States
Loading:
Show Mantine Skeleton matching chart card dimensions.
Do not show an ECharts spinner.
Empty:
Centered within chart area: Icon (Tabler IconChartOff, 32px, color.text.muted) + "No data available" text (font.size.body, color.text.secondary).
No ECharts noDataGraphic.
L. Mantine Theme Configuration
L.1 Complete Theme Object
import { createTheme, MantineColorsTuple } from '@mantine/core';
// Custom navy palette for Mantine color system
const navy: MantineColorsTuple = [
'#eff6ff', // 0
'#dbeafe', // 1
'#bfdbfe', // 2
'#93c5fd', // 3
'#60a5fa', // 4
'#3b82f6', // 5
'#1e3a8a', // 6 — base
'#1e3a8a', // 7 — base (repeated for primary shade)
'#172554', // 8
'#0f172a', // 9
];
// Custom teal palette
const teal: MantineColorsTuple = [
'#f0fdfa', // 0
'#ccfbf1', // 1
'#99f6e4', // 2
'#5eead4', // 3
'#2dd4bf', // 4
'#14b8a6', // 5
'#0d9488', // 6 — base
'#0f766e', // 7
'#115e59', // 8
'#134e4a', // 9
];
export const prismTheme = createTheme({
primaryColor: 'teal',
primaryShade: { light: 6, dark: 7 },
colors: { navy, teal },
fontFamily: "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
fontFamilyMonospace: "'JetBrains Mono', 'Fira Code', monospace",
headings: {
fontFamily: "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
fontWeight: '600',
sizes: {
h1: { fontSize: '20px', lineHeight: '1.3' },
h2: { fontSize: '16px', lineHeight: '1.35' },
h3: { fontSize: '14px', lineHeight: '1.4' },
h4: { fontSize: '14px', lineHeight: '1.4' },
},
},
fontSizes: {
xs: '11px',
sm: '12px',
md: '14px',
lg: '16px',
xl: '20px',
},
spacing: {
xs: '4px',
sm: '8px',
md: '12px',
lg: '16px',
xl: '24px',
},
radius: {
xs: '4px',
sm: '6px',
md: '8px',
lg: '10px',
xl: '12px',
},
defaultRadius: 'sm',
shadows: {
xs: 'none',
sm: 'none',
md: '0 4px 12px rgba(0,0,0,0.08), 0 1px 3px rgba(0,0,0,0.06)',
lg: '0 8px 30px rgba(0,0,0,0.12), 0 2px 8px rgba(0,0,0,0.06)',
xl: '0 8px 30px rgba(0,0,0,0.12), 0 2px 8px rgba(0,0,0,0.06)',
},
other: {
zIndex: {
mapOverlay: 100,
mapPopup: 200,
stickyHeader: 300,
sidebar: 400,
topbar: 500,
dropdown: 600,
drawer: 700,
modalBackdrop: 800,
modal: 801,
tooltip: 900,
commandPalette: 950,
notification: 1000,
},
},
components: {
Card: {
defaultProps: {
withBorder: true,
shadow: undefined,
radius: 'md',
padding: 'lg',
},
styles: {
root: {
borderColor: 'var(--color-border-default, #e2e8f0)',
},
},
},
Button: {
defaultProps: {
radius: 'sm',
},
styles: {
root: {
fontWeight: 500,
},
},
},
TextInput: {
defaultProps: {
radius: 'sm',
},
styles: {
input: {
borderColor: 'var(--color-border-strong, #cbd5e1)',
'&:focus': {
borderColor: '#0d9488',
},
},
},
},
Select: {
defaultProps: {
radius: 'sm',
comboboxProps: { zIndex: 600 },
},
},
MultiSelect: {
defaultProps: {
radius: 'sm',
comboboxProps: { zIndex: 600 },
},
},
Modal: {
defaultProps: {
radius: 'md',
zIndex: 801,
overlayProps: { backgroundOpacity: 0.35, blur: 2 },
},
},
Drawer: {
defaultProps: {
zIndex: 700,
},
},
Popover: {
defaultProps: {
radius: 'md',
shadow: 'md',
zIndex: 600,
},
},
Tooltip: {
defaultProps: {
radius: 'sm',
zIndex: 900,
},
},
Table: {
defaultProps: {
striped: true,
highlightOnHover: true,
},
},
Badge: {
defaultProps: {
radius: 'xs',
variant: 'light',
},
},
Tabs: {
styles: {
tab: {
fontWeight: 500,
fontSize: '14px',
padding: '8px 16px',
},
},
},
Skeleton: {
defaultProps: {
radius: 'sm',
},
},
Notification: {
defaultProps: {
radius: 'md',
},
},
},
});
L.2 CSS Variable Layer
Add to styles.css root:
:root {
/* Neutrals */
--color-bg-default: #f8fafc;
--color-bg-subtle: #f1f5f9;
--color-bg-muted: #e2e8f0;
--color-surface-card: #ffffff;
--color-border-default: #e2e8f0;
--color-border-strong: #cbd5e1;
--color-text-primary: #111827;
--color-text-secondary: #64748b;
--color-text-muted: #94a3b8;
/* Structural */
--color-navy-base: #1e3a8a;
--color-navy-dark: #172554;
--color-navy-light: #dbeafe;
--color-navy-text: #eff6ff;
/* Action */
--color-teal-base: #0d9488;
--color-teal-hover: #0f766e;
--color-teal-pressed: #115e59;
--color-teal-light: #ccfbf1;
--color-teal-subtle: #f0fdfa;
/* Semantic */
--color-success: #059669;
--color-warning: #d97706;
--color-error: #dc2626;
--color-info: #2563eb;
/* Interaction */
--color-hover-row: #f1f5f9;
--color-selected-row: #f0fdfa;
--color-focus-ring: 0 0 0 2px #ffffff, 0 0 0 4px #0d9488;
/* Typography */
--font-tabular: 'tnum' 1, 'lnum' 1;
}
M. Compact Mode Implementation Strategy
M.1 Architecture
DensityProvider (React Context)
├── value: "comfortable" | "compact"
├── toggle: () => void
├── persisted to localStorage per-page
└── sets data-density attribute on wrapper div
M.2 File Structure
shared/
ui/
DensityProvider.tsx — Context provider + hook
DensityToggle.tsx — SegmentedControl toggle component
theme/
density.css — All density-responsive CSS overrides
M.3 DensityProvider Implementation
// DensityProvider.tsx
import { createContext, useContext, useState, useCallback, ReactNode } from 'react';
type Density = 'comfortable' | 'compact';
interface DensityContextValue {
density: Density;
setDensity: (d: Density) => void;
}
const DensityContext = createContext<DensityContextValue>({
density: 'comfortable',
setDensity: () => {},
});
export function useDensity() {
return useContext(DensityContext);
}
export function DensityProvider({ pageKey, children }: { pageKey: string; children: ReactNode }) {
const storageKey = `prism:density:${pageKey}`;
const [density, setDensityState] = useState<Density>(
() => (localStorage.getItem(storageKey) as Density) || 'comfortable'
);
const setDensity = useCallback((d: Density) => {
setDensityState(d);
localStorage.setItem(storageKey, d);
}, [storageKey]);
return (
<DensityContext.Provider value={{ density, setDensity }}>
<div data-density={density}>
{children}
</div>
</DensityContext.Provider>
);
}
M.4 DensityToggle Component
// DensityToggle.tsx
import { SegmentedControl } from '@mantine/core';
import { IconLayoutDistributeVertical, IconLayoutAlignMiddle } from '@tabler/icons-react';
import { useDensity } from './DensityProvider';
export function DensityToggle() {
const { density, setDensity } = useDensity();
return (
<SegmentedControl
size="xs"
value={density}
onChange={(v) => setDensity(v as 'comfortable' | 'compact')}
data={[
{ label: 'Comfortable', value: 'comfortable' },
{ label: 'Compact', value: 'compact' },
]}
/>
);
}
M.5 Pages That Support Density Toggle
BuildingsExplorePage
CompsExplorePage
TimsExplorePage
OperationsQueuePage
OperationsSavedViewsPage (if table-based)
Wrap these pages in <DensityProvider pageKey="buildings">. Add <DensityToggle /> to their DataTableToolbar.
N. Reusable Component Structure
N.1 Current Components (Keep)
Component Location Status PageHeadershared/ui/PageHeader.tsxKeep, no changes needed FormSectionBlockshared/ui/FormSectionBlock.tsxKeep DataTableToolbarshared/ui/DataTableToolbar.tsxKeep, add density toggle slot StatBarshared/ui/StatBar.tsxKeep RowActionsMenushared/ui/RowActionsMenu.tsxKeep LookupSelectshared/ui/LookupSelect.tsxKeep QueryFeedbackshared/ui/QueryFeedback.tsxKeep TableEmptyStateRowshared/ui/TableEmptyStateRow.tsxKeep AddressMapPickershared/ui/AddressMapPicker.tsxKeep CommandLaunchershared/ui/CommandLauncher.tsxKeep
N.2 New Components (Add)
Component Location Purpose DensityProvidershared/ui/DensityProvider.tsxDensity context + wrapper DensityToggleshared/ui/DensityToggle.tsxDensity switch control KpiCardshared/ui/KpiCard.tsxStandardized KPI display ConfirmActionshared/ui/ConfirmAction.tsxDestructive action popover EntityDrawershared/ui/EntityDrawer.tsxStandard create/edit drawer shell ChartCardshared/ui/ChartCard.tsxChart wrapper with title + loading/empty MapContainershared/ui/MapContainer.tsxStandard MapLibre container with controls FilterBarshared/ui/FilterBar.tsxCollapsible filter controls row
N.3 Component API Sketches
KpiCard
interface KpiCardProps {
label: string;
value: string | number;
delta?: { value: number; direction: 'up' | 'down' | 'flat' };
format?: 'number' | 'percent' | 'currency' | 'sf';
linkTo?: string;
loading?: boolean;
}
EntityDrawer
interface EntityDrawerProps {
opened: boolean;
onClose: () => void;
title: string;
width?: number; // default 480
children: ReactNode;
footer?: ReactNode; // sticky footer with Cancel + Save
loading?: boolean;
}
ChartCard
interface ChartCardProps {
title: string;
subtitle?: string;
height?: number; // default 300
loading?: boolean;
empty?: boolean;
emptyMessage?: string;
children: ReactNode; // ECharts component
actions?: ReactNode; // export button etc
}
ConfirmAction
interface ConfirmActionProps {
title: string;
message: string;
confirmLabel: string; // e.g., "Delete"
onConfirm: () => void;
variant?: 'danger' | 'warning'; // default danger
children: ReactNode; // trigger element
}
O. UI Consistency Checklist
O.1 Color Usage
O.2 Typography
O.3 Spacing & Layout
O.4 Interactions
O.5 Component Patterns
O.6 Maps
O.7 Charts
O.8 Accessibility
O.9 WCAG AA Contrast Verification
Pair Foreground Background Ratio Pass Primary text on page bg #111827#f8fafc15.4:1 Yes Secondary text on page bg #64748b#f8fafc4.9:1 Yes Primary text on card #111827#ffffff17.3:1 Yes Secondary text on card #64748b#ffffff5.5:1 Yes Muted text on card #94a3b8#ffffff3.3:1 Fail (large text only) Teal on white #0d9488#ffffff4.5:1 Yes (borderline) Navy text on white #1e3a8a#ffffff9.4:1 Yes White on navy #eff6ff#1e3a8a8.7:1 Yes Error on white #dc2626#ffffff4.6:1 Yes Success on white #059669#ffffff4.6:1 Yes
Note: color.text.muted (#94a3b8) fails AA for small text. Use only for:
Placeholder text (exempt per WCAG)
Decorative/supplementary text where adjacent high-contrast text carries the meaning
Large text (18px+ or 14px+ bold)
P. File Summary
All specification sections mapped to implementation files:
Spec Section Implementation File(s) A. Design Tokens shared/theme/prismColors.ts, app/styles.css (:root vars)B. Typography app/providers.tsx (Mantine theme), app/styles.css (font import)C. Spacing/Density app/providers.tsx (theme), shared/ui/DensityProvider.tsx, shared/theme/density.cssD. Layout Grid Mantine Grid/SimpleGrid usage in page components E. Z-Index app/providers.tsx (theme other.zIndex), app/styles.cssF. Shell shared/ui/AppShellLayout.tsx, app/styles.cssG. Interactions app/styles.css (global states), app/providers.tsx (component defaults)H. Page Patterns Individual page components in modules/*/pages/ I. Map Standards shared/theme/prismColors.ts (MAP_COLORS), map componentsJ. Table Standards app/styles.css (.prism-table), shared/ui/TableEmptyStateRow.tsxK. Chart Standards shared/theme/prismColors.ts (ANALYTICS_COLORS), analytics pagesL. Mantine Theme app/providers.tsxM. Compact Mode shared/ui/DensityProvider.tsx, shared/ui/DensityToggle.tsx, shared/theme/density.cssN. Components shared/ui/*.tsx