Skip to content

DESIGN SYSTEM (v2)

VietGara — Multi-Tenant SaaS Platform for Auto Garage Management & Maintenance

Version 2.0 (approved and implemented) — October 7, 2026 · Web: React + TypeScript + Tailwind CSS · Mobile: Flutter

v2 replaces v1 (../v1/03_Design_System.md) for all work (approved by the product owner on October 7, 2026; implemented in the web apps and the Flutter core package). The rationale and the audit of the existing apps are in 04_UI_Audit_and_Redesign_Proposal.md; screens are specified in 02_UI_Design_Spec.md; UX rules in 01_UX_Guideline.md.

1. Identity and Principles

1.1. Character

VietGara is a tool for people doing exact work: owners reading numbers, accountants reconciling payments, advisors quoting while a customer waits. The interface should feel calm, precise and quietly premium — a well-run workshop, not a marketing site. Name for this direction: Workshop Precision.

QualityWhat it means in practice
CalmNeutral surfaces, one accent color, status colors only for things that need attention, motion only to explain change
PremiumTight alignment, consistent rhythm, crisp hairlines instead of shadows, careful type with tabular numbers; nothing looks default
Information-dense, not cluttered14px body, 48px (default) / 40px (compact) table rows, hierarchy through weight and color rather than boxes and borders
Highly usablePrimary action obvious on every screen, keyboard-complete, states (loading, empty, error, success) always designed
ConsistentOne component for each job; no container-specific styling; tokens only
ResponsiveWorks from 360px to wide desktop; tables become card lists on phones
AccessibleWCAG 2.2 AA minimum, in both themes

1.2. Signature elements (what makes it VietGara, not a template)

  1. Plate chip. The license plate is the vehicle’s identity everywhere (lists, kanban cards, headers). It is a recurring, dedicated component: white plate, 1.5px ink border, mono caps with letter-spacing, 4px radius — and it stays “plate-colored” in dark mode.
  2. Ledger tables. Tables read like a clean ledger: hairline row rules, no zebra, no cell borders, sticky header in small-caps-free 12px medium, numbers right-aligned in tabular figures, the currency symbol de-emphasized (1.250.000 ₫).
  3. Status rail. Records whose state matters (repair-order cards, overdue settlements, low-stock rows) carry a 3px left rail in the status tone plus an icon. State is readable without color.
  4. Status vocabulary. One domain-specific mapping from status to tone and icon (Section 3.2.4), used by badges, rails, filters and charts alike.
  5. Quiet shell, loud work. A light sidebar and a 56px header recede; the page content carries contrast. Navy appears only in the brand mark and the sign-in hero.
  6. Spot illustrations. Six small duotone vehicle/workshop SVGs for empty and error states, in primary and neutral tints — the only illustrative decoration in the product.

1.3. Explicit anti-goals

No more than one subtle gradient (sign-in hero only). No glassmorphism or blur surfaces. No shadows on resting cards. No card-in-card nesting beyond one level. No radius above 16px except avatars and pills. No heading above 32px. No icon-in-tinted-square decoration. No random colors: every color in code is a token. No animation that delays the user or loops without reason.

2. Technology and Architecture

ConcernChoiceNotes
UI libraryReact + TypeScriptAs the current apps
StylingTailwind CSS v4 with tokens in @themeUtilities map to CSS variables; no free-form hex/px in components
Component baseshadcn/ui pattern over Radix UI primitives, in our own repoWe copy components in and restyle them to this document. The shadcn defaults (neutral-zinc palette, rounded-md everywhere, default shadows) are not used
Variantsclass-variance-authority + tailwind-mergeOne variant API per component
IconsLucide (lucide-react)16 / 20 / 24px, stroke 1.75
TokensCSS custom properties, light on :root, dark on [data-theme="dark"]Authored once as JSON (W3C design-token shape), compiled to CSS variables and Dart constants
MotionMotion (motion/react) for presence, layout and shared-element animation; CSS transitions for hover/focus/pressNothing else animates (Section 3.6)
Toast, palette, formssonner, cmdk, react-hook-form + zod (only for large forms)Approved in the proposal
Docs and testsComponent gallery in the kit (npm run dev; Storybook with the a11y addon is the planned upgrade), Vitest (token contrast test), Playwright via vietgara-e2eToken-contrast test fails the build below threshold
HomeNew repo vietgara-web-ui, consumed by vietgara-owner-web and vietgara-admin-web as a tag-pinned dependencyMirrors vietgara-flutter-core; no copies of styles or components in apps

Layering. Tokens (CSS variables) → primitives (Radix, unstyled) → kit components (Button, DataTable, …) → domain components in the apps (KanbanBoard, LineItemsEditor, PermissionGrid, GarageSwitcher) → pages. A layer may use only the layer above it in this list. Components use semantic tokens only — never primitives, never literals.

