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.
- 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.
- Calm by subtraction. Remove a border, a shadow or a saturated background before adding one. The interface should disappear so clinical content can breathe.
- One saturated chrome colour. Primary blue. All semantic colours (success, info, warning, danger) render in soft variants by default. Saturated
accentcolour is reserved for icons, 2–3 px rails, 1 px borders and destructive action buttons — never for large fills. - Adjustable density. Comfortable for forms and reading; compact for dense clinical surfaces (odontogram, calendar, treatment lists). User-controlled, persisted.
- Vertical rhythm: generous between blocks, denser within. Cards don't drown in interior padding, but they sit in space.
- Motion is meaningful or absent. Transitions ≤150 ms, only on state change (hover, expand, focus). No decorative animation. Respect
prefers-reduced-motion. - Warm neutrals, never blue-grey. Greys carry yellow-brown undertones in both light and dark modes. Reduces visual fatigue across long shifts.
- Readability is non-negotiable. WCAG AA minimum, AAA where it costs nothing. Clinically distinguishable glyphs (
1/l/I,0/O) via Inter'scv11feature. 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
| Token | Value | Role |
|---|---|---|
--color-canvas | #FBFAF8 | Page background (warm off-white) |
--color-surface | #FFFFFF | Cards, modals, popovers |
--color-surface-muted | #F4F2EF | Alternating rows, sidebar, grouped sections |
--color-surface-sunken | #EFEDE9 | Inputs, code blocks, inactive areas |
--color-border-subtle | rgba(15, 17, 22, 0.06) | Dividers between rows |
--color-border | rgba(15, 17, 22, 0.10) | Card borders, input borders |
--color-border-strong | rgba(15, 17, 22, 0.16) | Hover borders, soft focus |
--color-text | rgba(15, 17, 22, 0.92) | Body and headings |
--color-text-muted | #615D59 | Secondary text, descriptions |
--color-text-subtle | #94908A | Placeholders, metadata |
--color-text-disabled | #BAB6B0 | Disabled 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.
| Token | Value | Role |
|---|---|---|
--color-canvas | #161513 | Page background |
--color-surface | #1E1C19 | Cards, modals, sidebar |
--color-surface-muted | #252320 | Alternating rows |
--color-surface-sunken | #121110 | Inputs, inactive areas |
--color-border-subtle | rgba(255, 250, 240, 0.06) | Dividers |
--color-border | rgba(255, 250, 240, 0.10) | Borders |
--color-border-strong | rgba(255, 250, 240, 0.18) | Hover, soft focus |
--color-text | rgba(255, 250, 240, 0.92) | Body |
--color-text-muted | #A39E97 | Secondary |
--color-text-subtle | #787570 | Placeholder |
--color-text-disabled | #525049 | Disabled |
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).
| Token | Light | Dark | Use |
|---|---|---|---|
--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 itssoftbackground. 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.
| Role | Accent (light) | Accent (dark) | Soft bg (light) | Soft bg (dark) | Text (light) | Text (dark) | Use |
|---|---|---|---|---|---|---|---|
success | #0F9D58 | #34D399 | #E6F6EC | rgba(52,211,153,0.10) | #15803D | #86EFAC | Paid, completed, performed |
info | #0284C7 | #7DD3FC | #E0F2FE | rgba(125,211,252,0.10) | #075985 | #BAE6FD | Informational badges |
warning | #D97706 | #F59E0B | #FEF3E2 | rgba(245,158,11,0.12) | #9A3412 | #FCD34D | Pending, expiring plan, scheduling overlap |
danger | #DC2626 | #F87171 | #FEE7E7 | rgba(248,113,113,0.12) | #991B1B | #FCA5A5 | Allergies, 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"— alternate1/l/Ito disambiguate in clinical data."ss01"— single-storeya(cleaner at small sizes)."tnum"— tabular numerals so amounts, doses, times and FDI tooth numbers align in tables.
3.2 Scale
| Token | Size | Line-height | Tracking | Weight | Use |
|---|---|---|---|---|---|
--text-display | 28 px | 1.15 | -0.4 px | 700 | Page titles, KPI numerals |
--text-h1 | 22 px | 1.25 | -0.25 px | 700 | Modal titles, section heads |
--text-h2 | 18 px | 1.30 | -0.15 px | 600 | Sub-sections, card headers |
--text-h3 | 15 px | 1.35 | normal | 600 | Mini panel headers |
--text-body | 14 px | 1.50 | normal | 400 | Default body |
--text-body-prose | 15 px | 1.55 | normal | 400 | Clinical notes, anamnesis (long-form reading) |
--text-ui | 14 px | 1.30 | normal | 500 | Form labels, nav items |
--text-button | 14 px | 1.20 | normal | 600 | Buttons |
--text-caption | 12 px | 1.40 | 0.1 px | 500 | Metadata, timestamps, badges |
--text-micro | 11 px | 1.30 | 0.2 px | 600 | Dense 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: prettyon headings and short labels.- Flexible
min-widthon nav items — never fix widths that fit only English. - Avoid
text-overflow: ellipsison 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
| Token | Value | Use |
|---|---|---|
--radius-xs | 4 px | Inline inputs, small buttons |
--radius-sm | 6 px | Standard buttons, chips |
--radius-md | 8 px | Form inputs, list items |
--radius-lg | 12 px | Standard cards |
--radius-xl | 16 px | Modals, featured panels |
--radius-pill | 9999 px | Badges, avatars, status pills |
4.3 Elevation
Light mode uses stacked low-opacity shadows. Dark mode uses surface colour to communicate elevation; shadows are disabled.
: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;
}| Level | Light treatment | Dark treatment | Use |
|---|---|---|---|
| Flat | None | None | Page background, text blocks |
| Whisper | 1px solid var(--color-border) | Same | Card outlines, dividers |
| Soft | --shadow-sm | Surface = --color-surface (one step up from canvas) | Standard cards |
| Medium | --shadow-md | Surface = --color-surface-muted | Popovers, dropdowns |
| Deep | --shadow-lg + border | Surface = --color-surface + border | Modals |
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.
| Density | Card padding | Table row | Button padding | Body size |
|---|---|---|---|---|
comfortable (default) | 16 / 20 px | 44 px | 8 / 14 px | 14 px |
compact | 10 / 14 px | 32 px | 6 / 10 px | 13 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)
| Variant | Background | Text | Border | Use |
|---|---|---|---|---|
solid (primary) | --color-primary | white | none | Primary CTA — one per view |
soft | --color-primary-soft | --color-primary-soft-text | none | Secondary actions |
outline | transparent | --color-text | --color-border | Tertiary actions |
ghost | transparent | --color-text | none | In-row actions, icon buttons |
link | transparent | --color-primary | none | Inline navigation |
- Sizing:
smis the default (14 pxtext, 8/14 px padding).xsfor 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}-softbackground and--color-{role}-texttext. 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,
ghostvariant. - 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,
tnumactive. - 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-softbackground,--color-primary-soft-texttext and icon. No fully saturatedbg-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}-accentfor 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 var | Light source | Dark 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-outline | rgba(15,17,22,0.55) | rgba(255,250,240,0.55) |
--odontogram-detail | rgba(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
compactdensity. - 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-softat low alpha overlay on the existing professional fill, 1 px outer border in--color-danger-accent, small warning icon in--color-danger-accenttop-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-accentinstead 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-softto communicate grouping without a heavy border.
7.5 Login (pages/login.vue)
- Centred card, max-width 400 px.
- No decorative background.
--color-canvaseverywhere. - 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: nonewithout 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 userole="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, …) andfont-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
// 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 →
solidvariant in--color-danger-accent. This is the only place danger appears as a large fill.
Typography choices
- Page title →
--text-displayweight 700. - Card header →
--text-h2weight 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
tnumon numeric columns.
Component defaults
- Buttons →
size: 'sm',variant: 'soft',color: 'neutral'. Usesolid+primaryonly 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
EmptyStatecomponent. - Page wrappers → use
PageHeadercomponent.
Forbidden patterns
- Hardcoded hex colours in components.
bg-white,bg-gray-*,bg-slate-*directly. Use tokens.text-3xl,text-2xlfor headings. Use typography utilities.- Decorative animations or scale transforms.
- Saturated
accentcolour as a large fill (banner/cell/badge background). The only fills allowed for semantics are--color-{role}-soft. Saturatedaccentis 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, notbg-primary-500). outline: nonewithout a focus replacement.
Iteration checklist
When adding or modifying a view:
- 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.)
- Did I add a border or shadow I could have replaced with spacing? → Remove.
- Did I introduce a new colour outside the token set? → Use a token.
- Does the view work at both densities? → Test both, force one if dense.
- Does it pass WCAG AA in light and dark? → Run contrast check.
- Does it respect
prefers-reduced-motion? → Test. - Does Spanish copy fit? → Test with the longest realistic strings.