# Divetracx design system ## Atmosphere and identity Divetracx is a calm, precise dive logbook. It combines a cool sea-glass page, crisp white cards, deep-water ink, and a single teal accent. Dive data — depths, times, pressures, gases, coordinates — should read like instrument output: compact, aligned, and unambiguous. Reliability is part of the visual identity. Profiles, maps, and pictures must show useful content or an intentional empty state, never a broken placeholder. Imported data is never silently altered by presentation: units, precision, and provenance stay visible. ## Color Use these semantic tokens from `src/styles.css`: - Page: `--background`, `#f4f8f8`. The body adds a faint teal radial glow at the top right; do not repeat it inside components. - Ink: `--foreground`, `#102c31`. - Card: `--card` / `--card-foreground`, `#ffffff` / `#102c31`. - Muted surface: `--muted`, `#e9f1f1`. Muted text: `--muted-foreground`, `#597176`. - Accent surface: `--accent` / `--accent-foreground`, `#d9eeef` / `#102c31`. Used for icon tiles and selected rows, never for text emphasis. - Primary action: `--primary` / `--primary-foreground`, `#087f8c` / `#f5ffff`. - Border: `--border`, `#d3e1e2`. - Destructive: `--destructive` / `--destructive-foreground`, `#dc2626` / `#ffffff`. Inline destructive text uses `text-red-600`. - Warning: `--warning` / `--warning-foreground`, `#d97706` / `#92400e`. Teal identifies interaction and current state, not decoration. Section eyebrows, active navigation, icon tiles, focus rings, and the site-map pin all derive from `--primary`. Add new semantic roles to `src/styles.css` before using them. The one sanctioned exception is data visualization: the dive-profile chart and per-tank series use a fixed categorical palette (`#0891b2`, `#7c3aed`, `#ea580c`, `#16a34a`, `#db2777`, `#4f46e5`) so tanks stay distinguishable regardless of theme. Certification cards use a `from-primary via-primary to-cyan-800` gradient as their card surface. Agency logos use a rounded-rectangle frame everywhere they appear. Profile-card agency badges show both the agency name and membership number and fall back to initials when a logo cannot be loaded. The profile certification list uses a star control to select up to eight certifications for the Divetracx card. The card shows only starred certifications; one to three starred items use one column, while four to eight use two columns. Certification pill labels keep their full names and reduce their font size only as needed to fit the available width. When insurance details are recorded and enabled for the card, the card footer shows the insurer, optional plan, and policy number. Emergency-contact name, phone, and email are likewise shown only when available and enabled. Both groups default to visible and have independent controls in their profile-form sections. The card is titled “Diver Profile”; its certification labels reduce their font size as needed to retain full names. Agency badges are visually prominent beneath a slightly smaller profile image. On desktop, the Divetracx card and certifications occupy the profile page's left column while the personal-details form occupies the right column. Body text must meet WCAG 2.2 AA contrast on its actual surface. Form-control outlines need at least 3:1 contrast with adjacent colors. There is currently a single light theme; do not add `dark:` utilities piecemeal — a dark theme is a token-level change. ## Typography - UI and body: `Manrope`, sans-serif (`font-sans`). - Data: `DM Mono`, monospace (`font-mono`). Use it for depths, durations, pressures, coordinates, certification numbers, serials, and dive numbers so columns of values align. Scale: - Page title: `text-4xl`, weight 600, `tracking-tight`; the hero variant grows to `md:text-5xl` with `text-balance`. - Section eyebrow: `text-xs`, weight 700, uppercase, `tracking-[0.2em]`, `text-primary`. Muted eyebrows for grouped metadata use weight 500 or 600, `tracking-wide`, and `text-muted-foreground`. - Section title: `text-xl` or `font-semibold` at body size, weight 600. - Body: `text-sm` to `text-base`, weight 400 to 600. - Helper text: `text-xs`, `text-muted-foreground`. Body content never drops below 14px. Helper text, attribution, and control labels may use 12px when legible. ## Spacing and layout - The page frame is `mx-auto w-full max-w-6xl px-4 py-8 md:px-8 md:py-12`. - The header is sticky, `h-16`, `bg-background/90 backdrop-blur-xl`, with a `border-b border-border/80`. - Cards use `p-5` or `p-6`; card headers stack with `gap-1.5`. - Grids step from one column to `sm:grid-cols-2` and, for stat tiles, `lg:grid-cols-4`, with `gap-4` or `gap-5`. - Minimum tap target is 44px: interactive controls use `min-h-11`, icon buttons `size-11`, list rows `min-h-16`. ## Radius and depth - Cards, panels, buttons, and inputs: `rounded-2xl` for surfaces, `rounded-xl` for controls and inline tiles, `rounded-lg` for small badges and the `sm` button size. - Surfaces are flat: `border border-border bg-card` with no shadow. Shadows are reserved for the brand mark (`shadow-lg shadow-primary/20`), error cards (`shadow-xl shadow-primary/5`), and the map pin. - Layering uses transparency, not elevation: `bg-muted/40` for callouts, `bg-muted/50` for row hover, `color-mix` with `--card` for map controls. ## Components Shared primitives live in `src/components/ui/` (shadcn-style wrappers over Base UI): `alert`, `badge`, `button`, `card`, `checkbox`, `input`, `scroll-area`, `select`, `textarea`. Domain components live in `src/components/`. When a UI pattern gains a second consumer, promote it to a shared component with one visual contract instead of copying classes. ### Buttons `Button` variants: `default` (filled primary), `outline` (`border bg-card`), `ghost`, `destructive`. Sizes: `default` (`min-h-11 px-5`), `sm` (`min-h-9 rounded-lg px-3`), `lg` (`min-h-12 px-7`), `icon` (`size-11`). Every button is `rounded-xl`, `text-sm font-semibold`, and shows a `focus-visible:ring-2 ring-primary/30`. `SaveButton` wraps the pending state so forms never show two competing submit affordances. ### Cards and stat tiles `Card` is `rounded-2xl border border-border bg-card`; `CardLink` is the same surface as a whole-card link. `StatCard` is an `article` with a `text-primary` Lucide icon, a muted uppercase label, and a large mono value. Use it for aggregate numbers on Overview, Stats, and detail pages. ### Badges and states `Badge` variants: `default`, `secondary`, `outline`, `warning`, `destructive`. Import statuses (discovered, new, changed, unchanged, failed) and integration capabilities map onto these; do not introduce ad-hoc pill styles. ### Notifications Sonner provides app-wide transient notifications at the bottom right. Use an error toast for action failures whose inline location may be outside the current viewport; keep field-validation errors directly beneath their fields. Toasts must use plain-language titles and retain the actionable server message as their description. ### Record lists `DiveLinkList` and similar lists are `grid` rows with an icon tile (`size-9 rounded-lg bg-accent text-primary`), a truncating primary label, a mono trailing value, and a chevron. Rows separate with `border-b border-border` and highlight with `hover:bg-muted/50`. Empty lists render a centered `text-sm text-muted-foreground` sentence in the form "No dives yet." ### Forms `EntityForm` provides the field stack and inline star rating. Dive editors select existing taxonomy values; creating or renaming taxonomies belongs in Settings, not alongside the dive fields. Dive operator and boat are adjacent taxonomy selects in the main Dive section. The dive people picker is a searchable popover; selected people render as rows with a role select and remove action. New assignments default to Buddy. The Depth & time section carries the paper-logbook extras — a safety-stop toggle with an inline minutes input, and a Dive tables group with single-letter pressure-group inputs (upper-cased, mono) and residual nitrogen time; total bottom time is derived and displayed read-only. Field labels are `text-sm font-medium`; validation and server errors appear directly under the field in `text-sm text-red-600` with `aria-live="polite"`. Recorded dive profiles, import history, and external provenance are view-only and must not gain edit affordances. ### Destructive actions `DeleteRecordButton` renders a ghost button in `text-red-600`, asks for confirmation before deleting, and reports failures inline with `aria-live="polite"`. Never delete on the first click. The AI access settings page groups MCP tools into Read, Create and update, and Delete cards. Every tool has an independent switch, while the page-level MCP switch pauses the endpoint without discarding clients or history. Destructive tools and scopes use the destructive badge treatment; connected clients show their live scopes and require confirmation before revocation. Each integration card on the Integrations page ends with a “What to sync” list using the same switch rows: label, one-line description, and a mono `on/total` count in the heading. A required entity shows a disabled switch and says why it is always synced. A row that depends on a switched-off row is shown off and disabled with a `--warning` “Off because …” note, and a row with dependants warns what else switches off with it before it is toggled. The list saves with a small outline `SaveButton` that is disabled until something changed. ### Media `PictureGallery` is a `sm:grid-cols-2` grid of `aspect-[4/3]` images in `rounded-xl bg-muted` frames with a subtle `hover:scale-[1.02]`. `PhotoManager` adds upload and removal; failures show inline in red. Profile and buddy avatars fall back to the bundled illustrated diver (`src/assets/diver-fallback.png`), never to initials or an empty box. ### Certification cards `CertificationCard` renders a physical-card-shaped surface with the teal gradient, a bold tracked eyebrow, the certification name, and a mono card number; scanned fronts and backs are bounded to `856×540`. Agency names, codes, and logos under `public/agency-logos/` are resolved through `src/modules/profile/agency-catalog.ts`. ### Dive profile chart `dive-profile-chart.tsx` is hand-written SVG: depth on an inverted axis, temperature and decompression-ceiling series, per-tank pressure lines in the categorical palette, and tank-switch markers drawn as labelled circles with connector lines and an `aria-label`. Axes use the mono face and gridlines use `--border`. Do not introduce a charting library for this chart. Heart rate, when a computer recorded it, shares the temperature band as a dashed rose (`rose-500`) line with its own scale and a peak marker in `bpm`; it appears in the legend and the selected-sample panel only when the dive has readings. Alerts the computer raised sit on the depth curve at their time: a `--warning` triangle for critical ones (ascent rate, broken ceiling or safety stop, ppO₂, CNS/OTU, battery) and a small hollow circle for the rest, each with a dotted connector and a ``. Gas switches keep their labelled circles. When a dive carries decompression data, the selected-sample panel shows the remaining no-deco limit (or time to surface once in deco) with CNS and N₂ loading. The dive page lists the same alerts and gas switches under “Computer events” as a mono-time / icon / label list; critical rows use `--warning-foreground`. A merged dive holds more than one recorded profile on one timeline. Samples keep their true elapsed times, so the surface interval between segments is drawn to scale: a `--muted` band spanning the depth track, edged with dashed `--border` rules, and a legend entry reading “Surface interval · no recorded data”. Every series — depth, temperature, pressure, per-tank pressure, ceiling — lifts the pen at a segment boundary, and the depth outline and fill are closed once per segment. Nothing is interpolated across a gap that holds no samples, and no sample is invented to bridge one. ### Logbook dive diagram When a dive has no recorded samples, `manual-dive-diagram.tsx` replaces the chart with a paper-logbook schematic: the surface line steps down to a flat bottom labelled with the bottom time, climbs back up through an optional safety-stop shelf, and returns to the surface. Boxed values above the line carry the pressure group before and after the surface interval, the interval itself, and the ending pressure group; the depth panel lists average and maximum depth; a sum line reads `RNT + ABT = TBT` in minutes. The sketch is never scaled to the data — the kicker says "Schematic · not to scale" — and missing values render as an em dash in `--muted-foreground`, never as zero. The whole SVG is a single `role="img"` whose `aria-label` reads the numbers out in prose. ### Merging dives `/dives/$diveId/merge` is a full page, not a dialog: a merge is destructive and its review needs room. The left column lists dives within a day of the one being merged, each row a checkbox, dive-type tile, identity, and mono duration and depth. The right column is a sticky review panel showing the resulting segment order — the surviving entry marked “kept”, with the surface gap between each pair — then the merged duration, depths, sample count and tank handling as mono values. When the survivor is not the dive being viewed, a `--warning` note says so before the confirmation does. Blocking problems — overlapping dives — show as red text with an alert icon and disable the action; consequences that are merely worth knowing use the `--warning` surface. Confirmation is `window.confirm`, matching dive deletion, and it names every dive that will be deleted. A dive that absorbed others lists them under “Merged from” in its “Recorded by” panel. That panel names every computer and app the dive was read from — device name, integration, serial, firmware — with a badge saying whether the source created the dive or attached to one that already existed, and a line for what it added (profile samples, tanks, computer events, people, gear). A dive with no sources says whether it was logged by hand or from computer data. ### Data verification `/settings/data-verification` groups read-only logbook health checks into cards. Each check has a passed or review-count badge and explains the fields it compares. Dive numbering starts with the “Dives before Divetracx” field: dives made before the logbook started offset every number, so the first logged dive follows them. Saving the count changes no dive by itself; the check then reports the dives whose numbers no longer match, and the confirmed renumber action beside its findings applies the offset. It also reports missing, repeated, and non-chronological numbers. Duplicate-entry candidates show both dive summaries, the signals that caused the match, and a link to the existing full-page merge review; detection never changes or deletes records by itself. ### Site map MapLibre GL in a `rounded-xl border` frame. The canvas is desaturated (`saturate(0.72)`) so teal markers stay legible. Pins are 20px primary circles with a card-colored ring; clusters show a mono count; the selected pin scales to 1.1 and raises its z-index. Controls are restyled to `rounded-xl` card surfaces with `--accent` hover. `CoordinatePicker` reuses the same frame with a click-to-pin interaction and a muted helper callout. ### Navigation Destinations are declared once in `src/lib/navigation.ts` and rendered two ways. From `md` up, the header nav is icon-first: Dives, Sites, Buddies, Gear, Stats, Profile, and a Settings gear. Labels are hidden below `lg`, so every item carries an `aria-label`. The active item uses `bg-accent text-foreground`; inactive items are `text-muted-foreground` and turn `text-foreground` on hover. Below `md`, the header keeps only the brand and the current section name, and a fixed bottom tab bar (`h-16`, same blur and border treatment as the header, plus `env(safe-area-inset-bottom)`) shows Dives, Sites, Buddies, Gear, and a "More" tab. Tabs are icon-over-label at 11px, `text-primary` with a heavier stroke when active. "More" opens a Base UI `Drawer` bottom sheet (`rounded-t-3xl bg-card`, swipe-to-dismiss) listing Overview, Map, Stats, Profile, and Settings as `min-h-16` rows with an icon tile and one-line description; the current destination uses `bg-accent` with a filled primary tile. The page frame adds `pb-[calc(6rem+env(safe-area-inset-bottom))]` and toasts are offset above the bar so neither is covered. Demo builds show an amber read-only banner directly under the header. A detail page that belongs to an ordered series pairs its back link with a prev/next control on the same row: outline buttons carrying a chevron, the word Previous or Next, and the neighbour's mono identifier. Previous means the older record and Next the newer one, so the pair reads chronologically even though the list it came from is newest-first. Both ends of the series keep their button visible as a `pointer-events-none opacity-40` span, so the control never shifts width, and each link carries an `aria-label` naming the neighbour. ### Error state `AppError` is a centered `max-w-lg` card with a `size-14 rounded-2xl bg-destructive/10 text-destructive` icon tile, a plain-language title, and the technical message beneath in muted text. ## Content vocabulary and formatting - Units follow the metric logbook convention: metres with one decimal, minutes as integers, bar as integers, °C with one decimal, coordinates as signed decimals with seven places. - Dates are absolute and locale-formatted (`toLocaleString()` for timestamps, `yyyy-MM-dd` for date inputs); there is no relative time anywhere. - Dive types are free-form and often German (they come from DiveMate); `dive-type-icon.ts` picks an icon by keyword and falls back to a fish. Do not normalize the user's names in the UI. - Sentence case for headings and buttons; no trailing periods on labels. - Empty states say what is missing and, when possible, how to fill it. ## Motion and interaction - Transitions are `150ms ease-out` on color, opacity, and transform only. - Hover scale is capped at 1.02 for images and 1.1 for map pins and rating stars. - The map inspector uses `@starting-style` for its entrance; there is no other entrance animation. - Respect `prefers-reduced-motion` when adding any new transform animation. ## Accessibility constraints - Every interactive element has a visible focus style: a 2px `--primary` outline with `outline-offset: 3px` on links and buttons, or the `ring-primary/30` ring on shared controls. - Icon-only controls carry `aria-label`; decorative icons carry `aria-hidden="true"`. - Live regions (`aria-live="polite"`) announce form errors and async results. - Touch targets are at least 44×44 CSS pixels; on coarse pointers or below 640px the map inspector docks to the bottom edge. - Verify changed routes at 375px, 768px, and 1280px with keyboard navigation.