Skip to content

Latest commit

 

History

History
101 lines (72 loc) · 12.7 KB

File metadata and controls

101 lines (72 loc) · 12.7 KB

Design System

How Aplio's UI should look and behave. Read this before building or changing any UI. The single most important rule:

IMPORTANT: never hardcode colors, radii, or font families. Always use the semantic Tailwind tokens / CSS variables defined in app/globals.css. A literal hex, rgb(), or raw oklch() in a component is a bug. If you need a value that doesn't exist as a token, add a token to globals.css first.

Exception: app/manifest.ts. A web app manifest is static JSON and can't read CSS custom properties, so theme_color/background_color are the one sanctioned place for a literal hex.

Stack: shadcn/ui new-york style, zinc base color, OKLCH colors with light + .dark themes, lucide icons, RSC enabled. Config in components.json; tokens in app/globals.css.

Platform icons (app/apple-icon.png, public/icon-*.png) are committed PNG rasters exported by hand from public/logo-dark.svg — re-export them if the logo changes.

1. Theme & atmosphere

Clean, neutral, content-first administrative UI. Near-white/near-black neutral surfaces (zinc, zero chroma) with a single saturated red brand accent (hue ~27). Generous radius (--radius: 0.75rem) gives a soft, modern feel. Light and dark are first-class — every surface must read correctly in both.

2. Color tokens & their roles

Use the Tailwind utility that maps to each variable (e.g. bg-background, text-muted-foreground, border-border, ring-ring). Pair each background with its -foreground.

Token (utility) Role
background / foreground Page base surface and primary text
card / card-foreground Cards, raised panels
popover / popover-foreground Popovers, dropdowns, menus
primary / primary-foreground Brand red — primary buttons, key emphasis, active states
secondary / secondary-foreground Low-emphasis buttons / chips
accent / accent-foreground Hover/active fills on neutral controls
muted / muted-foreground Subtle backgrounds; secondary/help text (muted-foreground)
destructive Errors, destructive actions (red, higher chroma)
border / input / ring Borders, input borders, focus rings
success / warning / info (+ -foreground) Project-specific status colors — use these for status, never raw green/amber/blue
sidebar* Sidebar surface, text, primary, accent, border, ring
nav-hover Hover fill for nav items
header-bg / header-border App header surface and divider
chart-1chart-5 Data-viz series (in order)

Status mapping: success = positive/complete, warning = needs attention, info = neutral notice, destructive = error/danger. Don't invent new status colors.

Any change to a brand/status token must keep ≥4.5:1 contrast against its paired -foreground in both themes, verified in-browser.

3. Typography

  • Font family comes from --font-sans (set in the root layout) — use the font-sans utility; never name a font in a component.
  • Hierarchy with Tailwind scale: page title text-2xl font-semibold, section text-lg font-semibold, body text-sm, helper/caption text-xs text-muted-foreground. Keep headings hierarchical (h1h2, no skips).
  • Heading capitalisation: Title Case. Applies to PageHeader title, SectionCard title, <h2>/<h3> that name a section, and CardTitle used as a section label. Capitalise the first and last word plus every major word; lowercase, when internal only, articles (a, an, the), coordinating conjunctions (and, but, or, nor, so, yet), and prepositions of four letters or fewer (at, by, for, in, of, on, to, up, via, with). Determiners and pronouns are major words — hence "Your Answers for This Position". Does not apply to (all sentence case): dialog/sheet/popover titles, buttons, dropdown and nav items, form labels and placeholders, EmptyState / SectionCardEmpty titles and descriptions, SectionCard subtitle, helper text, toasts, and table filter labels (table column headers are already Title Case and stay so). A heading that is a statement or question to the user, not a label, stays sentence case (e.g. "You've already applied"). Do it in the string literal — never capitalize / uppercase in CSS.

4. Radius, spacing, layout

  • Radius: --radius: 0.75rem. Use rounded-md/rounded-lg/rounded-xl (derived from the scale); don't use arbitrary radii. Inputs/buttons/cards inherit the shadcn defaults.

  • Spacing: Tailwind 4-point scale. Card padding p-6 (compact p-4); stack gaps gap-4/gap-6; form field gap gap-2. Be consistent rather than pixel-tuning.

  • Page width tiers: every route inside the app shell picks exactly one tier for its top-level container. app-shell.tsx supplies only p-6, so the page owns the width.

    Tier Container classes Use for
    Full-bleed none (no max-w) list, table and dashboard pages — /, /positions, /manage/positions, /users, /manage/applications, /applications, /global-questions
    Wide mx-auto max-w-5xl two-column review/detail pages — /manage/applications/[id], /applications/[id]
    Narrow mx-auto max-w-2xl single-column forms and reading views — /profile, /positions/[id]/apply, /manage/positions/[id]/edit

    There is no fourth tier: max-w-6xl/4xl/3xl on a page container is a bug. Inside a full-bleed page, constraining an individual prose block (max-w-2xl on a description) is correct and not a tier violation — see /positions/[id]. A route's loading.tsx must use the same tier as its page.tsx, or the skeleton shifts on resolve. Routes outside the app shell ((legal), login) set their own width.

