Skip to content

Design System

Tokens are extracted from the action-flow HTML prototypes in ux-tests/prototypes/action-flow-prototypes/ and codified in src/styles/. They are a starting baseline; component implementations may tune individual values as the system matures, but the categories and naming below are stable.

CSS Token Architecture

Three files, loaded in this order from src/styles/index.css:

src/styles/index.css        ← entry: @import 'tailwindcss' FIRST, then tokens + components
src/styles/tokens.css       ← @theme block — all CSS variables (single source of truth)
src/styles/components.css   ← @layer components — common class combinations

Why Tailwind first matters. CSS @layer cascade order is set by first declaration. If components.css is imported before tailwindcss, our @layer components claims slot #1, Tailwind's later @layer theme, base, components, utilities; cannot move it, and Tailwind's @layer base preflight (* { padding: 0; margin: 0; border: 0 solid }) ends up cascading after our components layer — silently zeroing every padding/margin we declare on classes like .page-container. Importing tailwindcss first establishes the canonical theme → base → components → utilities order so our custom @layer components rules sit correctly between base and utilities. See src/styles/index.test.ts for the file-level invariant guard. (GRF-117 regression — see § Outer Page Gutter Contract below.)

Tailwind config maps token names to CSS variables so tokens work as utility classes: - bg-[--color-bg-card] (or bg-surface once shorthand is configured). - text-[--color-fg-muted]text-muted. - shadow-[--shadow-card]shadow-card.


Full Token Reference

/* src/styles/tokens.css */

@theme {
  /* ── Typography ─────────────────────────────────────────── */
  --font-sans: 'Plus Jakarta Sans', -apple-system, sans-serif;
  --font-mono: ui-monospace, 'JetBrains Mono', monospace;

  /* ── Colors ─────────────────────────────────────────────── */
  --color-accent:       #10B981;   /* primary actions, active nav, logo gradient start */
  --color-accent-dark:  #059669;   /* hover state for accent, logo gradient end */
  --color-accent-soft:  #D1FAE5;   /* accent backgrounds, LOT badge bg */

  --color-bg:           #FAFAFA;   /* page background */
  --color-bg-card:      #FFFFFF;   /* card / panel background */

  --color-fg:           #18181B;   /* primary text */
  --color-fg-muted:     #71717A;   /* secondary text, table headers */

  --color-border:       #E4E4E7;   /* all borders */

  --color-warning:      #F59E0B;   /* expiring stock, low inventory alerts */
  --color-warning-soft: #FEF3C7;   /* warning badge background */

  --color-error:        #EF4444;   /* blocked LOTs, form errors */

  --color-info:         #3B82F6;   /* planned / informational state */
  --color-info-soft:    #DBEAFE;   /* info badge background */

  /* ── Status Badge Exact Values ───────────────────────────── */
  /* Generic role-based tokens — usable for any module's status enum.    */
  /* Per-status semantic names belong in the feature, not in tokens.     */
  --status-neutral-bg:    #F1F5F9;
  --status-neutral-text:  #64748B;
  --status-info-bg:       #DBEAFE;
  --status-info-text:     #1E40AF;
  --status-warning-bg:    #FEF3C7;
  --status-warning-text:  #B45309;
  --status-success-bg:    #D1FAE5;
  --status-success-text:  #047857;
  --status-muted-bg:      #E4E4E7;
  --status-muted-text:    #71717A;

  /* ── Border Radii ────────────────────────────────────────── */
  --radius-xs:   4px;     /* LOT badge, progress bars */
  --radius-sm:   6px;     /* status badges */
  --radius-md:   8px;     /* nav links, small buttons, icon buttons */
  --radius-lg:   10px;    /* buttons, search input */
  --radius-xl:   12px;    /* cards, panels, inputs */
  --radius-2xl:  20px;    /* action cards (dashboard), filter chips */
  --radius-full: 9999px;  /* avatars, pills */

  /* ── Shadows ─────────────────────────────────────────────── */
  --shadow-card:  0 4px 20px rgba(0, 0, 0, 0.05);               /* default card elevation */
  --shadow-hover: 0 12px 32px rgba(16, 185, 129, 0.15);         /* green lift on hover */
  --shadow-float: 0 8px 32px rgba(0, 0, 0, 0.10);               /* toasts, modals, dropdowns */
  --shadow-sm:    0 4px 12px rgba(0, 0, 0, 0.10);               /* kanban cards */

  /* ── Layout ──────────────────────────────────────────────── */
  --page-padding-x:      16px;    /* phone-first; bumped to 24/32 at md/lg below */
  --page-padding-y:      16px;    /* phone-first; bumped to 24 at md */
  --max-w-list:          1600px;  /* table/list pages */
  --max-w-detail:        1200px;  /* detail/form pages */
  --sidebar-col:         380px;   /* right info panel on detail pages */
}

