The Kora frontend uses a semantic, token-based design system built on Tailwind CSS, CSS custom properties, and Radix UI primitives.
| Layer | Location |
|---|---|
| CSS tokens | app/globals.css (:root and .dark blocks) |
| Tailwind mapping | tailwind.config.ts |
| UI primitives | components/ui/ |
| Domain components | components/invoice/, components/wallet/, etc. |
- Dark-mode-first with light mode via the
classstrategy (darkMode: ["class"]in Tailwind). - The persisted preference (
light/dark/system) lives inuiStore; an inline bootstrap script inapp/layout.tsxapplies the resolved class before first paint to prevent FOUC. lib/theme.tsis the single source of truth for theme resolution:resolveTheme()is shared by the inline bootstrap script (THEME_INIT_SCRIPT) andcomponents/layout/ThemeProvider.tsx, so the pre-paint class and the hydrated class can never drift when the OS switches color scheme. A unit test executes the script and compares its result againstresolveThemefor every preference/OS combination.- Color tokens are defined as HSL components (e.g.
--color-primary: 174 72% 40%) and consumed ashsl(var(--primary))in Tailwind.
The mobile browser UI bar (and PWA title bar) follows the resolved theme via THEME_COLORS in lib/theme.ts:
| Token | Value | Surface |
|---|---|---|
THEME_COLORS.light |
#ffffff |
white page background |
THEME_COLORS.dark |
#09090b |
zinc-950 dashboard background |
app/layout.tsxexports a media-scopedviewport.themeColor(viabuildViewportThemeColor()) so the server-rendered<meta name="theme-color">tags match the OS preference before hydration.- After hydration,
ThemeProvidercallsapplyBrowserThemeColor(resolved), which forces the resolved color onto everytheme-colortag — this is what makes an in-app override (e.g. OS light + user-picked dark) win over the OS preference. - The manifest's
theme_color/background_color(built inlib/pwa/manifest.ts) use the same dark token so the PWA install splash stays consistent. Keep them in sync viaTHEME_COLORS.
The manifest is served by the /manifest.json route handler (app/manifest.json/route.ts), not a static file, so it renders for the active kora-locale:
- Shortcut names reuse the translated navbar labels (
nav.marketplace,nav.createInvoice) so a shortcut's title matches its destination. - Shortcut
short_name, descriptions and screenshot labels come from thepwa.manifestmessage namespace; missing keys fall back to English. - Shortcut URLs are locale-agnostic app paths (
/marketplace,/invoice/create). The locale lives in thekora-localecookie, never in the URL, so localized manifests must not ship locale-prefixed shortcut paths.
Semantic tokens in globals.css:
- Brand:
--color-primary,--color-accent - Surfaces:
--color-surface,--color-surface-elevated,--color-surface-muted - Text:
--color-text,--color-text-muted,--color-text-subtle - Feedback:
--color-success,--color-warning,--color-destructive,--color-info
Legacy shadcn-compatible aliases (--background, --foreground, --card, etc.) map to the semantic tokens for compatibility with existing UI components.
Spacing scale (--space-1 … --space-12) and font sizes (--font-size-xs … --font-size-3xl) are defined in globals.css and referenced where needed for consistent rhythm.
Reusable building blocks live under components/ui/. Every file in that folder is listed below with its purpose and when to use it. Prefer composing these primitives over one-off styles. Use cn() from lib/utils for conditional class merging.
| Primitive | File | Purpose | When to use |
|---|---|---|---|
Button |
button.tsx |
Primary CTA, secondary, ghost, danger variants with loading spinner, asChild, and iconLabel a11y support. |
Any clickable action. Use isLoading for async submits; iconLabel for icon-only buttons. |
Input |
input.tsx |
Text input with label, error, hint, and size variants. | Single-line text entry (debtor name, invoice number, search). |
Textarea |
textarea.tsx |
Multi-line text with auto-resize, label, error, hint. | Description/memo, incident notes, long-form entry. |
NumberInput |
number-input.tsx |
Numeric input with steppers, min/max, currency formatting. | Amount fields (invoice amount, min investment, funding amount). |
Select |
select.tsx |
Custom select with grouped options, search, and Radix popover. | Jurisdiction, category, risk tier, status filters. |
DatePicker |
date-picker.tsx |
Calendar popover (Radix Popover + date-fns), min/max clamping, keyboard nav, hidden input for react-hook-form. |
Due date, listing expiry, any date field. |
FileInput |
file-input.tsx |
Drag-and-drop file dropzone with validation and preview. | Invoice PDF upload in the mint wizard. |
RangeSlider |
range-slider.tsx |
Dual-thumb range slider for numeric intervals. | APR range, amount range filters on marketplace. |
| Primitive | File | Purpose | When to use |
|---|---|---|---|
Card |
card.tsx |
Surface container with header, content, footer slots and hover/selected states. | Invoice cards, dashboard stats wrappers, panel sections. |
Dialog |
dialog.tsx |
Radix Dialog (modal) with overlay, title, description, focus trap, and close affordances. | Confirmations (CancelInvoice, Repay, Share), KYC dialogs, wizard review. |
Drawer |
drawer.tsx |
Slide-in panel from the right with focus trap, Escape, backdrop, and scroll lock. | Position detail, analytics breakdowns, history drawer. |
BottomSheet |
bottom-sheet.tsx |
Mobile slide-up sheet with drag-handle swipe-to-dismiss, focus trap, Escape, and scroll lock (lg:hidden). |
Marketplace filters on mobile, secondary market filters. |
Breadcrumb |
breadcrumb.tsx |
Navigational hierarchy trail with separators and current-page marking. | Page headers where parent context matters (e.g. invoice detail → marketplace). |
BackButton |
back-button.tsx |
Router-aware back navigation with icon and label. | Detail pages, wizard steps, dashboard drill-downs. |
PrintLayout |
print-layout.tsx |
Print/PDF export wrapper with @media print rules and page-break controls. |
Invoice PDF generation and print preview. |
| Primitive | File | Purpose | When to use |
|---|---|---|---|
Badge |
badge.tsx |
Status pill with color variants (default, secondary, destructive, outline, success, warning). | Risk tier, invoice status, live counts. |
Progress |
progress.tsx |
Radix Progress bar with determinate value and accessible label. | Funding progress on invoice cards and detail. |
Skeleton |
skeleton.tsx |
Animated placeholder block/circle for loading states. | Any async surface before data resolves. |
EmptyState |
EmptyState.tsx |
Illustrated empty state with title, description, action slot and variant (default, search, error). |
No results, no invoices, filtered-empty marketplace. |
ErrorBoundary |
error-boundary.tsx |
React error boundary with fallback UI, retry, and classified error kinds. | Route-level and widget-level crash containment. |
ErrorPage |
ErrorPage.tsx |
Full-page error display with translated title/description and reset CTA. | error.tsx and global-error.tsx boundaries. |
Tooltip |
tooltip.tsx |
Radix Tooltip with delay, side, and arrow. | Icon buttons, truncated labels, APR explainers. |
animations.tsx |
animations.tsx |
Framer-motion helpers: SuccessCheckmark, reduced-motion guard, shared transitions. |
Success states (minted, funded), micro-interactions. |
CountdownTimer |
CountdownTimer.tsx |
Live countdown with ICS export helper for maturity reminders. | Time-to-maturity on invoice cards and detail. |
| Primitive | File | Purpose | When to use |
|---|---|---|---|
DataTable |
data-table.tsx |
Headless data table with sortable columns, row selection, and empty state. | SME/investor dashboards, transactions list. |
Pagination |
pagination.tsx |
Page navigation with ellipsis, previous/next, and accessible labels. | Any paginated list (marketplace, transactions, dashboards). |
StatCard |
stat-card.tsx |
Metric card with value, trend, icon, and change indicator. | Portfolio stats, marketplace aggregates, analytics KPIs. |
APRDisplay |
apr-display.tsx |
Formatted APR with tier badge and tooltip. | Invoice cards, detail financing terms, comparison table. |
Highlight |
Highlight.tsx |
Query-aware text highlighter that wraps matches in <mark>. |
Search results (marketplace, transactions, address book). |
StellarAddress |
stellar-address.tsx |
Truncated Stellar G… address with copy and explorer link. |
Seller address, debtor wallet, transaction from/to. |
StellarTxLink |
stellar-tx-link.tsx |
Transaction hash link to Stellar Expert with copy and external icon. | Toast, history, detail confirmations. |
investor-dashboard-skeleton |
investor-dashboard-skeleton.tsx |
Composite skeleton layout mirroring the investor dashboard grid. | Loading state for /dashboard/investor. |
| Primitive | File | Purpose | When to use |
|---|---|---|---|
CopyButton |
CopyButton.tsx |
Copy-to-clipboard button with copied feedback, useTranslations("copyButton"), and tooltip. |
Address, hash, share link, taxonomy copy. |
ShortcutBadge |
ShortcutBadge.tsx |
Keyboard shortcut hint badge (⌘K, ?, etc.). |
Command palette, shortcut reference modal. |
index.ts |
index.ts |
Barrel re-exports for the most commonly composed primitives. | Import via components/ui instead of deep paths. |
Note on
index.ts: the barrel currently re-exportsbutton,card,badge,skeleton,progress,input,textarea,number-input,date-picker,file-input,pagination,dialog,select,stat-card,data-table,CopyButton,Highlight,apr-display,drawer, andrange-slider. Other primitives (e.g.bottom-sheet,breadcrumb,tooltip,EmptyState) are imported via their direct paths — this is intentional and not a gap; extend the barrel only when a primitive is used widely enough to justify it.
Component stories live alongside components as *.stories.tsx files. Storybook
packages are not currently wired as an npm run storybook script — visual
coverage runs through the Vitest snapshot test instead. See
CONTRIBUTING.md.
Run the snapshot suite for stories:
npm run test -- __tests__/stories.snapshot.test.tsxIf you make intentional markup changes, regenerate the committed snapshots:
npm run test -- __tests__/stories.snapshot.test.tsx -uSnapshots are stored in __tests__/__snapshots__/ and must be committed with
your change.
npm run storybook— dev server athttp://localhost:6006npm run build-storybook— static buildnpm run test -- __tests__/stories.snapshot.test.tsx— Vitest snapshot coverage without a dev server
- Add the CSS variable to
:rootand.darkinapp/globals.css. - Map it in
tailwind.config.tsundertheme.extend. - Use the Tailwind utility in components — avoid hard-coded hex values in feature code.
- Create
components/ui/<name>.tsxfollowing the existing patterns (forwardRef where appropriate,cn()for classes,aria-*for a11y,useTranslationsonly when the primitive owns user-facing copy). - Add
components/ui/<name>.stories.tsxwith at leastDefaultand one variant/accessibility story. - Export it from
components/ui/index.tsif it is broadly reused; otherwise document its direct import path in the table above. - Update this inventory doc so the new primitive appears in the correct table.
Screen readers rely on semantic landmarks to navigate page regions efficiently. All major layout surfaces expose explicit ARIA roles and labels:
| Component | Landmark | Label | Rationale |
|---|---|---|---|
Header (navbar) |
banner |
Implicit via <header> in top-level layout |
Primary site navigation; one per page |
Footer |
contentinfo |
aria-labelledby="footer-copyright" |
Copyright, changelog, shortcuts; links footer region to copyright text |
main (layout children) |
main |
Implicit via <main> wrapper in layout |
Primary page content; one per page |
- Modal dialogs (
Dialog,Drawer,BottomSheet) automatically trap focus and restore it to the trigger element on close via Radix UI primitives. - Footer changelog button stores a ref to the trigger; when
uiStore.changelogOpentransitions fromtrue→false, focus is programmatically restored to the button viauseEffect. - Page navigation (via Next.js
<Link>) moves focus to the new page's<main>element automatically; no custom focus management is needed for route changes.
Currently not implemented. Consider adding a visually-hidden "Skip to main content" link at the start of <body> if user feedback indicates keyboard users are navigating through repetitive chrome on every page load.
Run npm run test:a11y (if available) to audit landmark structure. Manual verification:
- Enable a screen reader (NVDA on Windows, VoiceOver on macOS).
- Navigate by landmarks (NVDA:
D/Shift+D, VoiceOver:VO+U→ Landmarks). - Verify
banner,main, andcontentinfoare announced and navigable. - Confirm footer's label reads "Kora Protocol copyright" or similar localized text.