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"
Intl.DateTimeFormat for locale-aware output.
QuantityDisplay
Locale-aware number formatting.BooleanDisplay
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>
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.
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 | null—null(or empty string) means the wrapper is a pass-through and renders its child unchanged. Settingreasondisables the child and surfaces the text as a tooltip on hover.- i18n is mandatory.
reasonis always the output oft(...)— never a hardcoded string. The same rule as every other visible string in the app (seeCLAUDE.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 tosrc/locales/{en,pl}/<module>.jsonand listed in the module'si18n-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:
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 aDataTablerow). - 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>inAppShell(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 itsvariant="detail") stamps the.page-container/.page-container-detailclass, which appliespadding-inline: var(--page-padding-x)andpadding-block: var(--page-padding-y). The token values are responsive (16/24/32px on the inline axis,16/24px 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 insrc/styles/index.css(see § CSS Token Architecture). Any reorder that moves Tailwind below the custom CSS demotes our@layer componentsrules below Tailwind's@layer basepreflight, and* { padding: 0 }silently zeros every gutter on the site. The unit testsrc/styles/index.test.tscodifies this and trips duringpnpm teston 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;
}
}