/* ── Layout — breakpoint overrides (spacing-via-CSS-tokens contract) ── */
@media (min-width: 48rem) {
  :root {
    --page-padding-x: 24px;
    --page-padding-y: 24px;
  }
}

@media (min-width: 64rem) {
  :root {
    --page-padding-x: 32px;
  }
}

/* ── Dark Mode ───────────────────────────────────────────────
   Swap variable values — zero component changes needed.        */
[data-theme="dark"] {
  --color-bg:       #09090B;
  --color-bg-card:  #18181B;
  --color-fg:       #FAFAFA;
  --color-fg-muted: #A1A1AA;
  --color-border:   #27272A;
  /* accent, status, shadow tokens remain the same */
}

Typography Scale

Style Size Weight Letter spacing Usage
Page title 28px 700 <h1> on every page
Section heading 18px 600 Card titles, section labels
Body / label 14px 500 General text, form labels
Meta / muted 13px 400 Secondary info, timestamps, help text
Table header 11px 600 0.5px All-caps column headers
LOT / ID code 13px 600 Monospace, accent color on soft bg

Font: Plus Jakarta Sans, loaded from Google Fonts via tokens.css import.


Component Layers

components/ui/ — shadcn Primitives

Owned copies of shadcn/ui components (not an npm package — files live in the repo). Use CVA variants for all multi-variant components.

// CVA pattern — invalid variant values are compile errors
const buttonVariants = cva('base-classes', {
  variants: {
    variant: { default: '...', destructive: '...', outline: '...' },
    size:    { sm: '...', md: '...', lg: '...' },
  },
  defaultVariants: { variant: 'default', size: 'md' },
})

Rules: - Always display-only. No fetching, no routing, zero domain knowledge. - Primitives planned for Foundation M2: Button, Badge, Card, Dialog, Input, Label, Select, Table, Textarea, Tooltip, Popover, Toast, Checkbox, Switch, Separator, Tabs.

components/display/ — Display Primitives

Pure display components shared across table cells and detail pages. No fetching, no domain-specific logic.

DateDisplay

<DateDisplay value="2026-06-15" />                    // "15 cze 2026"
<DateDisplay value="2026-06-15" format="relative" />  // "za 73 dni"
Uses date-fns for formatting, Intl.DateTimeFormat for locale-aware output.

QuantityDisplay

<QuantityDisplay value={125.5} unit="kg" />  // "125,5 kg"
Locale-aware number formatting.

BooleanDisplay

<BooleanDisplay value={true} />   // green checkmark
<BooleanDisplay value={false} />  // muted dash

DetailField — label + value layout

// Stacked variant (default) — label above value
<DetailField label={t('item.shelfLife')} value={<QuantityDisplay value={item.shelf_life_days} unit="day" />} />

// Inline variant — label left, value right
<DetailField label={t('item.type')} variant="inline">
  <ItemTypeBadge type={item.item_type} />
</DetailField>
Used on detail pages. Same display primitives as table cells — never duplicate formatting logic.

Enum-to-variant badge components

Pattern for any string-enum value the module wants to render as a badge — whether it's a status (a state-machine value with transitions, e.g. Recipe.status: Draft | Published | Archived) or a fixed classification (e.g. Item.item_type). The badge owns the value → variant + i18n-label mapping. Always maps on string value, never on id (ids are environment-dependent).

