Principles
The platform’s interface is property-centric: every information hierarchy, color choice, card layout, and interaction pattern follows from the question “what does the user need to know about this property right now?” These principles distill the team’s design intent into the rules a contributor should internalize before opening a UI PR.
Property-first hierarchy
Section titled “Property-first hierarchy”Listings are the protagonist on every property-bearing surface — search, detail, favorites, lists, dashboards. The pattern:
- Image first, primary visual weight. Galleries get the lightbox; cards always lead with a thumbnail with skeleton placeholder while loading.
- Identity row — property type badge + listing-type label (sale / rent) + price. Anchored in the user’s mental model before specs.
- Specs grid — bedrooms, bathrooms, area. Three icons, tight spacing, label-on-hover for accessibility.
- Location context — address (privacy-redacted when not granted), neighborhood, distance to anchor.
- Agent attribution — name + photo + contact CTA. Always present on detail, optional on cards.
- Status + freshness signals —
status-active/status-pending/status-soldbadge; price-drop badge when applicable; “new” badge within 7 days of publish.
Same shape on web and mobile so users moving between surfaces don’t re-learn the layout.
Status color taxonomy
Section titled “Status color taxonomy”Property status is colorized via the status-* tokens defined in layout.css. The semantic mapping is fixed and always identical on web and mobile:
| Token | Status | Color family |
|---|---|---|
--status-active | ACTIVE listings — the default on-market state | Green (success-adjacent) |
--status-draft | DRAFT listings — owner hasn’t published yet | Neutral gray |
--status-pending | PENDING — under contract / awaiting verification | Amber |
--status-sold | SOLD — terminal | Blue |
--status-rented | RENTED — terminal | Purple |
--status-inactive | INACTIVE — admin-soft-deleted | Muted gray |
--status-archived | ARCHIVED — soft delete from owner side | Dark gray |
--status-paused | PAUSED — visible to owner only, off public search | Orange |
The web Badge component (apps/svelte-web/src/lib/components/ui/badge/) and the native apps’ StatusBadge primitive consume these directly. Both light and dark variants are tuned in layout.css so contrast stays correct in both themes — see Tokens → Status badges.
For the canonical enum values, read packages/database/src/enums.ts (the const-object values) alongside packages/database/src/generated/db.ts (the column types) — these are typed enums, not loose strings.
Card layout pattern
Section titled “Card layout pattern”Property cards follow a tight composition contract. The web composer lives in apps/svelte-web/src/lib/components/properties/; the native apps compose the same shape from platform-native card primitives (see Components → Native mobile primitives).
Shared rules:
- Aspect ratio 3:2 on the image. Smaller crops on dense grids fall back to 4:3.
- Favorite-heart slot is always present on user-facing cards (web public search + favorites; mobile user app). Agent surfaces hide it.
- Price-drop badge: tertiary (pink/magenta) “-X%” pill in the top-left corner of the image, only when
price_historyshows an active discount. - Multi-unit pills overlay the bottom-right of the image when a property has more than one unit.
- Cards are tappable / clickable as a whole. Internal CTAs (favorite, share, agent profile link)
stopPropagationso the card click still routes to detail.
Map UX
Section titled “Map UX”The search surface is viewport-first: the visible map bounds drive the query, not a static city filter. Implications:
- Markers cluster automatically at low zoom. Cluster style: filled circle with count + halo, same primary color across themes.
- Marker style is zoom-adaptive — small dots far out, larger pins with thumbnails when zoomed in.
- The “list” and “map” surfaces are toggleable on mobile, side-by-side on web (50/50 default, drag-resizable). On mobile, a floating “Map” toggle on the list and “List” toggle on the map keep the round-trip one tap.
- Filter changes invalidate the map query and the list query simultaneously; the map only redraws markers when the result set changes (no shimmer when only sort order changes).
- The viewport coordinates are tracked in URL params + saved searches so a “share this view” link reproduces the same bounds.
See Architecture → Data flow for the search pipeline (Meilisearch autocomplete → Valkey GEOSEARCH → Kysely fetch → imgproxy URL signing).
Form patterns
Section titled “Form patterns”Forms anchor on Label + Input + error message as the atomic unit. The web stack is bits-ui primitives + Formsnap + sveltekit-superforms + Zod; the native apps use their own text Input + field primitives with per-screen ViewModel validation.
Conventions:
- Required vs. optional — Label takes a
requiredprop that renders an asterisk +aria-required. Optional fields render no marker. The opposite (marking optional, leaving required unmarked) is not used. - Field-level errors sit immediately below the input in
text-destructive. The input getsaria-invalid="true"+border-destructivewhile errored. - Form-level errors (cross-field, server-returned) render as an Alert above the submit button.
- Submit buttons show a Spinner inline while submitting; the button is disabled, not hidden.
- Destructive confirms (delete listing, archive, sign out) always go through a Dialog with the destructive variant on the confirm button and a Cancel button as the default-focused control.
- superforms owns validation state, not the component.
<Field.Error>reads from the superform errors store.
See Conventions → Web for the full form-action template; the same superform defaults + zod4 pattern is the only sanctioned way to write a new form on web.
Empty states
Section titled “Empty states”Every list / grid surface uses the EmptyState primitive (apps/svelte-web/src/lib/components/ui/empty-state/ on web; the matching native EmptyState). Four required slots:
| Slot | Purpose |
|---|---|
icon | Lucide icon, 32–48 px, muted color |
heading | Single sentence in text-foreground |
body | Optional secondary line in text-muted-foreground |
cta | Optional Button with the primary action (“Create listing”, “Search properties”) |
Loading states use Skeleton rectangles in the same shape as the loaded content (card-shaped skeleton on grids, row-shaped on tables). Never render a Spinner for list loading — Skeleton communicates “content is coming” while Spinner communicates “we’re working on a one-shot action.”
Dark mode strategy
Section titled “Dark mode strategy”The platform ships with a non-default-neutral dark palette: instead of pure neutral grays, surface colors are tinted with the brand indigo hue (HSL 270) at low chroma. This keeps the dark UI on-brand and improves perceived contrast on indigo accents.
Mechanism:
- Web —
layout.cssdeclares both:root { ... }and.dark { ... }blocks with full OKLCH values.mode-watchertoggles the.darkclass on<html>. Tailwind’s@custom-variant dark (&:is(.dark *));then routes everydark:utility through the same rule. - Native mobile — each app ships committed light + dark token palettes (
Tokens.swift/Tokens.kt) derived from the same web OKLCH values. The active palette is exposed through the environment (@Environment(\.tokens)on iOS,LocalTokens.currenton Android) and switches live with the chosen appearance (System / Light / Dark) — no restart. - Read colors from the token environment, never a hardcoded value. A raw
Color/ hex literal won’t flip with the active scheme; themobile-ui-auditskill flags these. - Map tiles — the search map applies a dark map style when the active scheme is dark.
Shadows in dark mode use 2–7× higher opacity to compensate for the indigo-tinted slate surface palette; see Tokens → Shadows.
i18n text expansion
Section titled “i18n text expansion”Spanish (es-MX, the primary locale) renders 15–25% longer on average than English equivalents. Layouts must accommodate without truncation or ellipsis:
- Buttons size to their longest label across both locales — don’t pin widths to English copy.
- Labels in forms can wrap; inputs sit on their own row.
- Currency and date formatting use
Intl.NumberFormat('es-MX', ...)/Intl.DateTimeFormat('es-MX', ...)— not'es', which loses the comma thousands separator anddd/mm/yyyyformat. See Conventions → Monorepo for the locale mapping rule. - Strings live in
apps/svelte-web/messages/{en,es}.json(compiled via Paraglide) for web and the sharedi18n/{user,agent}/{en,es}.jsoncatalogs (synced into each native app, read directly at runtime) for mobile.
Accessibility floor
Section titled “Accessibility floor”The platform targets WCAG 2.1 AA as the floor. The minimums:
- Text contrast — 4.5:1 for body text, 3:1 for ≥18 px or bold. Verified in both light and dark themes when introducing a new token combination.
- UI control contrast — 3:1 for borders, icons, focus rings.
- Keyboard navigation — every interactive control reachable via Tab; no
tabindex="-1"traps; visible focus rings (--ringtoken). - Semantic HTML — buttons are
<button>, links are<a>, headings are properly leveled. No<div onclick>. - Form labels — every input has a
<label for>or wrapping<label>. The Label component enforces this. - Image alt text — every meaningful
<img>has descriptive alt; decorative images usealt="". - Color is never the only signal — status changes also use icons + text labels; map markers include count text inside clusters.
The .claude/agents/design-review.md agent runs all of these as automated checks against Playwright-rendered surfaces; the .claude/skills/mobile-ui-audit/SKILL.md skill covers the parallel mobile rule set.
Where the design intent lives
Section titled “Where the design intent lives”This page distills the team’s design intent into rules a contributor can act on. The longer-form sources of intent — the why behind each pattern — live in:
/context/style-guide.md(~2,200 lines, gitignored — local-only working notes)./context/design-principles.md(~480 lines, gitignored).
Those files are read-only design intent; this docs site never embeds them. When a new pattern emerges that the team wants to surface publicly, it lands here under Design → Principles in the same paragraph style.