Design System — Preview-validated Spec
Build-ready Clinical-Curator design contract + calendar plan, distilled from the interactive preview. Source of truth for the @praxly/ui migration.
Status: SPEC — do NOT start implementing yet (user: "damit wir damit loslegen können, aber noch nicht loslegen"). Consolidated, build-ready design contract distilled from the throwaway preview
docs/frontend/showcase-preview.src.jsx(rebuild:bash docs/frontend/build-showcase.sh). SUPERSEDES the scattered "design reversals" captured during preview iterations; single source of truth to start the real@praxly/uimigration from. Companion docs:docs/frontend/0-redesign-migration-doc.md(phases) ·docs/frontend/1-shared-components-based-on-shadcn.md(contracts) · referencedocs/frontend/praxly-components/.
Entry point — where the migration starts
- Single package =
@praxly/ui(packages/ui). All 7 apps already consume it, so package changes propagate automatically. Globals: eachapps/*/src/index.cssdoes@import "@praxly/ui/globals.css". Components: imported as@praxly/ui/components/*(import sites: admin 40 · clinic 81 · doctor 61 · safety 81 · portal 52 · learn 28 · patient 5). - First edits live in the package, NOT the apps:
packages/ui/src/styles/globals.css(tokens + control scale) and the vendored components underpackages/ui/src/components/**. - First concrete change (Phase 0 — package-only, low risk): delete the sidebar active-state rail at
packages/ui/src/components/skeletons/sidebar/sidebar-link.tsx:38(thebefore:*bar) → primary-container fill only; plus load fonts once + central scrollbar +--tap-mininglobals.css. (doc 0 Phase 0.) - Mechanism: shadcn is vendored (base-ui primitives +
cva+ theglobals.csstoken bridge) — edit the.tsxin place; never npm-install a pre-styled lib. (doc 1 §"how shadcn components are modified".) - Reference implementation to port from:
docs/frontend/showcase-preview.src.jsx(see §5 inventory for the per-component mapping). - App cutover order when phases reach shells/routes (Phase 5+): portal + admin first (closest to canonical), then the 4 hand-rolled shells, then patient.
Migration flow (big picture)
Strict order — apps can only be rebuilt once the canonical layer exists:
- Build the canonical primitives in
packages/ui(doc 0 Phases 0–2): tokens + control scale inglobals.css, thenButton·Select/Dropdown·Link·DateField·Heading/Text· field chassis. Package-only, sequential, low risk. - Build the one scaffold for all (Phases 3–4):
AppFrame(+AppSidebar·Topbar·MobileNavDrawer·BrandMark) +PageScaffold+FilterBar+ one canonicalDataTable. Still package-only. - Fan out per app with subagents (Phases 5–6): one agent per app (portal + admin first, then the 4 hand-rolled, then patient). Each: swap the hand-rolled
app-shell→AppFrame; codemod routes ontoPageScaffold/DataTable/FilterBar/Heading/Link/Select; delete the app's custom shell/table/filter/heading/inline-font. Each app = its own PR + visual regression. - Enforce (Phase 8): lint bans in
apps/*(raw h1–h3, off-scaletext-*,text-[..px], inline font, page-wrapperoverflow-x-auto, hand-rolled shells). CI warn → hard-block at the end.
End state (governing principle): apps pass data + compose canonical @praxly/ui components. Layout utilities (grid/flex/gap/spacing to arrange those components) stay; structural + off-scale styling is gone. No app hand-rolls a shell, table, filter, heading, link, or account menu.
"No custom Tailwind in apps" ≠ literally zero className — it means no structural/off-scale styling; page-level layout utilities are fine.
Coupling: the calendar UI (§3) depends on the somnara scheduling backend port (separate workstream, in progress) — sequence accordingly; it can be the last UI piece.
0. Doc reconciliation — overrides to apply (these CONTRADICT current rules)
The preview deliberately overrides three hard rules. When the migration starts, update DESIGN.md / .claude/CLAUDE.md / doc 0 accordingly:
- Slideovers (Sheets) are now ALLOWED for detail + create. Overrides CLAUDE.md hard-constraint #8 ("No modals or slideovers — ever") and doc-0 locked decision #2 ("detail renders in
PageScaffold.aside, not overlay"). Rationale (user): "wir müssen sheets verwenden, sonst geht die Bearbeitung nicht." → All row-clicks and "… anlegen" CTAs open a right-side Sheet.asideis no longer the detail home. - Control touch-target floor drops from 56px to 44px (somnara/reference scale H_CTL≈40). Overrides CLAUDE.md #8 ("never below 56px touch targets"). Buttons = big 52 / medium 44 / sm 40; filters/segmented/icon and form inputs all = the 44px medium baseline.
- AI "Praxly suggests …" suggestion panels are OUT OF SCOPE for now — removed everywhere in the preview (user: "vollständig entfernen überall. out of scope."). The signed-AI Voice rule in DESIGN.md stands as a principle but no AI surface ships in this migration. (The topbar Praxiq entry-point stays.)
1. Design tokens & scale (final)
All values mirror packages/ui/src/styles/globals.css Clinical-Curator palette. New/confirmed:
Control sizing — ONE coherent scale
| Token | Value | Applies to |
|---|---|---|
| Control radius | 12px (everything; NOT pill) | buttons (md/sm/icon), icon-buttons, filter-selects, search, segmented |
big button | 52px h · 17px text · 12px radius | prominent / hero CTAs |
md button (baseline) | 44px h · 16px text · 12px radius | page/header CTAs, toolbar primary |
sm button | 40px h · 14px text · tighter padding | row / sheet-footer / compact |
icon button | 44px square · 12px radius | topbar, nav arrows, sheet close |
| Filter select / search / segmented | 44px h · 12px radius · surface-high | list toolbars, calendar toolbar |
| Segmented item | 36px inner + 4px pad = 44px total | view switchers |
| Form input (select/date/text) | 44px h (= medium button on the y-axis) · bottom-bar chassis (14px 14px 6px 6px) | editable fields |
| Pills kept ONLY for | status badges · count chips · avatars (round) | — |
Three button sizes:
big52 ·medium44 ·sm40 (user: "wir haben dann big medium und small (sm) buttons"). Medium (44px) is the baseline shared by filters, search, segmented, icon-buttons AND form inputs (user: "input felder genau so groß machen wie medium buttons auf der y-axis").big+smare deliberate deviations for emphasis / compactness.
Contrast rule
Controls/secondary buttons sit on the grey page (surface) → they use surface-high (visible grey), hover surface-highest — NEVER near-white surface-low (user: "aufgrund des grauen Hintergrunds … nicht weiß"). Primary CTA = --primary flat (no gradient).
Type scale (role-named, weight-pinned)
--text-h1 2.5rem/600 · --text-h2 1.75rem · --text-h3 1.375rem · --text-body 1.0625rem (17px floor) · --text-body-lg 1.1875rem · --text-meta .8125rem (only allowed sub-floor) · --text-eyebrow .6875rem.
Calendar palette (per-calendar colors) — NEW, from somnara
8-slot OKLCH palette (no green/cyan), light + dark variants:
--cal-1 indigo --cal-2 violet --cal-3 rose --cal-4 amber
--cal-5 terracotta --cal-6 mauve --cal-7 slate-indigo --cal-8 warm-redLight = oklch(.5 .2 275)… ; dark raises L to ~.7. Deterministic assignment: sort calendar IDs → palette[i % 8].
2. Component decisions (final, preview-validated)
| Component | Decision |
|---|---|
| Table | Borderless / tonal — NO frame, NO drop-shadow (reverses an earlier shadcn-border attempt). surface-lowest rounded-16 surface, surface-low header band, hover surface-low, hairline only if needed. |
| Detail / create | Sheet (right slideover) — never modal-in-place, never aside. Row-click → detail Sheet; + anlegen → create Sheet. |
Create / + buttons | ONE size (md), placed in the PageHeader action slot next to the title on every page (incl. calendar — moved out of its toolbar). |
| Sheet footer | No "Abbrechen" button (top-right close icon cancels). Delete = always red (danger = red text/ghost). |
| Custom Select / Dropdown | Always the central custom Select, never native select. Variants: trigger (44), filter (44), field (44 bottom-bar, class cfield — NOT field, which collides with the form-wrapper). No checkmark in popover items (selection = highlighted row / color dot, user: "nimm den Haken global raus, wird über die Farbe schon indiziert"). No "Status:/Art:" prefix in filter selects — a leading icon (tag/file) identifies the filter; the value shows the choice. |
| Inline-edit fields (ALL: text, select, date) | Leading icon → content, left-aligned (icon then value, flex:1; text-align:left). One .ctl chassis. |
| DateField | Inline calendar popover — never type a date. Calendar icon LEFT of the value. |
| Mobile table | under 768px → stacked cards (primary field + status badge + chevron) → tap opens the Sheet. Horizontal scroll stays banned. |
3. Calendar — DESIGN from docs, FUNCTIONALITY from somnara
Ignore praxly's currently-implemented calendar entirely (user: "den aktuell implementierten Kalender ignorieren, absoluter Müll" — i.e.
apps/safety+packages/ui/src/components/scheduling/*). Port somnara's calendar, restyle to praxly tokens.
Somnara source (somnara/Somnara/packages/ui/src/components/): calendar-shell.tsx, calendar-month.tsx, calendar-week.tsx, calendar-day.tsx, calendar-year.tsx, calendar-time-grid.tsx, calendar-time-grid/{day-column,doctor-day-column,event-chip}.tsx.
3.1 Layout config — pixel-based (REQUIRED)
SLOT_H = 192px per hour (somnara) ·HEAD = 42px column header ·SUBDOC_H = 24px multi-doctor sub-row. (Preview usesSLOT_H = 96— make it a real config constant.)- All vertical positions:
top = HEAD + (minutes/60) * SLOT_H; event height= (durMin/60) * SLOT_H. Overlap layout = greedy column assignment (event-chip.tsxlayoutDayEvents, PAD=5, GAP=3).
3.2 Current-time indicator (the "Zeiger") — REQUIRED
nowstate,setInterval(…, 30_000)(30s).top = HEAD + (nowMinutes/60) * SLOT_H.- Render only on the today column: an 8px dot (left) + 1.5px full-width line, color
var(--primary). (Somnara:calendar-time-grid.tsx:50–59,152–166.)
3.3 NO black frame
- The preview's black frame was month cells rendered as
buttonwithoutborder:0→ UA default bevel. Fix = reset border, then re-add hairlines. - Borders: outer card soft (
--border, rounded-16); internal gridlines hairline (--border-soft≈oklch(.93 .01 72)). Today = ink circle (bg-foreground / text-background, rounded-7) — keep.
3.4 Per-calendar color filter — REPLACES the "Alle/Meine Termine" toggle
The old all/mine slider is gone. New model (somnara):
- A "calendar" = a doctor/staff member (no separate calendar table). Materialized
{ userId, name, isSelf, color, selected }frommember+user(role=doctor).- somnara:
packages/api/src/lib/clinic-helpers.ts:267–279,packages/ui/src/lib/calendar-types.ts:16–32.
- somnara:
- Color assigned deterministically from the 8-palette by sorted userId (
apps/clinic/src/lib/doctor-colors.ts:18–25). Event derives color from its doctor → rendered as left-border color on the chip. - Filter UI =
DoctorPickerpopover (apps/clinic/src/components/doctor-picker.tsx): trigger shows up-to-3 color dots + count; popover lists each calendar = color swatch + name + checkbox, multi-select, self sorted first ("ich"). Persisted tolocalStorage(somnara-calendar-docs), default = self for doctors / all for staff. Events filtered client-side by selected set, passed tobookings.list({ doctorUserIds }). - Praxly preview implements this as
CalendarFilter(selectedSet, color swatches, multi-toggle). It sits LEFT of the toolbar (next to ‹ Heute ›), not right.
3.5 Views — density vs shared (somnara split)
- Month + Year = density heatmap. Do NOT list event chips. Each day's background tint opacity scales with the appointment count (more appts → more opaque
--primary); month additionally prints "N Termine". (User: "für Monats- und Jahresansicht sollte mir per Opacity angezeigt werden, wieviele Termine an diesem Tag sind.") Every tinted day (count ≥ 1) gets a white day-number (--on-primary) — min tint opacity raised (~0.45 month / ~48% year) so white always reads (user: "all that have this should have white color numbers"). Today keeps its ink circle. Calendar page width cap raised (≥1680px) — month view was too narrow. - Day + Week = TimeGrid with per-calendar SHARED columns (somnara
doctor-day-column): when more than 1 calendar is selected, each day splits into N side-by-side sub-columns (one per selected calendar, color-coded, color-dot sub-header). 1 calendar → single column. Shared view applies to day/week only, never month/year. (User: "shared Ansicht … gilt nur für Tag und Woche, nicht für Monat.") - Day height == Week height, both driven by the same
SLOT_H(preview 96 for "mehr Pixel space"; somnara 192). - Event cards = tinted fill + colored left-stripe (user preferred this over the somnara white-card look): bg
color-mix(calendar-color ~12–13%, surface-container), 3px left border in the calendar color, time + name. Time-grid showstime · type+ name; month shows time + name; shared sub-columns show name only. (Reverted from a faithful somnarabg-cardwhite-card attempt — user: "die Karten sahen vorher besser aus".) - Toolbar: Segmented (Jahr/Monat/Woche/Tag) · ‹ Heute › · CalendarFilter · title ·
+ Termin anlegen(in PageHeader).
3.6 Create flow — full somnara NewApptPanel in a WIDE Sheet
Sheet wide ≈680px (the default sheet was too small). Sections top→bottom:
- patient search → list → selected-card (Ändern)
- Terminart chips with icons
- doctor / Behandelnde:r chips (calendar-color avatar)
- duration Segmented
- date + time = two-panel
BookingDateTime(NOT a day-strip): left = month grid (Monat+Jahr header, prev/next nav, day-pills — availablesurface-high/ selectedprimary/ unavailable dimmed-disabled / today-dot; 60-day window); right = slot side-panel (selected-day label + vertical scrollable time-pill list from Öffnungszeiten; stacks under the grid on narrow widths) - note
- footer summary (
slot–end · date · Arzt) + primary "Termin anlegen" (disabled until patient+slot)
Exact somnara source: packages/ui/src/components/booking-calendar-picker.tsx + booking-slot-panel.tsx + new-appt-drawer/* (the live clinic component; the old PraxlyCalendar.jsx day-strip was a stale snapshot — do NOT use it).
4. Calendar — advanced functionality to PORT from somnara (document now, build later)
Backend/engine scope the user asked to record ("die anderen Calendar-Funktionalitäten wie Google etc. auch rüber kopieren von somnara"). Separate workstream from the UI migration. Source:
somnara/Somnara/packages/scheduling(newest, plugin-split) +somnara/somnara-app/packages/scheduling(provider adapters + Vitest).
4.1 External providers (adapter pattern — no SDK bundling)
| Provider | Source | Capability |
|---|---|---|
| Google Calendar | packages/scheduling/src/google.ts | free/busy (freeBusy.query), create/update/delete events, Google Meet links, idempotent request IDs, refreshGoogleToken() |
| Microsoft/Outlook (Graph) | src/microsoft.ts | free/busy (/me/calendar/getSchedule), event CRUD, Teams links, tenant-aware refreshMicrosoftToken() |
| CalDAV / other | src/schema.ts (calendarCredential.metadata) | provider-agnostic adapter interface; bring-your-own adapter |
| ICS feed | src/ics.ts | RFC-5545 generation (no deps), VEVENT REQUEST/CANCEL, SEQUENCE bump on reschedule; attached to confirm/reschedule/cancel emails |
4.2 Sync architecture
- Direction: one-way write-back. Reads = polling free/busy during slot computation (
gatherExternalBusy()); writes = on-demand (writeToCalendars/updateCalendars/removeFromCalendars). No webhook ingestion of external changes (host cancels manually). - OAuth: tokens from Better-Auth social account →
accountToCredential()→calendarCredentialtable (accessToken/refreshToken/expiresAt/metadata). Scopes: Googlecalendar.events+.readonly+ offline; MSCalendars.ReadWrite+offline_access. - Refresh:
onCredentialRefreshhook, called pre-flight whenexpiresAt ≤ now; refreshed tokens persisted; failure non-fatal. - Idempotency: per-slot
idempotencyKeyUNIQUE (409 on collision); bookinguidfor external dedup.
4.3 Plugin system (packages/scheduling/plugin-*.ts) — one concern each
plugin-types (options/validation) · plugin-shared (session/org) · plugin-context (availability build, busy load, OOO) · plugin-booking-create (dispatch) · plugin-booking-handlers (single-host) · plugin-booking-lifecycle (attendees, ICS, webhooks, events, seats) · plugin-booking-recurring · plugin-booking-reschedule · plugin-booking-seated · plugin-booking-ops · plugin-calendar-ops (refresh, free/busy, write-back) · plugin-calendar-mutations · plugin-host-ops (multi-host intersection / round-robin) · plugin-endpoints-{booking,calendar,event-type,schedule,slots,webhook}.
4.4 Cross-cutting
- Recurrence:
recurrence.ts{ freq: daily|weekly|monthly, interval, count≤52 }, luxon, wall-clock across DST, sharedrecurringGroupId; only first N occurrences conflict-checked. - Timezones: IANA per schedule; luxon DST-safe.
- Availability:
schedule+availability(weekday windows) +dateOverride+outOfOffice;computeSlots()steps byslotInterval, excludes busy+buffers and hard blocks. - Booking links / video: booking
uidfor guest manage/cancel;bookingReference→ external event IDs + Meet/Teams/Jitsi/LiveKit URLs.
4.5 Schema (port targets)
schedule · availability · dateOverride · outOfOffice · eventType (length, buffers, limits, recurrence, seats, bookingFields, locationType) · eventTypeHost (collective/round-robin) · booking (uid, idempotencyKey, status, recurringGroupId, rescheduledFromUid) · bookingAttendee · calendarCredential · selectedCalendar (readBusy/writeEvents) · bookingReference · webhook (HMAC, triggers).
4.6 Endpoints (/scheduling/*)
schedule/availability/override/office CRUD · event-type CRUD + hosts · book (public, rate-limited; single/seated/recurring/multi-host) · booking list/get/confirm/decline/reschedule/cancel · slots (merges internal+external free/busy) · calendar connect/list/available/select/disconnect · webhook CRUD. Triggers: BOOKING_CREATED/REQUESTED/CONFIRMED/RESCHEDULED/CANCELLED/REJECTED/NO_SHOW/MEETING_ENDED.
5. Component inventory — preview → @praxly/ui (source of truth)
The working reference implementation is
docs/frontend/showcase-preview.src.jsx(rebuild:bash docs/frontend/build-showcase.sh). It is a single throwaway file (inline CSS classes + React) but every value/behaviour is real and validated. Port each component below from that file intopackages/uiper the doc-1 contracts. Demo data in the file (TONE,STATUSES,KINDS,DOCTORS,PATIENTS,APPTS,AV…) is illustrative — real data comes from the API /@praxly/sharedenums.
| Preview component (in src.jsx) | @praxly/ui target | Spec ref | Notes |
|---|---|---|---|
Btn | Button | doc1 §9, §1 | sizes big 52 / md 44 / sm 40 / icon 44²; variants primary·secondary·ghost·destructive·danger (red, delete)·link; 12px radius |
Select | Select / Dropdown | doc1 §10/§13, §2 | variants trigger/filter/cfield(field 44); leading icon; NO checkmark; no "Status:/Art:" prefix |
Segmented | Segmented (NEW) | §1 | 44px, 12px radius; view-switcher + duration |
DateField + MiniCalendar | DateField | doc1 §16, §2 | calendar popover, icon-left; record dates only, never appointments |
IconInput / IconArea (.ctl) | field chassis / Input,Textarea | §2 | inline-edit: leading icon + left content, 44px bottom-bar |
Badge (TONE) | Badge / status-tones | §1 | status pills; tones from a central enum→label map |
Sheet (+wide) | Sheet (NEW) | §0.1, §2 | right slideover (overrides no-modal rule); wide≈680 for booking; close icon cancels, no Abbrechen |
Sidebar+Topbar+App | AppFrame/AppSidebar/Topbar | doc1 §1/§2/§3 | active = primary-container fill; Topbar: search = ICON → CommandPalette (⌘K) modal, NO user-avatar (sidebar user-card owns identity) |
CommandPalette | GlobalSearch | doc1 §1 | ⌘K command modal opened by topbar search icon |
PageHead | PageScaffold/PageHeader | doc1 §4/§5 | create/+ button = md in header action slot |
DataTable (in VorsorgePage) | DataTable | doc1 §6, §2 | borderless/tonal, header sort, row→Sheet, mobile = stacked cards |
FilterBar (in VorsorgePage) | FilterBar | doc1 §7 | search + Select filter pills (icon, no prefix) + reset |
BrandMark (LOGO) | BrandMark | doc1 §3 | preview embeds praxly-mark.png (base64); final SVG TBD |
CalendarPage,MonthView,TimeGrid,YearView,CalendarFilter,BookingDateTime,NewApptSheet,EventChip | calendar suite (port somnara) | §3, §4 | heatmap month/year · now-line · shared columns · color-per-calendar filter · month-grid booking |
Stat,hero-number,.paper* | surfaces/Stat | DESIGN.md | tonal cards, no 1px frames |
DashboardPage/PatientsPage/DocsPage/SettingsPage/GalleryPage | — (example pages) | — | illustrative usage, not shipped components |
Removed/out-of-scope (do NOT port): the AI "Praxly schlägt vor" panels (§0.3); the somnara white bg-card event style (user kept the tinted fill).
6. Build order (when we DO start — not yet)
- Land tokens + control scale (§1) in
globals.css+Button/Select/field chassis. - Borderless
DataTable+Sheetprimitive +DateField+ customSelectwith leading-icon fields (§2). - Calendar UI port (§3) — start month/week/day + now-line +
CalendarFilter; praxly tokens; ignore old calendar. - Calendar engine port (§4) — separate backend workstream (providers, plugins, schema, endpoints).
Quality gates — per feature / per phase (MANDATORY)
Every component/feature built during the migration must pass these before its PR — fixing lint/type errors is part of building the feature, not a follow-up:
- oxlint — zero errors AND zero warnings. Toolchain is oxlint + oxfmt + turbo (NOT ultracite/biome — CLAUDE.md is stale here). The repo keeps warnings at 0 (see commit "chore(lint): clear all oxlint warnings"); do not introduce new findings and do not silence with disable comments to pass. Run
bunx oxlint(or the repolintscript) and clear everything you touched. - oxfmt — formatted; no manual style drift.
- Type-check green —
tsc --noEmit(per package /bun run check-types); noany-escape hatches. - Build green — the affected app(s) build.
- No
try/catch— house rule: use guards, let errors bubble (see memoryno-try-catch). Applies to all new code in this migration. - Visual regression — screenshots at 320 / 768 / 1024 / 1440, both themes (no unit-test infra in this repo).
Each phase = its own PR. CI gate: warn during migration, hard-block at the end (after the last codemod), per doc-0 locked decision #3.