// Illustrative (manufacturing module — Recipe.status is a real status: it transitions
// Draft → Published → Archived). Per-module file would live at
// features/manufacturing/components/RecipeStatusBadge.tsx.
const RECIPE_STATUS_VARIANT_MAP: Record<RecipeStatus, BadgeVariant> = {
  draft:     'secondary',
  published: 'success',
  archived:  'muted',
}

export function RecipeStatusBadge({ status }: { status: RecipeStatus }) {
  const { t } = useTranslation('manufacturing')
  return <Badge variant={RECIPE_STATUS_VARIANT_MAP[status]}>{t(`recipeStatus.${status}`)}</Badge>
}

The mapping is a module-level constant — never built inside the component body. For fixed classifications (item type, document type), the same pattern applies — the component name reflects the concept (ItemTypeBadge), not *StatusBadge.

components/common/ — App-Aware Reusables

Have domain or UX knowledge. Know that this is a Polish ERP with standard action patterns.

Semantic action buttons — carry their own translated text and icon. Pages never hardcode button labels.

<SaveButton onClick={handleSubmit} isLoading={mutation.isPending} />
<CancelButton onClick={handleCancel} />
<DeleteButton onClick={handleDelete} />

All semantic buttons accept an optional requiredPermission?: Permission prop. When set and the current user lacks the permission, the button renders disabled with a tooltip — see docs/standards.md § Permission-Gated UI for the full pattern.

<DeleteButton requiredPermission={Permission.ITEM_DELETE} onClick={handleDelete} />

Disabled states

There are three distinct "disabled" reasons; the visuals differ because their interaction model differs.

Reason Trigger Styling Pointer events Why
Form not ready submit button while RHF invalid, async work in flight disabled:opacity-50 disabled:pointer-events-none (shadcn default) off nothing useful to communicate on hover; clicks are no-ops
Permission denied <PermissionGate> / requiredPermission prop opacity-50 cursor-not-allowed (no pointer-events-none) on tooltip needs hover to fire; without pointer events the user gets no feedback
Business-rule disabled <DisabledWithReason reason={...}> — non-permission block (e.g. server-side guard would 409) opacity-50 cursor-not-allowed (matches permission-denied) on tooltip explains why the action is blocked so the user knows what to fix

The <PermissionGate> wrapper (Foundation M2 + identity) sets the second variant. Don't reach for disabled:pointer-events-none on a permission-denied control — the tooltip is the whole point.

Business-rule disabled — <DisabledWithReason>. Use this wrapper when a control should be disabled for a reason other than missing permission — typically a server-side business rule the FE can pre-empt (e.g. "Cannot delete a group with assigned items — reassign first" would otherwise return ITEM_GROUP_HAS_MEMBERS 409). The wrapper lives at src/components/common/DisabledWithReason.tsx (added by a Foundation M0 prerequisite ticket).

<DisabledWithReason
  reason={hasMembers ? t('item-catalog:groupDetail.deleteBlockedReason') : null}
>
  <DeleteButton onClick={handleDelete} />
