Design — principles and decisions¶
Philosophy¶
Spare by default, complex on demand.
Legible to first-time visitors; advanced filters reachable on demand. No "dashboard syndrome" or "dark ops" aesthetic.
Guiding principles¶
- Progressive disclosure — the default UI is simple: a map, points. Filters, details, and tools appear on demand.
- Clarity over aesthetics — every visual element must serve a function.
- Neutral and professional — sober tone, no "military-tech" or "hacker dashboard" tropes.
- Controlled density — information lives in layers: map → points → detail panel → full proof. The user picks their depth.
Theme¶
Dark minimal. Uniform dark background, opaque panels, warm accent (orange) for contrast.
Dark for long-session comfort; data reads better on dark.
Color palette¶
Foundation (dark)¶
| Role | Color | Tailwind | Usage |
|---|---|---|---|
| Background | #0a0a0a |
gray-950 |
Global background, behind the map |
| Surface | #171717 |
neutral-900 |
Panels, cards, modals |
| Surface elevated | #262626 |
neutral-800 |
Inputs, interactive elements, hover |
| Border | #333333 |
neutral-700 |
Separators, field outlines |
| Text primary | #f5f5f5 |
neutral-100 |
Titles, primary content |
| Text secondary | #a3a3a3 |
neutral-400 |
Labels, metadata |
| Text muted | #737373 |
neutral-500 |
Placeholders, disabled elements |
Accent¶
The orange palette uses tinted-on-dark variants almost exclusively — never a flat bg-orange-500 fill for buttons or selected states. The full recipe is in the Orange palette recipe below. The shorthand:
| Token | Where it shows up |
|---|---|
orange-400 |
Text colour for every interactive element (inline links, button labels, tappable-card hover state, status pills). |
orange-500 |
The hue itself — only appears at fractional opacity (bg-orange-500/10, /15, /20) on backgrounds and borders, and full strength on map points + 1.5 px state dots. |
Tag chips are decorative-not-interactive and use a neutral paint (bg-neutral-800 text-neutral-400) — see the Orange palette recipe (decorative tag chip).
Map points¶
| Role | Color | Usage |
|---|---|---|
| Point default | #f97316 / orange-500 |
All points, single color |
| Point selected | #f97316 + white border |
Active, clicked point |
Semantic¶
| Role | Color | Tailwind | Usage |
|---|---|---|---|
| Danger | #ef4444 |
red-500 |
Errors, deletions |
| Success | #22c55e |
green-500 |
Confirmations |
| Info | #3b82f6 |
blue-500 |
Hints, neutral links |
Orange palette recipe¶
Vidit's UI lives in a single tonal family: orange on dark, intensity varies. The recipes below split the meanings whether something is interactive, selected, or decorative. Every recipe is exported as a named constant from frontend/src/components/ui/styles.ts — use the constant, don't hand-roll the class string (and don't reintroduce the flat bg-orange-500 text-white fill, removed in v0.0.10).
The rule that governs all of it:
If something looks orange and isn't clickable, it's a bug. If something is clickable and isn't orange, it's a bug.
The five buckets¶
- Inline orange text link — plain clickable text in body copy or rows (bylines, source URLs, "Cancel", "Back to bounties").
text-orange-400 hover:underline; sometimeshover:text-orange-300when the surrounding row is also turning orange under group-hover. - Tappable card / row (
TAPPABLE_HOVER) — the whole card or row is one click target (GeolocationCard, BountyCard, search rows, profile external links). Neutral at rest; on hover the border turns orange and the inner title picks upgroup-hover:text-orange-400(putgroupon the row). - Primary CTA (
PRIMARY_BUTTON) — "do this now" buttons (Submit, Post a bounty, Geolocate this, Follow, admin actions, the error-boundary "Try again"). Soft-fill outlined orange, visible at rest, brightens on hover. The constant covers colour only — shape (padding, width,disabled:opacity-50) stays at the call site. - Selected / active state (
FILTER_CHIP_ACTIVE/FILTER_CHIP_INACTIVE) — a state indicator on an interactive element (active filter chip, active sidebar nav row, the bounties status filter). Reads asactive ? FILTER_CHIP_ACTIVE : FILTER_CHIP_INACTIVE. Status pills add a thin border so the badge reads as a discrete shape, in three states:STATUS_PILL_ACTIVE(open, orange),STATUS_PILL_FULFILLED(end-state, neutral white — not green: fulfilment isn't a win),STATUS_PILL_CLOSED(author-withdrawn, the quietest — neutral grey). - Decorative tag chip (
TAG_CHIP) — display-only metadata pills (bg-neutral-800 text-neutral-400), rendered as<span>not<button>. Neutral, so several tags on a card don't compete with the orange CTAs / status pills / links. If a tag is clickable, use bucket ④ instead.
Other orange shapes¶
These don't fit the five buckets:
BETA_PILL— the fixed closed-beta corner banner + the gate-page header badge. Same family as the status pill but less saturated (decorative, shouldn't compete with active-state pills).pointer-events-noneis added at the call site.- Map points — drawn on the WebGL canvas, not DOM. The bright full-strength
orange-500fill is justified by the dot-on-dark-map context: 5–7 px markers, not buttons. See Components → Map points. - Tiny state dots (1.5 px) — the map filter loading dot, the sidebar notification dot, the beta indicator dot; all
size-1.5 rounded-full bg-orange-500. - Destructive actions — the admin "Hard delete" stays
bg-red-500 text-white; sibling soft-delete buttons usePRIMARY_BUTTON, so "less destructive = quieter." - Navigation chrome (back arrows, × close buttons) — kept neutral grey (
text-neutral-400 hover:text-neutral-200) so structural chrome doesn't compete with content links.
Constants — single source of truth¶
All of the above export from styles.ts:
| Export | What |
|---|---|
PRIMARY_BUTTON |
Soft-fill outlined CTA |
FILTER_CHIP_ACTIVE |
Tinted selected state for toggles |
FILTER_CHIP_INACTIVE |
Neutral partner of FILTER_CHIP_ACTIVE |
TAPPABLE_HOVER |
Orange-border-on-hover for tappable cards/rows |
STATUS_PILL_ACTIVE |
Status pill — open / in-progress (orange) |
STATUS_PILL_FULFILLED |
Status pill — completed end-state (neutral white) |
STATUS_PILL_CLOSED |
Status pill — withdrawn / archived (neutral grey) |
BETA_PILL |
Decorative closed-beta / system pill |
TAG_CHIP |
Decorative non-clickable tag chip (neutral) |
If you're writing a class string longer than ~3 Tailwind tokens for an orange element, a constant probably already fits.
What each colour says¶
| Looks like | Means |
|---|---|
| Plain orange text, underlined on hover | Inline link, click it |
| Card border turns orange on hover | Whole card is clickable |
| Outlined orange button | Primary action |
| Tinted orange background + orange text | Currently selected / active state |
| Neutral grey chip | Decorative tag, not interactive |
Bright bg-orange-500 flat fill |
Map point or 1.5 px state dot — never a button |
| Bright red filled | Destructive — proceed with caution |
| Neutral grey × or ← | Navigation chrome — close / back |
Map¶
- Style: CARTO Dark Matter (with labels)
- URL:
https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json - Renderer: MapLibre GL JS (vector tiles) with globe projection
- Map labels (cities, regions) are discreet light-gray
Typography¶
- Font: system stack —
-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif - Sizes:
- Titles:
text-lg(18px) max - Body:
text-sm(14px) - Labels / meta:
text-xs(12px) - Micro (counters, badges):
text-[11px] - Weights:
font-mediumfor titles,font-normalfor everything else
Layout¶
Structure¶
┌─────────────────────────────────────────────────┐
│ Top bar (minimal, floating, centered) │
├──────────┬──────────────────────┬───────────────┤
│ Filters │ │ Detail │
│ panel │ MAP │ panel │
│ (left) │ (full screen) │ (right) │
│ │ │ on click │
└──────────┴──────────────────────┴───────────────┘
- Map: full-screen background
- Top bar: floating, centered — logo + essential actions only
- Left panel: filters, opaque, fixed position
- Right panel: event detail, appears on click, dismissible
Panels¶
- Background:
neutral-900opaque (no glass / blur) - Border:
border neutral-700 - Corners:
rounded-lg(8px) - Padding:
p-4 - Floating above the map (no full-height sidebar)
- Width: ~240px (filters), ~380px (detail)
Components¶
Links and clickable surfaces¶
Orange = clickable; see the Orange palette recipe for the five buckets and constants. Carve-outs: navigation chrome stays neutral grey, destructive actions go red. External links open in a new tab (target="_blank" rel="noopener noreferrer") with the same orange styling.
Map points¶
- Default radius: 6px
- Selected radius: 7px + 2px white border
- Color:
orange-500(#f97316) - Opacity: 1.0 (individual points), 0.85 (clusters)
- Pointer cursor on hover
Filters¶
- Labels:
text-[10px] uppercase tracking-wider text-neutral-500 - Inputs:
bg-neutral-800 border-neutral-700 text-neutral-300 - Focus:
border-orange-500 - Active filter tags/buttons:
FILTER_CHIP_ACTIVE(tinted orange — see the Orange palette recipe) - Inactive filter tags/buttons:
FILTER_CHIP_INACTIVE - Point counter at the top of the panel
- "Clear all" button shows up only if filters are active
Detail panel¶
- Title:
text-lg font-medium text-neutral-100 - Metadata:
text-xs text-neutral-400 - Tags: compact badges via the shared
TAG_CHIPconstant (bg-neutral-800 text-neutral-400) — see the Orange palette recipe (decorative tag chip) - Source link:
text-orange-400 hover:underline - Proof:
text-sm text-neutral-300 leading-relaxed - Separator border:
border-neutral-800
Page chrome¶
Every main-app page uses the shared <PageShell> wrapper, which owns the title / subtitle / back slots:
| Element | Style | Notes |
|---|---|---|
| Column | max-w-4xl mx-auto px-6 pt-10 pb-16 space-y-6 |
One width across the app — content, forms, detail, profile, admin. |
H1 (title) |
text-xl font-medium text-neutral-100 |
Page chrome, consistent on every page. |
Subtitle (subtitle) |
text-sm text-neutral-400 |
Tight under the H1 (8 px gap). |
Back arrow (back) |
absolute right-full top-1.5 mr-3 text-neutral-400 hover:text-neutral-200 |
Lives in the gutter so the title sits at the same column-edge x-coordinate whether back is present or not. |
Loading / error / empty pre-data states use the sibling <PageCenter> (min-h-screen flex items-center justify-center pl-14). Pages that legitimately opt out: / (the public landing) and /map (the full-screen map), the (auth)/* route group, and app/error.tsx (the React Error Boundary lives outside the page tree).
The (auth)/* group composes <AuthCard> instead — the max-w-sm centered dark card owning the optional icon / title (text-lg H1) / subtitle / footer slots. The two single-email request pages (/forgot-password, /resend-confirmation) additionally share <SingleEmailFlow>, the idle → sending → sent | failed email form; its sent-state copy must stay anti-enumeration ("if X is registered…", never confirming the address exists).
Buttons¶
- Primary CTA:
PRIMARY_BUTTONconstant — soft-fill outlined orange. See the Orange palette recipe. - Secondary:
bg-neutral-800 border border-neutral-700 text-neutral-300— secondary actions. - Ghost:
text-neutral-500 hover:text-neutral-300— tertiary actions (close, clear). - Compact size:
px-3 py-1.5 text-sm rounded-md.
Work-in-progress affordances¶
For features visible to testers but not yet built:
WipBadge— small white-on-dark pill, default textComing soon. Passchildrento override (the sidebar nav usesSoonfor compactness).- Sidebar nav items opt into a
wipflag (Soonpill in expanded mode) and a separatenotifyflag (orange dot in collapsed mode for "new content awaits"). - For inline placeholders next to author handles or in panel headers, prefer a dedicated atom like
profile/TrustBadge.tsx.
What we avoid¶
- Heavy glow, neon, pulse effects
- Gradients
- Glass / blur
- Decorative icons
- Long or showy animations
- Too many distinct colors — orange is the single accent
- Information overload on the default view