Skip to content

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.

Listings are the protagonist on every property-bearing surface — search, detail, favorites, lists, dashboards. The pattern:

  1. Image first, primary visual weight. Galleries get the lightbox; cards always lead with a thumbnail with skeleton placeholder while loading.
  2. Identity row — property type badge + listing-type label (sale / rent) + price. Anchored in the user’s mental model before specs.
  3. Specs grid — bedrooms, bathrooms, area. Three icons, tight spacing, label-on-hover for accessibility.
  4. Location context — address (privacy-redacted when not granted), neighborhood, distance to anchor.
  5. Agent attribution — name + photo + contact CTA. Always present on detail, optional on cards.
  6. Status + freshness signalsstatus-active / status-pending / status-sold badge; 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.

Property status is colorized via the status-* tokens defined in layout.css. The semantic mapping is fixed and always identical on web and mobile:

TokenStatusColor family
--status-activeACTIVE listings — the default on-market stateGreen (success-adjacent)
--status-draftDRAFT listings — owner hasn’t published yetNeutral gray
--status-pendingPENDING — under contract / awaiting verificationAmber
--status-soldSOLD — terminalBlue
--status-rentedRENTED — terminalPurple
--status-inactiveINACTIVE — admin-soft-deletedMuted gray
--status-archivedARCHIVED — soft delete from owner sideDark gray
--status-pausedPAUSED — visible to owner only, off public searchOrange

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.

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_history shows 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) stopPropagation so the card click still routes to detail.

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

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 required prop 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 gets aria-invalid="true" + border-destructive while 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.

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:

SlotPurpose
iconLucide icon, 32–48 px, muted color
headingSingle sentence in text-foreground
bodyOptional secondary line in text-muted-foreground
ctaOptional 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.”

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:

  • Weblayout.css declares both :root { ... } and .dark { ... } blocks with full OKLCH values. mode-watcher toggles the .dark class on <html>. Tailwind’s @custom-variant dark (&:is(.dark *)); then routes every dark: 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.current on 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; the mobile-ui-audit skill 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.

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 and dd/mm/yyyy format. 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 shared i18n/{user,agent}/{en,es}.json catalogs (synced into each native app, read directly at runtime) for mobile.

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 (--ring token).
  • 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 use alt="".
  • 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.

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.