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 in04_UI_Audit_and_Redesign_Proposal.md; screens are specified in02_UI_Design_Spec.md; UX rules in01_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.
| Quality | What it means in practice |
|---|---|
| Calm | Neutral surfaces, one accent color, status colors only for things that need attention, motion only to explain change |
| Premium | Tight alignment, consistent rhythm, crisp hairlines instead of shadows, careful type with tabular numbers; nothing looks default |
| Information-dense, not cluttered | 14px body, 48px (default) / 40px (compact) table rows, hierarchy through weight and color rather than boxes and borders |
| Highly usable | Primary action obvious on every screen, keyboard-complete, states (loading, empty, error, success) always designed |
| Consistent | One component for each job; no container-specific styling; tokens only |
| Responsive | Works from 360px to wide desktop; tables become card lists on phones |
| Accessible | WCAG 2.2 AA minimum, in both themes |
1.2. Signature elements (what makes it VietGara, not a template)
- 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.
- 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 ₫). - 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.
- Status vocabulary. One domain-specific mapping from status to tone and icon (Section 3.2.4), used by badges, rails, filters and charts alike.
- 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.
- 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
| Concern | Choice | Notes |
|---|---|---|
| UI library | React + TypeScript | As the current apps |
| Styling | Tailwind CSS v4 with tokens in @theme | Utilities map to CSS variables; no free-form hex/px in components |
| Component base | shadcn/ui pattern over Radix UI primitives, in our own repo | We copy components in and restyle them to this document. The shadcn defaults (neutral-zinc palette, rounded-md everywhere, default shadows) are not used |
| Variants | class-variance-authority + tailwind-merge | One variant API per component |
| Icons | Lucide (lucide-react) | 16 / 20 / 24px, stroke 1.75 |
| Tokens | CSS 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 |
| Motion | Motion (motion/react) for presence, layout and shared-element animation; CSS transitions for hover/focus/press | Nothing else animates (Section 3.6) |
| Toast, palette, forms | sonner, cmdk, react-hook-form + zod (only for large forms) | Approved in the proposal |
| Docs and tests | Component gallery in the kit (npm run dev; Storybook with the a11y addon is the planned upgrade), Vitest (token contrast test), Playwright via vietgara-e2e | Token-contrast test fails the build below threshold |
| Home | New repo vietgara-web-ui, consumed by vietgara-owner-web and vietgara-admin-web as a tag-pinned dependency | Mirrors 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 withFormData, 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);cmdkpowers the command palette. - Motion. Overlays (dialog, sheet, popover, tooltip) animate with CSS keyframes driven by Radix
data-state; Motion is used throughLazyMotion(domMax) for the route fade and board layout animation, and honorsprefers-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 installgit. A gallery app (npm run devin the kit) replaces Storybook for now. - Collapsible sidebar (
@vietgara/web-uiv0.2.0): state inlocalStoragekeyvietgara.sidebar; apps pass the two toggle labels toAppShelland useuseSidebarCompact()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-labelon cells (CSS only), not a second markup tree. - Mobile.
vietgara_coreships the same tokens asThemeDataplus aVgColorstheme 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.
| Token | Size / line-height | Weight | Letter-spacing | Used for |
|---|---|---|---|---|
display | 32px / 40px | 600 | −0.02em | Sign-in hero, plan prices (max size in the product) |
title | 24px / 32px | 600 | −0.015em | Page title |
heading | 18px / 28px | 600 | −0.01em | Panel / section title |
subheading | 16px / 24px | 600 | −0.005em | Dialog title, card title |
body | 14px / 20px | 400 | 0 | Default UI text, tables, forms |
body-strong | 14px / 20px | 500 | 0 | Row titles, labels, nav items |
body-lg | 16px / 24px | 400 | 0 | Public quote page, mobile inputs, long text |
caption | 12px / 16px | 500 | +0.01em | Table headers, badges, hints, timestamps |
mono | 13px / 20px | 500 | +0.04em | Plates, 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.
| Token | Role | Light | Dark |
|---|---|---|---|
--background | App background (page) | #F6F8FA | #0F141B |
--foreground | Primary text | #151A23 | #E6EAF0 |
--surface | Panels, tables, cards (shadcn card) | #FFFFFF | #171D26 |
--surface-foreground | Text on surface | #151A23 | #E6EAF0 |
--surface-sunken | Table header, inset areas, code | #EEF1F5 | #12171F |
--popover | Menus, popovers, dialogs | #FFFFFF | #1C232E |
--muted | Quiet fills (skeleton, disabled, tags) | #EEF1F5 | #1F2732 |
--muted-foreground | Secondary text, hints, placeholders | #5F6A7B | #8D99AB |
--border | Hairlines and dividers (decorative) | #E1E6ED | #27303D |
--input | Control outlines (≥ 3 : 1) | #7C8798 | #6C788D |
--ring | Focus ring | #3B82D6 | #6FA8E8 |
--primary | Brand action | #1F66B3 | #2F73BF |
--primary-foreground | Text on primary | #FFFFFF | #FFFFFF |
--primary-hover | Hover/pressed | #194F8A | #3F82CF |
--primary-subtle | Selected/active tint | #EFF5FC | #14263D |
--primary-subtle-foreground | Text on tint, links | #194F8A | #9CC4F0 |
--secondary | Secondary button fill | #EEF1F5 | #1F2732 |
--secondary-foreground | #151A23 | #E6EAF0 | |
--accent | Hover on rows/menu items | #EFF3F8 | #1C232E |
--accent-foreground | #151A23 | #E6EAF0 | |
--destructive | Destructive action | #C62E24 | #C62E24 |
--destructive-foreground | #FFFFFF | #FFFFFF | |
--destructive-subtle / -subtle-foreground | Error banners, badges | #FDECEA / #B0261C | #3A1512 / #FF8A80 |
--success / --success-foreground | Solid success | #237A45 / #FFFFFF | #2E8F57 / #0F141B |
--success-subtle / -subtle-foreground | #E8F5EC / #1B6B3A | #12301F / #6FD09A | |
--warning / --warning-foreground | Solid warning | #E8A317 / #151A23 | #E8A317 / #151A23 |
--warning-subtle / -subtle-foreground | #FFF4E0 / #8A5300 | #33230A / #F2B756 | |
--info / --info-foreground | Solid info | #0E7490 / #FFFFFF | #2C8EAD / #0F141B |
--info-subtle / -subtle-foreground | #E6F4F8 / #0B5E75 | #14303A / #7DD0E8 | |
--sidebar / --sidebar-foreground | Shell | #FFFFFF / #394252 | #12171F / #A3AEBF |
--sidebar-active / -foreground | Active nav item | #EFF5FC / #194F8A | #14263D / #9CC4F0 |
--plate / --plate-foreground | Plate 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)
| Scale | 50 | 100 | 200 | 500 | 600 | 700 | 800 | 900 |
|---|---|---|---|---|---|---|---|---|
| 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
| Domain | Status → tone · icon |
|---|---|
| Quote | Draft → neutral · FilePen — Sent → info · Send — Approved → success · CircleCheck — Rejected → destructive · CircleX — Converted → neutral · ArrowRightLeft |
| Repair order | To do → neutral · Circle — In progress → info · Wrench — Waiting on parts → warning · Hourglass — Completed → success · CircleCheck — Closed → neutral · Lock |
| Settlement | Pending → warning · Clock — Partially paid → info · CircleDashed — Paid → success · CircleCheck — Overdue → destructive · AlarmClock |
| Account / plan | Active → 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.
| Use | Value |
|---|---|
| Inside controls (horizontal padding) | 12px |
| Label ↔ control | 6px |
| Between form fields | 16px (20px with helper text) |
| Panel padding | 20px desktop, 16px phone |
| Between panels / sections | 24px |
| Page gutter | 16 (phone) / 24 (tablet) / 32 (desktop) |
| Content max width | 1280px (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
| Token | Value | Used for |
|---|---|---|
radius-sm | 4px | Badge, checkbox, plate chip, tooltip |
radius-md | 8px | Button, input, select, tabs, menu item |
radius-lg | 12px | Panel, card, popover, toast |
radius-xl | 16px | Dialog, sheet |
radius-full | 9999px | Avatar, 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.
| Level | Token | Value (light) | Used for |
|---|---|---|---|
| 0 | shadow-none | none, 1px --border | Panels, cards, table containers, sidebar |
| 1 | shadow-sm | 0 1px 2px rgb(21 26 35 / 6%) | Buttons (secondary), sticky bars, segmented thumb |
| 2 | shadow-md | 0 6px 16px rgb(21 26 35 / 10%) | Popover, dropdown, select list, toast, command palette |
| 3 | shadow-lg | 0 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.
| Token | Value | Used for |
|---|---|---|
duration-fast | 100ms | Hover, press, focus, checkbox |
duration-base | 160ms | Popover, tooltip, tab indicator, toast in |
duration-slow | 240ms | Dialog, sheet, route content fade, skeleton → content |
ease-standard | cubic-bezier(0.2, 0, 0, 1) | Enter, move |
ease-exit | cubic-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 (--secondaryfill,shadow-sm), outline (transparent,--inputborder; 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; radiusmd; labelbody-strong; icon 16px with 8px gap. IconButton: square, requireslabel, 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--inputborder,--surfacefill, 12px padding; placeholder--muted-foreground(an example, never the label). Focus: border--primary+ 2px ring. Error: border--destructive+ icon + message. Disabled:--mutedfill. - 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
--borderrow rules, no cell borders, no zebra; header sticky, 12px/500--muted-foregroundon--surface-sunken, sort button witharia-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
mdrenders 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
| Event | Feedback |
|---|---|
| Action succeeded | Toast (with Undo if reversible) |
| Action failed | Inline beside the control, or Alert in the form; Toast only for background actions |
| Load failed | Error state in place of content with Retry |
| Destructive intent | Confirm 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
| Width | Shell | Tables | Dialogs | Forms |
|---|---|---|---|---|
| < 640 | Header + menu button → left Sheet nav | Card list | Bottom sheet | One column, 16px text, primary action full width / sticky |
| 640–767 | Same | Card list or reduced columns | Centered or sheet | 1–2 columns |
| 768–1023 | Icon rail | Full columns, low-priority columns hidden | Centered | 2 columns |
| ≥ 1024 | Full sidebar (collapsible) | Full | Centered | 2–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 token | Flutter |
|---|---|
| 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 / 16 | BorderRadius constants; buttons and inputs 8, cards 12, bottom sheets 16 (top corners) |
| Spacing 4px scale | Spacing constants |
| Shadows | Elevation 0 for cards (outline instead), popover/menus level 2, sheets level 3 |
| Motion tokens | Duration/Curves.fastOutSlowIn-like custom curve; respect MediaQuery.disableAnimations |
| Components | Same 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.