</DisabledWithReason>
  • reason: string | nullnull (or empty string) means the wrapper is a pass-through and renders its child unchanged. Setting reason disables the child and surfaces the text as a tooltip on hover.
  • i18n is mandatory. reason is always the output of t(...) — never a hardcoded string. The same rule as every other visible string in the app (see CLAUDE.md § i18n quick reference at the repo root).
  • Key naming convention: <module-namespace>:<screen>.<actionVerb>BlockedReason — e.g. item-catalog:groupDetail.deleteBlockedReason, manufacturing:productionRunDetail.cancelBlockedReason. The implementing module ticket owns the key (added to src/locales/{en,pl}/<module>.json and listed in the module's i18n-keys.md).
  • Composes with <PermissionGate>, outside-in: <PermissionGate><DisabledWithReason reason={...}>…</DisabledWithReason></PermissionGate>. Permission denial wins (control is disabled with the permission tooltip); the business-rule reason is the layer beneath and only surfaces when permission is granted.
  • Tooltip placement follows the § Tooltip placement convention below — side="left" for end-of-row actions, side="top" for header buttons.

Tooltip placement

  • Default: side="top" for inline buttons, header actions, and standalone controls.
  • End-of-row action buttons (table row Edit/Delete icons): side="left". Right-side placement clips against the table edge.
  • Form-field-adjacent help icons: side="right". Keeps the field column scannable top-to-bottom.

These are conventions, not hard rules — override per case if a real overlap appears.

FormActions — standard form footer layout. Slot-based: helperSlot (left, helper / metadata text), actionsSlot (right, semantic buttons). Sticks to the bottom of the scrolling form by default; pass sticky={false} when embedding inside a FormDialog footer:

<FormActions
  helperSlot={<span className="text-xs text-fg-muted">{t('item-catalog:page.itemCreate.helperImmutable')}</span>}
  actionsSlot={<><CancelButton onClick={handleCancel} /><SaveButton isLoading={mutation.isPending} /></>}
/>
// Renders: [helper text] ……………………………… [Cancel] [Save]

FilterActions — standard filter bar footer:

<FilterActions onReset={handleReset} onApply={handleApply} />
// Renders: [Reset] [Apply filters]

ConfirmDialog — generic destructive action confirmation:

<ConfirmDialog
  open={open}
  onConfirm={handleDelete}
  onCancel={() => setOpen(false)}
  title={t('common:confirm.deleteTitle')}
  description={t('common:confirm.deleteDescription')}
/>

FormDialog — standard wrapper for create/edit forms in a modal:

<FormDialog open={open} onOpenChange={setOpen} title={t('item.newTitle')}>
  <ItemForm onSuccess={() => setOpen(false)} />
</FormDialog>

Feature forms work identically as a full page and inside FormDialog. The onSuccess callback handles navigation or close depending on context — no duplication.


Display Primitives Across Contexts

Same primitive component, different layout wrapper — never duplicate formatting logic.

// In a table column definition:
{
  accessorKey: 'shelf_life_days',
  header: t('item.shelfLife'),
  cell: ({ row }) => <QuantityDisplay value={row.original.shelf_life_days} unit="day" />,
}

// On a detail page:
<DetailField label={t('item.shelfLife')}>
  <QuantityDisplay value={item.shelf_life_days} unit="day" />
</DetailField>

One DateDisplay, one QuantityDisplay — used everywhere.


Tables

Row density

Lists in this ERP serve dense, scan-heavy workflows. The shared <DataTable> (built in the item-catalog M0 prerequisite at src/components/display/DataTable.tsx) exposes a density prop with two values:

Density Default for Vertical cell padding Header row height
compact (default) All list pages py-2 (8px) h-9 (36px)
comfortable Detail-page sub-tables, dialogs with table content py-3 (12px) h-11 (44px)

Horizontal cell padding is fixed at px-4 for both modes — keeps tables aligned with header cards and filter bars that already use px-4. Rows-per-page perception is controlled by vertical rhythm alone.

Never set <th> / <td> padding directly on <DataTable> consumers. If a screen needs a density outside these two values, update this spec first.

// Default — list pages
<DataTable columns={columns} data={rows} />

// Detail-page sub-table or dialog with table content
<DataTable columns={columns} data={rows} density="comfortable" />

Responsive Design

Three documents own different layers of the responsive story; this section is the visual layer.

Layer Doc Owns
Policy responsive-strategy.md Which surfaces matter (desktop primary, phone secondary, tablet free-ride), the per-screen Excellent/Workable/Soft-gated rubric, and what we explicitly defer.
Per-primitive contracts architecture.md § Responsive contracts The implementation contract table — for each shipped primitive (AppShell, Dialog family, PageHeader, FilterBar, DataTable↔DataCardList, Stepper, FormActions, …), what it renders at each breakpoint. The useBreakpoint() / useIsBelow() hook signatures.
Tokens + patterns (this section) design-system.md § Responsive Design Breakpoint tokens, the table↔cards pairing pattern, dialog responsive layout, touch targets, tooltip-on-touch.
Review checklist component-audit-checklist.md What a reviewer ticks off when auditing a component for responsive correctness.

Treat this section as the source of truth for visual conventions — breakpoint names, target sizes, paired-component swap rules. For what a given component does at a given width, go to architecture.md § Responsive contracts.

Breakpoint tokens

Tailwind v4 defaults. No custom values — use the prefix names directly.

Token Min width Tailwind prefix Typical device
sm 640px sm: Large phone landscape, small tablet portrait
md 768px md: Tablet portrait — feature-parity threshold
lg 1024px lg: Small laptop, tablet landscape
xl 1280px xl: Laptop, primary desktop target
2xl 1536px 2xl: Large desktop, external monitor

Components consume these via Tailwind prefixes for structural layout only (grid column counts, flex direction, show/hide). Spacing and colour come from CSS variables; never write md:p-8. See § Tailwind Usage Rules below.

Table ↔ Cards pairing

Information-dense list pages render two paired components, picked at runtime via useIsBelow('md'):

Breakpoint Component Notes
≥ md (≥ 768px) <DataTable> Default density="compact". All column definitions used as-is.
< md (< 768px) <DataCardList> Same field accessor functions as the table, rendered as a stack of cards with primary + secondary fields plus a row-action menu.

Never try to render a wide table as cards from one component — a 20-column DataTable that "auto-collapses" lies to both surfaces. The page picks at runtime:

import { useIsBelow } from '@/hooks/useBreakpoint'
import { DataTable } from '@/components/display/DataTable'
import { DataCardList } from '@/components/display/DataCardList'

const isMobile = useIsBelow('md')

const sharedProps = {
  data: rows,
  getRowId: (row: Row) => row.id,
  isLoading,
  isFetching,
  emptyState: <EmptyState  />,
  onRowClick: (row: Row) => navigate({ to: routes.catalog.itemDetail(row.id) }),
  rowActions: (row: Row) => <ItemRowActions item={row} />,
}

return isMobile ? (
  <DataCardList
    {...sharedProps}
    renderCard={(row) => (
      <article className="flex flex-col gap-1">
        <h3 className="text-base font-semibold text-fg">{row.name}</h3>
        <p className="text-sm text-fg-muted">{row.code}</p>
        <ItemTypeBadge type={row.item_type} />
      </article>
    )}
  />
) : (
  <DataTable {...sharedProps} columns={columns} />
)

The page declares the shared data, row-id resolver, loading/fetching flags, empty state, and row actions once. DataTable reads its columns prop; DataCardList reads its renderCard prop (caller-controlled JSX — typically a primary heading, 2–4 secondary fields, and a status badge). Both components host an identical state machine (loading skeleton, empty slot, fetching overlay) so the swap is invisible to the surrounding code. Per-row actions render in the table's actions column on desktop and in the card's top-right corner on mobile — interactive elements inside rowActions must call event.stopPropagation() when onRowClick is also set.

Dialog responsive behavior

Breakpoint Layout
≥ sm (≥ 640px) Centred modal with a soft backdrop. Width clamps to min(560px, calc(100vw - 2 * --page-padding-x)) for forms; wider variants opt in via the size prop.
< sm (< 640px) Fullscreen Sheet slide-up. Body scrolls; sticky action footer pinned to the bottom of the viewport (above the safe-area inset).

The transformation is internal to the Dialog primitive — consumers pass the same children and never branch on breakpoint. The sticky footer on mobile re-orders action buttons into a vertical stack (primary on top, secondary below) so the thumb-zone always reaches the confirming action.

Touch targets

Minimum 44×44 px hit area for any interactive element on touch devices. Visual size can be smaller as long as the hit area meets the bar via padding or ::before pseudo-elements.

Applies to:

  • Icon buttons (sidebar collapse, dialog close, row-action overflow trigger).
  • List-row actions (every clickable cell in a DataCardList, every overflow menu trigger in a DataTable row).
  • Remove-tag chips (the × on a filter chip or selected pill in a multi-select).
  • Pagination page-number buttons.

Tailwind helper: min-h-[44px] min-w-[44px] for raw bounds; the <IconButton> primitive bakes this in by default. Hover states are not used as the only affordance — touch users won't see them.

Tooltip on touch

The default <Tooltip> primitive is hover-only — it does not appear on touch devices. This is deliberate; touch tooltips that trigger on a long-press are confusing and steal focus from row taps.

Two policies depending on the tooltip's content:

Tooltip type Touch behaviour
Decorative (e.g. "Last edited 3h ago" hover on an avatar) Accept that touch users don't see it. The visible context (avatar + name) is sufficient; the tooltip is a desktop polish, not a load-bearing affordance.
Non-decorative (e.g. <DisabledWithReason> explaining why a button is disabled) Use the <TouchTooltip> variant — opens on tap, dismisses on outside-tap. Renders as a small Popover-style chip with the explanation text.

Disabled buttons that gate permission-protected actions are the canonical non-decorative case — see standards.md § Permission-Gated UI.


Dark Mode

CSS variable swap under [data-theme="dark"] — components need zero changes. Apply the attribute to <html> or <body> via Zustand's useUIStore.

const toggleTheme = () => {
  document.documentElement.dataset.theme =
    document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark'
}

Tailwind Usage Rules

Responsive prefixes (md:, lg:) for structural layout only: - Grid column counts: grid-cols-1 md:grid-cols-2 lg:grid-cols-3 - Flex direction: flex-col md:flex-row - Show/hide: hidden md:block

Never for spacing or color — those come from CSS token variables. Spacing tokens change at breakpoints in CSS; components adapt automatically with no breakpoint classes needed.

// ✓ — token-driven spacing, responsive structure via grid
<div className="grid grid-cols-1 lg:grid-cols-[1fr_var(--sidebar-col)] gap-6">

// ✗ — responsive padding via Tailwind classes (use token instead)
<div className="p-4 md:p-8">

Outer Page Gutter Contract

Authenticated pages get their outer gutter (the breathing room between the app-shell chrome and the page content, on both axes) from .page-container / .page-container-detail — not from <main> inside AppShell.

  • <main> in AppShell (both desktop and drawer mode) intentionally has no padding. It's the layout host; the gutter belongs to whatever page-level wrapper sits inside it.
  • <PageContainer> (and its variant="detail") stamps the .page-container / .page-container-detail class, which applies padding-inline: var(--page-padding-x) and padding-block: var(--page-padding-y). The token values are responsive (16/24/32 px on the inline axis, 16/24 px on the block axis) — pages get the right gutter at every breakpoint with no per-page breakpoint utilities.
  • Any new page archetype that does not wrap its content in <PageContainer> is responsible for providing its own outer padding via the same tokens. Today every page archetype uses <PageContainer> — keep it that way.
  • Load-bearing invariant: @import 'tailwindcss' must remain the first import in src/styles/index.css (see § CSS Token Architecture). Any reorder that moves Tailwind below the custom CSS demotes our @layer components rules below Tailwind's @layer base preflight, and * { padding: 0 } silently zeros every gutter on the site. The unit test src/styles/index.test.ts codifies this and trips during pnpm test on any reorder.

@layer components in components.css — common class combinations extracted as named classes:

/* src/styles/components.css */
@layer components {
  .card {
    background: var(--color-bg-card);
    border: 1px solid var(--color-border);
    border-radius: var(--radius-xl);
    box-shadow: var(--shadow-card);
    padding: 24px;
  }

  .input-base {
    border: 1px solid var(--color-border);
    border-radius: var(--radius-xl);
    background: var(--color-bg-card);
    color: var(--color-fg);
    font-size: 14px;
  }

  .page-container {
    max-width: var(--max-w-list);
    margin-inline: auto;
    padding-inline: var(--page-padding-x);
    padding-block: var(--page-padding-y);
  }

  .page-container-detail {
    max-width: var(--max-w-detail);
    margin-inline: auto;
    padding-inline: var(--page-padding-x);
    padding-block: var(--page-padding-y);
  }

  .form-grid {
    display: grid;
    grid-template-columns: repeat(2, 1fr);
    gap: 16px;
  }

  .table-container {
    background: var(--color-bg-card);
    border: 1px solid var(--color-border);
    border-radius: var(--radius-xl);
    overflow: hidden;
  }
}