2.1. Implementation notes (October 2026)

Recorded after the first implementation (@vietgara/web-ui v0.1.2, vietgara_core v0.4.0). Where the code differs from the text below, the code is the decision:

  • Form controls stay native where form semantics matter. Select, Checkbox and Radio are styled native elements (NativeSelect, Checkbox, Radio), not Radix: they work with FormData, mobile pickers and autofill, and meet the contrast rules through tokens. Radix is used for Dialog, Sheet, Dropdown Menu, Popover, Tooltip, Tabs, Switch, Toggle Group (segmented control) and Avatar. The searchable Combobox is an ARIA 1.2 combobox in the apps (SearchSelect); cmdk powers the command palette.
  • Motion. Overlays (dialog, sheet, popover, tooltip) animate with CSS keyframes driven by Radix data-state; Motion is used through LazyMotion (domMax) for the route fade and board layout animation, and honors prefers-reduced-motion.
  • Packaging. The kit is source-only TypeScript installed from a git tag over HTTPS (git+https://github.com/viettechno/vietgara-web-ui.git#vX.Y.Z); apps compile it (optimizeDeps.exclude). Docker builds install git. A gallery app (npm run dev in the kit) replaces Storybook for now.
  • Collapsible sidebar (@vietgara/web-ui v0.2.0): state in localStorage key vietgara.sidebar; apps pass the two toggle labels to AppShell and use useSidebarCompact() for content a rail hides (the wordmark).
  • Required marker is generated content (::after), so a label’s text stays the plain label for assistive technology and test selectors.
  • Tables stack into cards below 768px through data-label on cells (CSS only), not a second markup tree.
  • Mobile. vietgara_core ships the same tokens as ThemeData plus a VgColors theme extension (context.vg); fonts are bundled TTFs; status colors, plate chip, skeleton and illustration widgets mirror the web components.

3. Foundations

3.1. Typography

Families. Be Vietnam Pro (UI and display; complete Vietnamese diacritics), self-hosted woff2 with font-display: swap, weights 400, 500, 600 (700 only for display numerals if needed). JetBrains Mono for plates, codes, OTP and IDs. Fallback Inter, system-ui, sans-serif. Fonts are never loaded from a third-party CSS @import.

TokenSize / line-heightWeightLetter-spacingUsed for
display32px / 40px600−0.02emSign-in hero, plan prices (max size in the product)
title24px / 32px600−0.015emPage title
heading18px / 28px600−0.01emPanel / section title
subheading16px / 24px600−0.005emDialog title, card title
body14px / 20px4000Default UI text, tables, forms
body-strong14px / 20px5000Row titles, labels, nav items
body-lg16px / 24px4000Public quote page, mobile inputs, long text
caption12px / 16px500+0.01emTable headers, badges, hints, timestamps
mono13px / 20px500+0.04emPlates, codes

Rules: sentence case everywhere, no all-caps labels except plate chips; tabular numerals (font-variant-numeric: tabular-nums) on money, quantities, dates in tables and KPI values; money right-aligned in tables; heading level reflects document structure, size comes from the role token; minimum text size 12px; line length for prose ≤ 72ch; Vietnamese strings run ~30% longer than English — never fix widths on text containers.

3.2. Color

3.2.1. Semantic tokens

Names follow the shadcn convention (so generated components and docs map cleanly) and extend it with surface, subtle and status roles. Secondary is a neutral tint, not a second brand hue — VietGara has one accent.

TokenRoleLightDark
--backgroundApp background (page)#F6F8FA#0F141B
--foregroundPrimary text#151A23#E6EAF0
--surfacePanels, tables, cards (shadcn card)#FFFFFF#171D26
--surface-foregroundText on surface#151A23#E6EAF0
--surface-sunkenTable header, inset areas, code#EEF1F5#12171F
--popoverMenus, popovers, dialogs#FFFFFF#1C232E
--mutedQuiet fills (skeleton, disabled, tags)#EEF1F5#1F2732
--muted-foregroundSecondary text, hints, placeholders#5F6A7B#8D99AB
--borderHairlines and dividers (decorative)#E1E6ED#27303D
--inputControl outlines (≥ 3 : 1)#7C8798#6C788D
--ringFocus ring#3B82D6#6FA8E8
--primaryBrand action#1F66B3#2F73BF
--primary-foregroundText on primary#FFFFFF#FFFFFF
--primary-hoverHover/pressed#194F8A#3F82CF
--primary-subtleSelected/active tint#EFF5FC#14263D
--primary-subtle-foregroundText on tint, links#194F8A#9CC4F0
--secondarySecondary button fill#EEF1F5#1F2732
--secondary-foreground#151A23#E6EAF0
--accentHover on rows/menu items#EFF3F8#1C232E
--accent-foreground#151A23#E6EAF0
--destructiveDestructive action#C62E24#C62E24
--destructive-foreground#FFFFFF#FFFFFF
--destructive-subtle / -subtle-foregroundError banners, badges#FDECEA / #B0261C#3A1512 / #FF8A80
--success / --success-foregroundSolid success#237A45 / #FFFFFF#2E8F57 / #0F141B
--success-subtle / -subtle-foreground#E8F5EC / #1B6B3A#12301F / #6FD09A
--warning / --warning-foregroundSolid warning#E8A317 / #151A23#E8A317 / #151A23
--warning-subtle / -subtle-foreground#FFF4E0 / #8A5300#33230A / #F2B756
--info / --info-foregroundSolid info#0E7490 / #FFFFFF#2C8EAD / #0F141B
--info-subtle / -subtle-foreground#E6F4F8 / #0B5E75#14303A / #7DD0E8
--sidebar / --sidebar-foregroundShell#FFFFFF / #394252#12171F / #A3AEBF
--sidebar-active / -foregroundActive nav item#EFF5FC / #194F8A#14263D / #9CC4F0
--plate / --plate-foregroundPlate chip#FFFFFF / #151A23#E6EAF0 / #0F141B

Note: the dark theme’s solid-status and --info values are approximations to be tuned in the gallery; the contrast thresholds below are hard requirements and are enforced by test. --warning text on its solid fill is dark (white on amber fails 4.5 : 1). --info is a cool teal, deliberately distinct from the brand blue.

Verified contrast (computed): foreground on background 17.4 : 1 (light), 15.3 (dark); muted-foreground on surface 5.5 (light), 5.9 (dark), on background 5.1 (light), 6.4 (dark); primary-foreground on primary 5.8 (light), 4.85 (dark); destructive-foreground on destructive 5.5; subtle status text on subtle fills 5.8–7.6 (light), 6.9–8.4 (dark); --input on surface 3.6 (light), 3.8 (dark); --ring on background 3.9 (light), 7.4 (dark).

3.2.2. Primitive scales (source for the semantic tokens)

Scale50100200500600700800900
Primary (Gara Blue)#EFF5FC#DCE9F8#B9D3F0#3B82D6#1F66B3#194F8A#163F6D#14325A
Neutral (Slate)#F6F8FA#EEF1F5#E1E6ED#6A7585#4F5A6B#394252#252C38#151A23

Anchors only; the full 11-step scales are generated and contrast-checked at implementation. Components never reference a primitive directly.

3.2.3. Data visualization

Categorical series (max 5): blue #1F66B3, teal #0E7490, violet #6B52B0, amber #B26A00, slate #6A7585; dark: #6FA8E8, #4FB8D4, #A99BE0, #E8A317, #8D99AB. Each is ≥ 3 : 1 against its surface. Single-series charts use primary. Status colors are reserved for status (paid / overdue), not for series. Always add direct labels or patterns where series could be confused.

3.2.4. Status vocabulary

DomainStatus → tone · icon
QuoteDraft → neutral · FilePen — Sent → info · Send — Approved → success · CircleCheck — Rejected → destructive · CircleX — Converted → neutral · ArrowRightLeft
Repair orderTo do → neutral · Circle — In progress → info · Wrench — Waiting on parts → warning · Hourglass — Completed → success · CircleCheck — Closed → neutral · Lock
SettlementPending → warning · Clock — Partially paid → info · CircleDashed — Paid → success · CircleCheck — Overdue → destructive · AlarmClock
Account / planActive → success — Trial → info — Past due / read-only → warning — Suspended / cancelled → destructive

3.3. Spacing and Sizing

4px base scale — tokens 0, 1 (4), 2 (8), 3 (12), 4 (16), 5 (20), 6 (24), 8 (32), 10 (40), 12 (48), 16 (64). No other values.

UseValue
Inside controls (horizontal padding)12px
Label ↔ control6px
Between form fields16px (20px with helper text)
Panel padding20px desktop, 16px phone
Between panels / sections24px
Page gutter16 (phone) / 24 (tablet) / 32 (desktop)
Content max width1280px (tables and Kanban may be full-width)

Control heights: sm 32, md 36 (default), lg 40. On pointer: coarse devices every interactive element gets a 44 × 44px hit area (extended with padding/pseudo-element, not necessarily visible size) and text inputs use 16px text. Table rows: 48px default, 40px compact. Header 56px, sidebar 256px (rail 64px).

Breakpoints: sm 640, md 768, lg 1024, xl 1280 (primary design target). Mobile-first; use container queries for components placed in variable-width regions.

3.4. Radius

TokenValueUsed for
radius-sm4pxBadge, checkbox, plate chip, tooltip
radius-md8pxButton, input, select, tabs, menu item
radius-lg12pxPanel, card, popover, toast
radius-xl16pxDialog, sheet
radius-full9999pxAvatar, switch, count pill

Nested radii step down (a 12px panel contains 8px controls). No other values; no radius above 16px on rectangular surfaces.

3.5. Elevation and Shadows

Hairlines separate; shadows lift only what floats. Resting surfaces (panels, cards, tables) have no shadow.

LevelTokenValue (light)Used for
0shadow-nonenone, 1px --borderPanels, cards, table containers, sidebar
1shadow-sm0 1px 2px rgb(21 26 35 / 6%)Buttons (secondary), sticky bars, segmented thumb
2shadow-md0 6px 16px rgb(21 26 35 / 10%)Popover, dropdown, select list, toast, command palette
3shadow-lg0 16px 40px rgb(21 26 35 / 18%)Dialog, sheet, dragged Kanban card

Dark theme: shadows are replaced by a 1px --border ring plus --popover being one step lighter than --surface; opacity increases to 45% where a shadow is still needed. Backdrop for dialogs: rgb(15 20 27 / 48%), no blur.

3.6. Motion

Motion explains change; it never decorates. CSS transitions (hover, focus, press, color, tab indicator) and Motion (mount/unmount, layout, list reorder, sheet and dialog presence) — nothing else.

TokenValueUsed for
duration-fast100msHover, press, focus, checkbox
duration-base160msPopover, tooltip, tab indicator, toast in
duration-slow240msDialog, sheet, route content fade, skeleton → content
ease-standardcubic-bezier(0.2, 0, 0, 1)Enter, move
ease-exitcubic-bezier(0.4, 0, 1, 1)Leave (≈ 70% of enter time)

Rules: animate only opacity, transform and color; ≤ 240ms per interaction; wrap Motion in MotionConfig reducedMotion="user" and neutralize CSS motion under prefers-reduced-motion: reduce (instant opacity changes remain); no looping animation except spinner and skeleton shimmer (≤ 1.5s loop, static under reduced motion); numbers cross-fade, never count up; spinners appear only after a 300ms delay.

3.7. Iconography

Lucide, sizes 16 (in text, badges, buttons, table rows), 20 (navigation, inputs), 24 (dialog and page-level); stroke 1.75; currentColor. Icons are aria-hidden unless they are a control’s only content, in which case the control has an accessible label (and a tooltip). Status icons come from Section 3.2.4 and are reused identically everywhere. No icons inside tinted squares as decoration.

3.8. Layering (z-index)

base 0 · sticky 10 · sidebar 20 · header 30 · dropdown/popover 40 · sheet 50 · dialog 60 · toast 70 · command palette 80. Radix portals render at the document root.

3.9. CSS variable reference

:root {
/* color (semantic) — see 3.2.1 for the complete list */
--background: #f6f8fa; --foreground: #151a23;
--surface: #ffffff; --surface-sunken: #eef1f5;
--muted: #eef1f5; --muted-foreground: #5f6a7b;
--border: #e1e6ed; --input: #7c8798; --ring: #3b82d6;
--primary: #1f66b3; --primary-foreground: #fff; --primary-hover: #194f8a;
--destructive: #c62e24; --success: #237a45; --warning: #e8a317; --info: #0e7490;
/* shape and motion */
--radius-sm: 4px; --radius-md: 8px; --radius-lg: 12px; --radius-xl: 16px;
--shadow-sm: 0 1px 2px rgb(21 26 35 / 6%);
--shadow-md: 0 6px 16px rgb(21 26 35 / 10%);
--shadow-lg: 0 16px 40px rgb(21 26 35 / 18%);
--duration-fast: 100ms; --duration-base: 160ms; --duration-slow: 240ms;
--ease-standard: cubic-bezier(0.2, 0, 0, 1);
color-scheme: light;
}
[data-theme='dark'] {
--background: #0f141b; --foreground: #e6eaf0;
--surface: #171d26; --surface-sunken: #12171f;
--muted: #1f2732; --muted-foreground: #8d99ab;
--border: #27303d; --input: #6c788d; --ring: #6fa8e8;
--primary: #2f73bf; --primary-hover: #3f82cf;
color-scheme: dark;
}
@theme inline { /* Tailwind v4: utilities read the variables */
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-surface: var(--surface);
--color-primary: var(--primary);
/* …one line per semantic token… */
--radius-md: var(--radius-md);
--font-sans: 'Be Vietnam Pro', Inter, system-ui, sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, monospace;
}

Theme selection: follows prefers-color-scheme by default, user override stored on the user and applied before first paint to avoid a flash.

4. Components

Conventions for every component: built from a Radix primitive where one exists; variants via cva; styled by semantic tokens only; all states designed (default, hover, focus-visible, active/pressed, disabled, loading, error where relevant); focus ring = 2px --ring with 2px offset, :focus-visible only; disabled = reduced contrast plus not-allowed cursor (never wait); labels are text, not placeholders; every icon-only control has an accessible name.

4.1. Button

  • Variants: primary (solid --primary, one per view), secondary (--secondary fill, shadow-sm), outline (transparent, --input border; for toolbar actions), ghost (no fill; row/inline actions, hover --accent), destructive (solid --destructive; only inside confirmations or clearly destructive spots), link (inline text). Sizes: sm 32 / md 36 / lg 40; radius md; label body-strong; icon 16px with 8px gap. IconButton: square, requires label, shows tooltip.
  • States: hover = --primary-hover / --accent; pressed = darker step + 0.5px translate; loading = spinner replaces the leading icon, width and label stay, aria-busy, click disabled; disabled never hides the reason (tooltip or helper text).
  • Rules: one primary per screen region; a verb label (“Send quote”, “Record payment”); navigation renders a link (asChild), not a button; destructive actions are never the default focus in a confirmation.

4.2. Input (text, number, password, search, textarea)

  • 36px height, radius-md, 1px --input border, --surface fill, 12px padding; placeholder --muted-foreground (an example, never the label). Focus: border --primary + 2px ring. Error: border --destructive + icon + message. Disabled: --muted fill.
  • Money inputs use NumberInput (thousand separators, ₫ suffix, inputmode="numeric", right-aligned, tabular). Search has a leading 16px icon and a clear button; shows result count in the list header, not inside the field. Password has a visibility toggle with label.
  • Always wrapped by Field (4.20); text is 16px on touch.

4.3. Select / Combobox

  • Select (Radix): same trigger styling as Input with a chevron; popover list shadow-md, items 36px, check mark on selected, keyboard typeahead. Use for ≤ 10 fixed options.
  • Combobox (cmdk + Popover): searchable, async, for customers, vehicles, parts, garages; shows two-line options (name + secondary detail such as plate or phone), loading row, “no results”, and a clear button. Native <select> is allowed only for the language switcher on sign-in pages and on touch fallback.

4.4. Checkbox

16px box (24px hit area, 44px on touch), radius-sm, --input border; checked = --primary fill with white check; indeterminate supported. Label to the right, clickable. Use for multi-select and “agree” flags; never for immediate on/off settings (use Switch).

4.5. Radio

16px circle, same rules as Checkbox. Use for 2–5 mutually exclusive, all-visible options; for 2 options that are modes (e.g. quote approval mode) prefer SegmentedControl. Group has a legend.

4.6. Switch

36 × 20 track, radius-full, on = --primary. Takes effect immediately (and says so); label on the left or above, with the current state in text where not obvious. Never inside a form that has a Save button — use a Checkbox there.

4.7. Dialog

Radix Dialog; --popover surface, radius-xl, shadow-lg, backdrop without blur; widths 400 (confirm) / 480 (form) / 720 (wide). Header subheading, optional one-line description; body scrolls, header and footer fixed; footer right-aligned: secondary left of primary; close button top-right. Behavior: focus moves in on open and back to the trigger on close; Esc closes the top layer; scroll locked; backdrop click closes non-destructive dialogs. Enter/exit via Motion: fade + 8px rise (240ms). Below md becomes a bottom sheet. Confirm dialogs name the object and consequence; destructive ones use the destructive button and, for the heaviest (deactivate garage, remove staff), require typing the name.

4.8. Drawer / Sheet

Radix Dialog presented from an edge: left for phone navigation, right (400–480px) for detail and filters (e.g. Kanban card detail), bottom for phone dialogs. radius-xl on the free corners, shadow-lg, same focus and dismissal rules as Dialog. Drag-to-dismiss on touch for bottom sheets. Use a Drawer when the user needs to keep context on the page behind; use a Dialog when a decision blocks.

4.9. Dropdown Menu

Radix DropdownMenu; --popover, radius-lg, shadow-md, 4px padding, items 32px (36px touch) with 16px icons, destructive items last and separated, shortcuts right-aligned in --muted-foreground. Row ”⋯” overflow menus carry the row subject in the trigger label (“Actions for Nguyen Van A”). Max ~7 items; more → rethink.

4.10. Tooltip

Radix Tooltip; --foreground background with --background text (inverted), radius-sm, caption, max 240px, 400ms open delay, 100ms close. Never the only place for essential information or interactive content; appears on focus as well as hover. Used for icon-only buttons, truncated text and disabled-reason hints.

4.11. Tabs

Radix Tabs; underline style (default): 2px --primary indicator that slides (160ms), label body-strong, inactive --muted-foreground; pill style inside panels. Optional Count pill. Arrow keys switch tabs; selected tab reflected in the URL when the view is shareable. Tabs switch views of one object; they are not for navigation between pages.

4.12. Card / Panel

--surface, 1px --border, radius-lg, no shadow, padding 20px (16px phone). Optional header (heading + actions on the right, hairline below) and footer. One level only — a panel never contains another bordered panel (use dividers and sunken areas). Interactive cards (plan choice, Kanban card) add hover border --input and a focus ring; selected = 2px --primary border. Use Stat strip for KPIs (one panel, dividers between figures).

4.13. Badge

caption, 22px height, radius-sm, 8px padding, optional 14px leading icon. Tone = neutral / info / success / warning / destructive using the *-subtle fill and *-subtle-foreground text; solid variant only for counts that demand attention (e.g. unread). Always text (+ icon) — color is never the only signal. Count is a separate radius-full pill (nav items, tabs, Kanban column headers). Status badges take their tone and icon from 3.2.4 via a single mapping per domain.

4.14. Avatar

Radix Avatar; circle 24 / 32 / 40; image with initials fallback on --muted with --muted-foreground text (no random pastel per person); optional presence dot. PersonCell = avatar + name (body-strong) + secondary line (caption, --muted-foreground). Garage logos use radius-md squares, not circles.

4.15. Table (DataTable)

  • Ledger style (1.2): 1px --border row rules, no cell borders, no zebra; header sticky, 12px/500 --muted-foreground on --surface-sunken, sort button with aria-sort; rows 48px (40px compact), hover --accent; selected row --primary-subtle; numbers right-aligned tabular; first column is the row’s identity (link, body-strong) and keeps a 3px status rail where state matters; truncated cells show a tooltip.
  • Row actions: ≤ 2 as ghost icon buttons, otherwise a ”⋯” menu; always labelled with the subject.
  • Toolbar above: search, filters (active filters as removable chips), primary action on the right; bulk-action bar replaces the toolbar on selection.
  • States: skeleton rows matching columns while loading; first-use empty state with CTA; “no results — Clear filters”; error with Retry; row-warning.
  • Below md renders as a card list: identity as card title, 2–3 key fields, badge, ”⋯” menu. No horizontal scroll for primary data; very wide matrices (permission grid) keep scroll with a sticky first column.

4.16. Pagination

Footer of the table panel: “1–20 of 134” (tabular) left; Previous / Next ghost buttons and, from md, page-number buttons (current = --primary-subtle, aria-current="page") right; page-size select optional. Disabled ends are --muted-foreground with not-allowed; enabled buttons use full-contrast text. Prefer server paging with stable sort; infinite scroll only for feeds and mobile card lists with a visible “Load more”.

4.17. Alert

Inline, in-flow message: *-subtle fill, 1px border in the tone at 30%, radius-lg, 16px padding, leading 20px icon, body-strong title, optional body text and one action. Tones: info (role="status"), success, warning, destructive (role="alert"). Use for conditions the user must notice and may act on (license trial/read-only, validation summary, failed load). Persistent plan notices span the content width under the header. Do not stack more than two; consolidate. Not for transient feedback — that is a Toast.

4.18. Toast

sonner: bottom-right (bottom-center on phone), --popover, radius-lg, shadow-md, 360px max, icon + one sentence + optional action (e.g. Undo). Success/info auto-dismiss in 5s (pause on hover/focus); errors persist until dismissed; announced politely (role="status", errors role="alert"). Never the only place for important information, and never for something that needs a decision.

4.19. Form

Layout: single column up to 480px per field group; two or three columns from md for short related fields (address parts, price/currency/cycle); group related fields in a FormSection (subheading + description, hairline) inside one panel; long forms use a sticky action bar (Save primary, Cancel ghost, “Unsaved changes” indicator). Validation on blur and on submit, after the first blur re-validate on change; on failed submit show an error summary (Alert) linking to each field and focus the first invalid field. Server errors map to fields via the API error code. Unsaved changes warn on navigation. Draft auto-save for long forms (“Saved 14:32”). Required fields are marked (* + aria-required), optional ones are not labelled “optional” unless most are required.

4.20. Field

The only way to place a control in a form: label (body-strong, above), control, helper text (caption, --muted-foreground) or error (caption, --destructive-subtle-foreground with 14px icon). The Field wires id, htmlFor, aria-describedby, aria-invalid, aria-required. Styling belongs to the control, so a field looks identical in a dialog, a panel or a page.

4.21. Navigation

  • Primary navigation lives in the Sidebar (4.22); secondary in Tabs (4.11); tertiary (record-level) in Breadcrumb and in-page links. Maximum two levels of nav in the sidebar; no nested accordions.
  • Command palette (cmdk, Ctrl/⌘ K): pages, garages, recent customers/vehicles/quotes, actions (“New quote”); every action also exists as visible UI.
  • Active location is indicated by position, not color alone: aria-current="page", bold label, left bar.
  • Garage switcher: popover (Combobox) in the header; switches in place.

4.22. Sidebar

256px expanded, 64px collapsed, --sidebar, 1px --border on the right, grouped (Operations · Customers · Finance · Garage · Account) with caption group labels; items 36px (44 touch), 20px icon + body-strong label, radius-md; hover --accent; active = --sidebar-active fill + 2px --primary bar on the inner left edge + --sidebar-active-foreground; count pills for pending items (invitations). Items follow permissions; items for future releases are hidden, not disabled, unless an upsell is intended.

Collapsing (desktop, ≥ lg). A toggle at the foot of the sidebar (PanelLeftClose / PanelLeftOpen, label “Collapse sidebar” / “Expand sidebar”, aria-expanded) switches between the expanded sidebar and a 64px icon rail: icons only, group labels hidden (a hairline separates groups), the wordmark hidden, counts as a small dot-badge on the icon, labels in tooltips. The choice is remembered per browser (default expanded) and the page content moves with it.

Peek. While the sidebar is a rail — collapsed by the user, or the resting state at md — it opens fully over the page (no content shift, shadow-lg, above the header) when the mouse has rested on it for 150 ms or when keyboard focus enters it, and closes when the pointer leaves or focus moves out. Touch has no peek: on tablets the rail’s icons are tapped, on phones the sidebar is the left Sheet below md. The motion is 160 ms on width and shadow and is removed under reduced motion. Hover alone is never the only way: the toggle and keyboard focus reach every state.

Items for future releases are hidden, not disabled, unless an upsell is intended.

4.23. Header

56px, --surface, hairline bottom. Left: menu button (phone), garage switcher (garage logo/initial, name, chevron), breadcrumb is not here. Right: command-palette trigger (“Search… ⌘K”), language, notifications bell with count, theme toggle (when enabled), user menu (avatar → profile, subscription, sign out). Sticky; content scrolls under with no shadow (hairline only). The Platform Admin header has no garage switcher.

4.24. Breadcrumb

Only on detail/edit pages (Quotes › BG-202610-0007): caption, --muted-foreground, current page --foreground with aria-current, separators decorative, collapse middle items into ”…” menu on phone; the last item is not a link. Not shown on top-level list pages (the sidebar already shows location).

4.25. Empty State

Centered in the region it replaces: spot illustration (≤ 120px), subheading title, one sentence of body --muted-foreground, one primary action (+ optional secondary link). Variants: first use (explains value + CTA: “No quotes yet — Create your first quote”), no results (Clear filters), no permission (explains who can grant access), error (cause if known, Retry, support link). Never an empty bordered box; never blame the user.

4.26. Loading State

Hierarchy: (1) skeleton shaped like the content (table rows, stat figures, form fields) for first loads; (2) inline spinner inside the trigger for actions (Button loading); (3) a 2px top progress bar for route transitions; (4) keep previous data visible with reduced opacity when refetching. Never a full-screen spinner or bare “Loading…”. Regions get aria-busy; spinners appear after 300ms; skeleton → content cross-fades in 240ms without layout shift. Optimistic updates for low-risk toggles; roll back with a toast on failure.

4.27. Skeleton

--muted blocks with radius-sm (text lines at line-height minus 4px) or the radius of what they stand for (avatar full, card lg); shimmer = a 1.5s horizontal gradient of --muted → --accent, static under reduced motion; aria-hidden. Compose per layout (TableSkeleton, StatSkeleton, FormSkeleton), never one generic grey box.

4.28. Supporting components

SegmentedControl (2–4 modes, Radix ToggleGroup; thumb slides 160ms), StatStrip, DescriptionList (label caption above value body), PlateChip, Money (tabular, right-aligned, ₫ de-emphasized), Meter/ProgressBar (plan usage; warning at 80%, destructive at 100%), Timeline/Stepper, StickyActionBar, ConfirmDialog, CommandPalette, OtpInput (6 cells, mono, paste-friendly, autocomplete="one-time-code").

5. Patterns

5.1. Page template

PageLayout owns gutters and vertical rhythm. Order: (breadcrumb on detail pages) → title row (title left; primary action right; secondary actions in an overflow) → optional tabs → content panels separated by 24px. Descriptions appear only where a page is unfamiliar (first use). Document title is set per route and focus moves to the heading on navigation.

5.2. Dashboard

Stat strip (3–5 figures with delta and sparkline) → one primary chart (axes, tooltip, direct labels) → attention list (overdue receivables, low stock; each row links to the fix) → in-progress orders table (compact). Quiet empty states for new garages.

5.3. Feedback model

EventFeedback
Action succeededToast (with Undo if reversible)
Action failedInline beside the control, or Alert in the form; Toast only for background actions
Load failedError state in place of content with Retry
Destructive intentConfirm dialog naming object and consequence
System condition (trial, read-only)Persistent Alert under header

5.4. Kanban

Columns on --surface-sunken, radius-lg; header = status icon + title + Count. Cards (--surface, radius-lg, 12px padding): plate chip, number, customer, technician avatars, updated time, 3px status rail; overdue = warning rail + clock icon. Read-only board; each card has a keyboard-accessible “Change status” menu and opens a right Drawer for line-level work (approved decision). Phone: one column at a time with a status tab bar.

6. Responsive

WidthShellTablesDialogsForms
< 640Header + menu button → left Sheet navCard listBottom sheetOne column, 16px text, primary action full width / sticky
640–767SameCard list or reduced columnsCentered or sheet1–2 columns
768–1023Icon railFull columns, low-priority columns hiddenCentered2 columns
≥ 1024Full sidebar (collapsible)FullCentered2–3 columns

Web on phones is usable, not the product’s phone experience (the Flutter apps are). Verify at 360, 768, 1280 and at 200% zoom.

7. Accessibility

WCAG 2.2 AA for the main flows in both themes. Contrast thresholds 4.5 : 1 (text) and 3 : 1 (control boundaries, focus, icons that carry meaning); focus visible and never hidden under sticky bars (scroll-padding); targets ≥ 24px (44px touch); keyboard-complete with a skip link; Radix handles dialog/menu/tab/select semantics; errors are programmatically associated and announced; status never by color alone; prefers-reduced-motion and prefers-color-scheme honored; per-route titles; lang follows the language switch; drag-and-drop (if ever added) always has a keyboard alternative. Enforced by eslint-plugin-jsx-a11y, vitest-axe, Storybook a11y, the token-contrast test, and a manual keyboard + screen-reader pass per release.

8. Content and Localization

Bilingual (VI default, EN). Sentence case; specific verbs (“Send quote”, not “OK”); errors say cause and fix; amounts 1.250.000 ₫ (vi) / ₫1,250,000 (en) via Intl.NumberFormat; dates dd/MM/yyyy (vi); every string in both languages, no text in components. Design for 30% expansion; truncate only names/emails, with a tooltip.

9. Mobile (Flutter) Mapping

Web and the four Flutter apps are one product. The token JSON generates Dart constants in vietgara_core (vietgara-flutter-core).

Web tokenFlutter
Semantic colors (3.2.1)ColorScheme + a VietgaraColors ThemeExtension (surface, surfaceSunken, success/warning/info and *-subtle roles, plate) for light and dark
Typography (3.1)TextTheme with Be Vietnam Pro bundled; tabular figures via FontFeature.tabularFigures(); JetBrains Mono for plates
Radius 4 / 8 / 12 / 16BorderRadius constants; buttons and inputs 8, cards 12, bottom sheets 16 (top corners)
Spacing 4px scaleSpacing constants
ShadowsElevation 0 for cards (outline instead), popover/menus level 2, sheets level 3
Motion tokensDuration/Curves.fastOutSlowIn-like custom curve; respect MediaQuery.disableAnimations
ComponentsSame names and states: VgButton, VgField, VgBadge, VgPlateChip, VgEmptyState, VgSkeleton, bottom navigation with labels; 48dp touch targets; offline banner (Technician) uses the warning tone + icon

The Flutter apps adopt the new primary #1F66B3 and neutrals in the same release as the web apps.

10. Governance and QA

  • Source of truth: the token files and components in vietgara-web-ui / vietgara_core (with the kit’s gallery); Figma, if used, mirrors them.
  • Change process: a new component or token needs a gallery entry with all states, an a11y check, and design-owner approval; apps may not ship one-off components that duplicate a kit component.
  • Versioning: semantic versions of vietgara-web-ui (MAJOR = token/identity change, MINOR = component, PATCH = fix); apps pin tags; each release has a changelog.
  • Definition of done for any screen: all four states (loading, empty, error, populated) designed and built; both themes verified; 360 / 768 / 1280 checked; keyboard pass; no literal colors, radii, font sizes or spacing in code; both languages present; document title set.
  • Design QA checklist from v1 §8 still applies (no detached components, Dev Mode annotations) with the additions above.