5. Components

  • Compose shadcn primitives from @/components/ui — don't rebuild buttons, inputs, dialogs, etc. Add new ones with the shadcn CLI so they pick up the configured tokens. Icons from lucide-react.
  • Buttons: primary = primary; secondary/cancel = secondary or variant="outline"; destructive = variant="destructive". One primary action per view.
  • Forms: use the shadcn Form/FieldGroup primitives with FormLabel; every input has an associated label; surface validation with data-invalid / inline FormMessage, not ad-hoc text. (Form wiring rules: ENGINEERING.md §4.)
  • State surfaces: all three required (ENGINEERING.md §4) — <Suspense> + skeleton for loading, error.tsx/inline error for failure, a designed empty state (icon + one line + primary action) for zero items. Secondary cards on a detail page may opt into SectionCardEmpty's compact variant (one muted line, no icon/action) instead — the roomy form stays the default for dashboards.
  • Focus & overlays: never outline-none without a visible replacement; rely on Radix focus trapping in dialogs/sheets — don't break it with custom wrappers.

6. Iconography

The vocabulary lives in lib/icons.ts as five separately-exported Record<Enum, LucideIcon> maps — CONCEPT_ICONS, APPLICATION_STATUS_ICONS, POSITION_AVAILABILITY_ICONS / POSITION_STATUS_ICONS, ACTION_ICONS, STATE_ICONS, FILE_TYPE_ICONS. Five exports, not one object, so a consumer importing ACTION_ICONS doesn't pull the status icons into its bundle.

  • The vocabulary is the allow-list. Import icons from @/lib/icons, never lucide-react, outside lib/icons.ts and shadcn-generated components/ui/*. No meaning in a map → no icon.
  • One concept, one icon, everywhere. A concept's icon is identical in nav, empty state, section heading and button.
  • Look the icon up inside the component that renders it. A LucideIcon is a function and is not serializable, so it must never be a prop crossing a server → client boundary; derive it from the enum value or the concept key locally. (EmptyState / SectionCardEmpty / WarningCallout's icon props are safe only because both sides sit in the same environment — keep it that way.)
  • Never set a size class on an icon inside a shadcn primitive. components/ui/button.tsx and DropdownMenuItem already carry [&_svg:not([class*='size-'])]:size-4; a className="size-4" there is dead weight. Elsewhere: size-4 inline with text, size-5 for a banner/trigger, size-10 in a card-section empty state, size-12 in a full EmptyState, size-3 in a badge, size-3.5 for a table-header sort affordance. Always size-N, never h-N w-N.
  • Never write aria-hidden on a lucide icon. lucide-react emits aria-hidden="true" itself unless an a11y prop is present. An icon-only control gets aria-label on the control, not on the icon, and never both an aria-label and an sr-only span.
  • Tint: text-muted-foreground by default; text-destructive / text-warning / text-success / text-info only where the icon carries that semantic. Never a hardcoded colour. Inside a coloured Badge the icon inherits the paired -foreground — set no tint.
  • Icons never carry meaning alone. Every icon sits beside a text label or an aria-label; colour + shape is a redundancy for WCAG 1.4.1, never the only channel.
  • Icon-free by rule: page titles (PageHeader h1), form labels and FormMessage, table body cells, data/value chips, role badges (Admin / Manager), and the (legal) prose pages plus markdown.tsx (user-authored content).
  • shadcn CLI output is exempt. checkbox dialog dropdown-menu input-otp pagination radio-group select sheet keep the CLI's emitted names (XIcon, CheckIcon, MoreHorizontalIcon, …) so re-running shadcn add produces no diff. Every other components/ui/* file is hand-written and bound by these rules.

7. Do / Don't

  • className="bg-card text-card-foreground border-border rounded-lg"style={{ background: '#fff' }} / bg-[#fff] / rounded-[12px]
  • text-muted-foreground for secondary text ❌ text-gray-500
  • text-success / bg-warningtext-green-600 / bg-amber-500
  • ✅ icons via @/lib/icons ❌ importing lucide-react directly outside lib/icons.ts / shadcn output
  • ✅ verify both themes ❌ values that only work in light mode
  • ✅ Title Case section heading ❌ className="capitalize"

8. Responsive

Mobile-first (per CLAUDE.md): base styles target mobile, layer md:/lg: upward. Sidebars collapse to a Sheet/drawer with a hamburger trigger below md. Touch targets ≥ ~44px. No fixed pixel widths that break narrow viewports. Test at 375px, 768px, 1280px.

Mobile cards carry no placeholders. A reads as "none" only directly beneath its column header. A DataTable mobile card (below md) has no headers, so a field with no value is omitted from the card, never rendered as a dash. Keep the placeholder in the desktop column's cell — never inside a child component both layouts render.

9. Agent quick reference

Surfaces → bg-background (page), bg-card (panels), bg-popover (menus). Text → text-foreground (primary), text-muted-foreground (secondary). Brand → primary. Status → success/warning/info/destructive. Lines → border-border, focus → ring-ring. Radius → rounded-lg. Icons → @/lib/icons, never lucide-react directly. Never hardcode any of these.