Skip to content

DentalPin Design System

This document is the single source of truth for visual design in DentalPin. It defines tokens, components and patterns that any contributor (human or agent) must follow when adding or modifying UI.

DentalPin is clinical software used by dentists, hygienists, assistants and receptionists for 8+ hour shifts. The design system optimises for calm, legibility and clinical safety in that order. Aesthetics serve those goals — they never override them.

Companion docs

  • Implementation plan: docs/technical/ui-redesign-calm.md
  • Module / feature UX briefs: docs/features/*.md
  • Module / feature tech plans: docs/technical/*.md

1. Principles

These are ordered. When two principles conflict, the higher one wins.

  1. Clinical signal is sacred — but calm. Alerts, statuses and patient-critical data are always identifiable, with WCAG AA contrast or better. They render in a pastel pattern (soft tinted background + bold semantic text + saturated icon and accent rail), not as walls of saturated colour. The signal is preserved through the icon, the rail and the text colour; the fill is calm.
  2. Calm by subtraction. Remove a border, a shadow or a saturated background before adding one. The interface should disappear so clinical content can breathe.
  3. One saturated chrome colour. Primary blue. All semantic colours (success, info, warning, danger) render in soft variants by default. Saturated accent colour is reserved for icons, 2–3 px rails, 1 px borders and destructive action buttons — never for large fills.
  4. Adjustable density. Comfortable for forms and reading; compact for dense clinical surfaces (odontogram, calendar, treatment lists). User-controlled, persisted.
  5. Vertical rhythm: generous between blocks, denser within. Cards don't drown in interior padding, but they sit in space.
  6. Motion is meaningful or absent. Transitions ≤150 ms, only on state change (hover, expand, focus). No decorative animation. Respect prefers-reduced-motion.
  7. Warm neutrals, never blue-grey. Greys carry yellow-brown undertones in both light and dark modes. Reduces visual fatigue across long shifts.
  8. Readability is non-negotiable. WCAG AA minimum, AAA where it costs nothing. Clinically distinguishable glyphs (1/l/I, 0/O) via Inter's cv11 feature. Tabular numerals for amounts, doses, times and FDI tooth numbers.

2. Colour System

All colours are exposed as CSS custom properties on :root (light) and .dark (dark). Components reference them via Tailwind classes (Nuxt UI 4) or directly via var(--token). Never hardcode hex in components.

2.1 Surfaces and text — Light mode

TokenValueRole
--color-canvas#FBFAF8Page background (warm off-white)
--color-surface#FFFFFFCards, modals, popovers
--color-surface-muted#F4F2EFAlternating rows, sidebar, grouped sections
--color-surface-sunken#EFEDE9Inputs, code blocks, inactive areas
--color-border-subtlergba(15, 17, 22, 0.06)Dividers between rows
--color-borderrgba(15, 17, 22, 0.10)Card borders, input borders
--color-border-strongrgba(15, 17, 22, 0.16)Hover borders, soft focus
--color-textrgba(15, 17, 22, 0.92)Body and headings
--color-text-muted#615D59Secondary text, descriptions
--color-text-subtle#94908APlaceholders, metadata
--color-text-disabled#BAB6B0Disabled states

2.2 Surfaces and text — Dark mode

Inspired by Linear/Height. Surfaces get lighter as elevation increases (the inverse of light mode). This communicates depth without shadows, which barely register in dark UIs. Surfaces are warm dark, not blue-black.

TokenValueRole
--color-canvas#161513Page background
--color-surface#1E1C19Cards, modals, sidebar
--color-surface-muted#252320Alternating rows
--color-surface-sunken#121110Inputs, inactive areas
--color-border-subtlergba(255, 250, 240, 0.06)Dividers
--color-borderrgba(255, 250, 240, 0.10)Borders
--color-border-strongrgba(255, 250, 240, 0.18)Hover, soft focus
--color-textrgba(255, 250, 240, 0.92)Body
--color-text-muted#A39E97Secondary
--color-text-subtle#787570Placeholder
--color-text-disabled#525049Disabled

2.3 Primary

Tailwind sky family, with the 500 used as the canonical primary in light and the 400 in dark (compensates for reduced colour vivacity on dark backgrounds).

TokenLightDarkUse
--color-primary#0EA5E9 (sky-500)#38BDF8 (sky-400)Primary CTAs, links, selection rings
--color-primary-hover#0284C7 (sky-600)#7DD3FC (sky-300)Hover state
--color-primary-soft#E0F2FE (sky-100)rgba(56,189,248,0.12)Active nav pill, highlight backgrounds
--color-primary-soft-text#0369A1 (sky-700)#7DD3FC (sky-300)Text on --color-primary-soft

2.4 Semantic colours

Each semantic role has three tokens:

  • soft — pastel tinted background. Used for fills (badges, banners, alert backgrounds, validation field tint).
  • text — semantic text and label colour. Chosen so it meets WCAG AA (≥ 4.5:1) against its soft background. AAA where it costs nothing.
  • accent — fully saturated semantic colour. Used for icons, 2–3 px rails, 1 px borders, destructive action buttons, and the inner colour of focus rings on semantic inputs. Never as a large fill.

The pastel pattern is uniform across all four semantics. The clinical urgency of an alert is communicated by the icon, the accent rail and (for the most critical surfaces) a 1 px border in accent — not by saturating the fill. This keeps the UI calm during long shifts while preserving identification under a glance.

RoleAccent (light)Accent (dark)Soft bg (light)Soft bg (dark)Text (light)Text (dark)Use
success#0F9D58#34D399#E6F6ECrgba(52,211,153,0.10)#15803D#86EFACPaid, completed, performed
info#0284C7#7DD3FC#E0F2FErgba(125,211,252,0.10)#075985#BAE6FDInformational badges
warning#D97706#F59E0B#FEF3E2rgba(245,158,11,0.12)#9A3412#FCD34DPending, expiring plan, scheduling overlap
danger#DC2626#F87171#FEE7E7rgba(248,113,113,0.12)#991B1B#FCA5A5Allergies, contraindications, conflicts, deletion, billing errors, validation errors

Alert surface anatomy (applies to PatientAlertsBanner, conflict cells, validation messages, destructive confirmations):

┌─[ rail 3px in --color-{role}-accent ]─────────────────────┐
│                                                            │
│  [icon in accent]  Title in --color-{role}-text (semibold) │
│                    Body in --color-{role}-text (regular)   │
│                                                            │
└─ background: --color-{role}-soft  ─────────────────────────┘

For the most clinically critical surface (allergies banner, scheduling conflict block) add a 1 px outer border in --color-{role}-accent for a slightly stronger frame. Still no saturated fill.

2.5 Professional colours (calendar)

Per-professional colours are assigned in useProfessionals(). Calendar blocks render the fill at alpha 0.12 with a 3 px solid left border in the professional's full colour. This communicates ownership without saturating the daily view.


3. Typography

3.1 Font

  • Family: Inter Variable, auto-hosted via @fontsource-variable/inter (no CDN). Fallback chain: -apple-system, system-ui, "Segoe UI", Helvetica, Arial.
  • Weights loaded: 400, 500, 600, 700 (variable axis covers all).
  • OpenType features (global):
    • "cv11" — alternate 1/l/I to disambiguate in clinical data.
    • "ss01" — single-storey a (cleaner at small sizes).
    • "tnum" — tabular numerals so amounts, doses, times and FDI tooth numbers align in tables.

3.2 Scale

TokenSizeLine-heightTrackingWeightUse
--text-display28 px1.15-0.4 px700Page titles, KPI numerals
--text-h122 px1.25-0.25 px700Modal titles, section heads
--text-h218 px1.30-0.15 px600Sub-sections, card headers
--text-h315 px1.35normal600Mini panel headers
--text-body14 px1.50normal400Default body
--text-body-prose15 px1.55normal400Clinical notes, anamnesis (long-form reading)
--text-ui14 px1.30normal500Form labels, nav items
--text-button14 px1.20normal600Buttons
--text-caption12 px1.400.1 px500Metadata, timestamps, badges
--text-micro11 px1.300.2 px600Dense odontogram tags

Why not 16 px body? A 16 px body inflates tables, treatment lists and calendar blocks. We default to 14 px and step up to 15 px specifically for reading-prose contexts (notes, anamnesis).

3.3 i18n

Spanish copy runs ~20% longer than English. Apply:

  • text-wrap: pretty on headings and short labels.
  • Flexible min-width on nav items — never fix widths that fit only English.
  • Avoid text-overflow: ellipsis on patient names and treatments unless the full string is exposed in a tooltip.

4. Spacing, Radius and Elevation

4.1 Spacing scale

Base 4 px. Use Tailwind's default scale: 0, 1 (4), 2 (8), 3 (12), 4 (16), 5 (20), 6 (24), 8 (32), 10 (40), 12 (48), 16 (64).

Vertical rhythm guidance:

  • Between page sections: 32–48 px.
  • Between cards in a grid: 16–24 px.
  • Inside a card: 16 px (comfortable) / 12 px (compact).
  • Between form fields: 16 px (comfortable) / 12 px (compact).

4.2 Radius

TokenValueUse
--radius-xs4 pxInline inputs, small buttons
--radius-sm6 pxStandard buttons, chips
--radius-md8 pxForm inputs, list items
--radius-lg12 pxStandard cards
--radius-xl16 pxModals, featured panels
--radius-pill9999 pxBadges, avatars, status pills

4.3 Elevation

Light mode uses stacked low-opacity shadows. Dark mode uses surface colour to communicate elevation; shadows are disabled.

css
:root {
  --shadow-xs: 0 1px 1px rgba(15,17,22,0.02);
  --shadow-sm: 0 1px 2px rgba(15,17,22,0.03), 0 1px 1px rgba(15,17,22,0.02);
  --shadow-md: 0 4px 12px rgba(15,17,22,0.04), 0 1px 3px rgba(15,17,22,0.03);
  --shadow-lg: 0 14px 28px rgba(15,17,22,0.05), 0 4px 12px rgba(15,17,22,0.03);
}

.dark {
  --shadow-xs: none;
  --shadow-sm: none;
  --shadow-md: none;
  --shadow-lg: none;
}
LevelLight treatmentDark treatmentUse
FlatNoneNonePage background, text blocks
Whisper1px solid var(--color-border)SameCard outlines, dividers
Soft--shadow-smSurface = --color-surface (one step up from canvas)Standard cards
Medium--shadow-mdSurface = --color-surface-mutedPopovers, dropdowns
Deep--shadow-lg + borderSurface = --color-surface + borderModals

5. Density Modes

Two global modes, persisted in localStorage as ui:density = comfortable | compact, applied as a class on <html> and exposed by the useDensity() composable.

DensityCard paddingTable rowButton paddingBody size
comfortable (default)16 / 20 px44 px8 / 14 px14 px
compact10 / 14 px32 px6 / 10 px13 px

Forced compact (regardless of preference): OdontogramChart, AppointmentDailyView, AppointmentCalendar, TreatmentListSection. These views are inherently dense.

Forced comfortable: MedicalHistoryForm, PatientQuickInfo, dashboard (pages/index.vue), EmergencyContactForm, LegalGuardianForm, AppointmentModal.

Mobile/tablet override: tap targets must stay ≥ 44 px. On < 1024 px viewports, comfortable is forced regardless of user preference.


6. Components

Components in DentalPin are built on Nuxt UI 4 (Radix Vue under the hood). Customisation happens centrally via app.config.ts, not per-call.

6.1 Buttons (UButton)

VariantBackgroundTextBorderUse
solid (primary)--color-primarywhitenonePrimary CTA — one per view
soft--color-primary-soft--color-primary-soft-textnoneSecondary actions
outlinetransparent--color-text--color-borderTertiary actions
ghosttransparent--color-textnoneIn-row actions, icon buttons
linktransparent--color-primarynoneInline navigation
  • Sizing: sm is the default (14 px text, 8/14 px padding). xs for in-row actions in tables.
  • Radius: --radius-sm.
  • Hover: background shifts one step (e.g. --color-primary--color-primary-hover).
  • Active: no scale transform (avoid micro-motion in clinical UI).
  • Focus: 2 px ring in --color-primary, 2 px offset.
  • Loading: spinner replaces leading icon, button stays the same width (no layout shift).

Permission-gated buttons follow the same defaults — gate with v-if="can(PERMISSIONS.x.y)" on a plain UButton.

6.2 Cards (UCard)

  • Background: --color-surface.
  • Border: 1px solid var(--color-border).
  • Shadow: --shadow-sm (light); none (dark — relies on surface colour).
  • Radius: --radius-lg (12 px).
  • Header padding: 16 / 20 px (comfortable), 10 / 14 px (compact).
  • No header divider unless visually necessary; rely on weight/size hierarchy.

6.3 Inputs and Forms

  • Background: --color-surface-sunken.
  • Border: 1px solid var(--color-border).
  • Radius: --radius-md.
  • Focus: border becomes --color-primary, ring 2 px in --color-primary.
  • Placeholder: --color-text-subtle.
  • Labels: --text-ui, sit above field with 6 px gap.
  • Help/error text: --text-caption, sits below with 4 px gap.
  • Required marker: small red dot after label; never an asterisk in superscript (legibility).

6.4 Badges (UBadge)

  • Default and only variant: soft — filled with --color-{role}-soft background and --color-{role}-text text. Same pattern across success, info, warning, danger.
  • For badges that need extra emphasis (e.g., "Vencido", "Conflicto", "Validation error") add a small leading dot or icon in --color-{role}-accent. Do not swap the fill to a saturated colour.
  • Radius: --radius-pill.
  • Padding: 2 / 8 px.
  • Font: --text-caption.

6.5 Modals (UModal)

  • Surface: --color-surface.
  • Radius: --radius-xl.
  • Border: 1px solid var(--color-border).
  • Shadow: --shadow-lg (light); none + border (dark).
  • Backdrop: rgba(15, 17, 22, 0.40) (light), rgba(0, 0, 0, 0.55) (dark).
  • Width: 480 px (form), 560 px (form with sections), 720 px (data-heavy).
  • Close button: top-right, ghost variant.
  • Focus trap: enforced, returns focus to trigger on close.

6.6 Tables

  • No outer border. Outer separation comes from the surrounding card.
  • Row separator: 1px solid var(--color-border-subtle).
  • Header row: --text-caption, weight 600, colour --color-text-muted, no background, no top border.
  • Hover row: --color-surface-muted.
  • Selected row: --color-primary-soft.
  • Numeric columns: right-aligned, tnum active.
  • Action column: ghost icon buttons, right-aligned, only visible on row hover (desktop) or always (touch).

6.7 Navigation (sidebar in layouts/default.vue)

  • Background: --color-surface-muted.
  • No border-right (separation by colour, not by line).
  • Item: 8 / 12 px padding, --radius-md, --text-ui.
  • Hover item: --color-surface.
  • Active item: --color-primary-soft background, --color-primary-soft-text text and icon. No fully saturated bg-primary-500 — too loud in long sessions.
  • Collapsed sidebar: 56 px width, only icons, tooltips on hover.

6.8 Header (top bar)

  • Background: --color-surface.
  • Bottom border: 1px solid var(--color-border-subtle).
  • Height: 56 px.
  • Houses: sidebar toggle, clinic name, density toggle, colour-mode toggle, logout.

6.9 Empty states

Use the shared EmptyState component. Pattern:

  • Icon (32 px, --color-text-subtle).
  • Title (--text-h2).
  • Description (--text-body, --color-text-muted).
  • Single primary action.

6.10 Page header

Use the shared PageHeader component with slots title, subtitle, actions, tabs. Standardises spacing and typography across all pages.


7. Module-Specific Guidance

7.1 Patient alerts (PatientAlertsBanner)

This banner communicates allergies, contraindications and ASA status. Use the alert surface anatomy from §2.4:

  • Background: --color-danger-soft (allergies, contraindications) or --color-warning-soft (cautions, ASA III/IV pre-op notes).
  • Text: --color-{role}-text (≥ 4.5:1 against the soft background).
  • Icon and 3 px left rail: --color-{role}-accent.
  • 1 px outer border in --color-{role}-accent for allergies and absolute contraindications (highest clinical urgency).
  • Always icon + text + colour — never colour alone.
  • Sticky to the top of the patient view; collapses to a one-line indicator on scroll, keeping the icon and rail visible.

This delivers immediate recognition without the wall-of-red effect that wears down clinicians across an 8-hour shift.

7.2 Odontogram

The odontogram has its own CSS variables (legacy, in main.css). They must be derived from the new tokens, not redefined:

Odontogram varLight sourceDark source
--odontogram-bg--color-surface-muted--color-surface-muted
--odontogram-fill--color-surface--color-surface
--odontogram-fill-shade--color-surface-sunken--color-surface-sunken
--odontogram-outlinergba(15,17,22,0.55)rgba(255,250,240,0.55)
--odontogram-detailrgba(15,17,22,0.20)rgba(255,250,240,0.20)
--odontogram-selected--color-primary--color-primary

Treatment colour codes in TreatmentIcons.ts carry clinical meaning. Do not change them. Verify each maintains 4.5:1 contrast against the new fill in both modes; if any fails, adjust the icon colour, not the importance.

Force compact density. Tooltip uses --shadow-md and a whisper border.

7.3 Calendar (weekly + daily)

  • Force compact density.
  • Grid lines: --color-border-subtle.
  • Appointment block: fill = professional colour at alpha 0.12, left border 3 px in full professional colour.
  • cancelled: opacity 0.5 + strikethrough on the title. Not a red fill (it's a state, not an alert).
  • conflict / overlap: alert surface — fill --color-danger-soft at low alpha overlay on the existing professional fill, 1 px outer border in --color-danger-accent, small warning icon in --color-danger-accent top-right. Identifiable as an alert without saturating the time slot.
  • Drag preview: dashed border in --color-primary.

7.4 Treatment lists, plans, budgets

  • All status badges use the soft pattern (no bold fills). For "Vencido" (overdue) and validation errors, add a leading dot/icon in --color-danger-accent instead of swapping the fill.
  • Inline form validation errors: tinted field background --color-danger-soft (alpha low), border 1 px in --color-danger-accent, helper text below in --color-danger-text.
  • Amounts and totals: right-aligned, tnum, weight 600 for the total row.
  • Multi-tooth treatment groups: subtle left rail in --color-primary-soft to communicate grouping without a heavy border.

7.5 Login (pages/login.vue)

  • Centred card, max-width 400 px.
  • No decorative background. --color-canvas everywhere.
  • Logo + product name above the card.
  • Single primary button, soft "forgot password" link below.

8. Accessibility (WCAG AA, clinical floor)

Non-negotiable:

  • Contrast: body text 4.5:1, large text 3:1, UI components 3:1, in both modes.
  • Focus visible: 2 px ring + 2 px offset on every interactive element. Never outline: none without a replacement.
  • Hit targets: ≥ 44 × 44 px on mobile/tablet (gabinete is often used on tablet).
  • Motion: @media (prefers-reduced-motion: reduce) { *, *::before, *::after { transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; } }.
  • Semantic HTML: <button> for actions, <a> for navigation, <nav>, <main>, <aside>, <section>. One <h1> per page.
  • Keyboard navigation: every flow operable without a mouse. No focus traps outside modals.
  • Colour is never the only signal: alerts always combine icon + text + colour.
  • Screen readers: all icon-only buttons have aria-label (or visible label). Status badges use role="status" for announcements when state changes.

9. Motion

  • Allowed transitions: background-color, border-color, color, opacity, transform, box-shadow.
  • Default duration: 150 ms.
  • Default easing: cubic-bezier(0.2, 0, 0.2, 1) (standard easing).
  • Forbidden: scale transforms on click, parallax, decorative loops, autoplay.
  • All transitions disabled under prefers-reduced-motion: reduce.

10. Implementation Reference

10.1 Files

  • frontend/app/assets/css/main.css — token definitions (:root + .dark), font loading, base resets.
  • frontend/app/assets/css/typography.css — typography utilities (.text-display, .text-h1, …) and font-feature-settings.
  • frontend/app/app.config.ts — Nuxt UI theme: primary: 'sky', neutral: 'stone', component slot overrides.
  • frontend/nuxt.config.ts — register @fontsource-variable/inter.
  • frontend/app/composables/useDensity.ts — density toggle + persistence.
  • frontend/app/components/shared/DensityToggle.vue — UI control in header.
  • frontend/app/components/shared/PageHeader.vue — standard page header.
  • frontend/app/components/shared/EmptyState.vue — standard empty state.

10.2 Nuxt UI configuration

ts
// app.config.ts
export default defineAppConfig({
  ui: {
    colors: { primary: 'sky', neutral: 'stone' },
    button: {
      defaultVariants: { size: 'sm', color: 'neutral', variant: 'soft' }
    },
    card: {
      slots: {
        root: 'bg-[var(--color-surface)] ring-1 ring-[var(--color-border)] shadow-[var(--shadow-sm)] rounded-[var(--radius-lg)]'
      }
    },
    badge: {
      defaultVariants: { variant: 'soft', size: 'sm' }
    }
  }
})

10.3 Tailwind / Nuxt UI mapping

Prefer Tailwind utility classes that already point at our tokens via Nuxt UI's --ui-* variables. When a component needs a token directly, use arbitrary value syntax: bg-[var(--color-surface-muted)].


11. Agent Prompt Guide

Quick reference for AI contributors:

Colour choices

  • Page background → --color-canvas.
  • Card / modal background → --color-surface.
  • Sidebar / alternating row → --color-surface-muted.
  • Input background → --color-surface-sunken.
  • Primary text → --color-text. Secondary → --color-text-muted. Hint → --color-text-subtle.
  • Border → --color-border. Divider → --color-border-subtle.
  • Primary action → --color-primary.
  • Status badges and alerts → always semantic soft fill + text + accent (icon/rail). Same pattern across success, info, warning, danger.
  • Destructive action button → solid variant in --color-danger-accent. This is the only place danger appears as a large fill.

Typography choices

  • Page title → --text-display weight 700.
  • Card header → --text-h2 weight 600.
  • Table cell, list item → --text-body (14 px).
  • Long-form notes → --text-body-prose (15 px).
  • Badge, timestamp → --text-caption (12 px).
  • Always activate tnum on numeric columns.

Component defaults

  • Buttons → size: 'sm', variant: 'soft', color: 'neutral'. Use solid + primary only for the single primary CTA per view.
  • Cards → no extra border, rely on --shadow-sm (light) or surface step (dark).
  • Modals → 480 px (form), 720 px (data).
  • Empty states → use EmptyState component.
  • Page wrappers → use PageHeader component.

Forbidden patterns

  • Hardcoded hex colours in components.
  • bg-white, bg-gray-*, bg-slate-* directly. Use tokens.
  • text-3xl, text-2xl for headings. Use typography utilities.
  • Decorative animations or scale transforms.
  • Saturated accent colour as a large fill (banner/cell/badge background). The only fills allowed for semantics are --color-{role}-soft. Saturated accent is for icons, rails (2–3 px), borders (1 px) and destructive buttons.
  • Dropping the icon or rail from an alert "to clean it up". The icon and rail carry the semantic identity once the fill is pastel.
  • Loud saturated nav backgrounds (use --color-primary-soft, not bg-primary-500).
  • outline: none without a focus replacement.

Iteration checklist

When adding or modifying a view:

  1. Does any clinical signal lose contrast or stop being identifiable at a glance? → Revert. (Pastel fill is fine; missing icon/rail or text contrast below 4.5:1 is not.)
  2. Did I add a border or shadow I could have replaced with spacing? → Remove.
  3. Did I introduce a new colour outside the token set? → Use a token.
  4. Does the view work at both densities? → Test both, force one if dense.
  5. Does it pass WCAG AA in light and dark? → Run contrast check.
  6. Does it respect prefers-reduced-motion? → Test.
  7. Does Spanish copy fit? → Test with the longest realistic strings.