Skip to content

UI/UX AUDIT AND REDESIGN PROPOSAL (WEB)

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

Version 0.2 (approved and implemented) — October 7, 2026 · Scope: vietgara-owner-web, vietgara-admin-web

Status: the product owner approved all recommendations in Section 11 (October 7, 2026). Implementation starts on a refactor/... branch in each frontend repo (web and Flutter) only after this documentation PR is approved. The normative design system is 03_Design_System.md in this folder; this document keeps the audit, the rationale and the refactoring plan. Sections written before approval say “proposed”; read them as the approved direction.

1. Introduction

1.1. Purpose

Functionality and business rules in both web apps are largely right. The interface is not: it is consistent in intent (it follows the Design System’s colors, radii and Lucide icons) but it was built one screen at a time on a single global stylesheet, so it looks like a generic admin template and carries a lot of structural debt. This document:

  1. audits the current UI against 20 dimensions (Section 2) and lists concrete problems (Section 3);
  2. proposes a visual direction and a design system to fix them (Sections 4–5);
  3. proposes a component, responsive, motion and accessibility strategy (Sections 6–9);
  4. orders the work into a refactoring plan (Section 10).

1.2. Method and limits

  • Static audit of source, not a browser walkthrough: both repos were read in full (owner-web: 124 source files, ~12,000 lines incl. i18n; admin-web: 53 files, ~4,900 lines). The apps were not run against the backend, so statements about rendered appearance (spacing rhythm, alignment, loading flashes) are inferred from CSS and markup. A visual pass in a browser should confirm items marked (verify visually).
  • Contrast ratios were computed from the hex values in src/app/styles.css using the WCAG 2.x relative-luminance formula.
  • Counts (colors, radii, font sizes, class usage) come from grep over src/ and are exact for the commit audited.
  • Related documents: 01_UX_Guideline.md, 02_UI_Design_Spec.md, 03_Design_System.md.
  • Where the audit cites “Design System 3.x” or “Spec 3.x” it means the v1 documents (../v1/) the apps were built against; the v2 documents in this folder carry their own numbering.

1.3. What exists today (facts)

FactOwner webAdmin web
StackReact, TypeScript, Vite, TanStack Query, react-router, i18next, lucide-reactsame
Tailwind CSS v4Installed (@tailwindcss/vite), @theme block declares 5 colors + spacingsame
Tailwind utility classes used in markup00
Stylingone global src/app/styles.css, 2,118 lines, plain class namesbyte-identical copy of the same file
Shared components16 files in src/components/13 files, identical to owner-web’s (only Brand’s default route differs); owner-web adds KpiCard, RevenueChart, SearchSelect
Design-system primitives (Button, Input, Select, Table, Card, Tabs, Toast, Skeleton)none — all are CSS classes (.primary-button ×32, .secondary-button ×32, .table-card ×18)none
Dark mode, prefers-reduced-motion, color-schemenonenone
Motionone @keyframes spin; no transition anywheresame

2. UI/UX Audit

Rating: ● solid · ◐ works but weak · ○ missing or broken. Evidence references styles.css (CSS) or a source file.

#DimensionRatingFinding
1Visual hierarchy◐Every page is the same stack: breadcrumb → 32px H1 + grey line → controls → white bordered box. No page has a clear primary focus; the H1 is large (32/40) while the content under it is 14px grey, so titles dominate and data recedes. Dashboard KPI cards, chart card and table card have identical weight (same white box, same 1px border).
2Typography◐Be Vietnam Pro is a good choice (full diacritics). But: ~14 distinct font-size values (10, 11, 12, 13, 14, 16, 18, 20, 22, 24, 28, 32, 34, clamp) against a 6-step documented scale; 13px is used 15× and is not in the scale; h2 sizes are set per-container (.table-card h2, .modal h2, .detail-card h2, .auth-card h2, .section-title) rather than by role; money and plates have no tabular figures, so columns of amounts do not align; @import url(fonts.googleapis…) inside CSS is render-blocking and sends visitor IPs to Google.
3Color system○49 distinct hex values in styles.css, 4 uses of var(--…). The documented tokens (primary/600, neutral/*, semantic) exist only as 5 @theme entries that nothing references. Four different blues for links/active (#2e74b5, #1e5f9d, #1e4f85, #1f3864), three greys for secondary text (#6b7280, #4b5563, #9ca3af), five near-identical warning tints (#fffbeb, #fff8eb, #fff1d6, #ffedd5, #f5d49a). Status colors are re-declared per component (.status.*, .subscription-status.*, .alert-banner.*, .segmented).
4Spacing & layout◐Spacing values are literal pixels (4, 6, 7, 8, 10, 12, 13, 14, 16, 20, 24, 28, 32, 36, 48) not the documented 4px scale; .page is a fixed max-width:1440px; padding:32px; td { height: 76px } is a global rule written for the Staff table’s avatar rows and applies to every table (customers, quotes, inventory…), making dense lists ~2× taller than needed. Vertical rhythm between sections comes from ad-hoc margin-top on each class (.list-card, .plans-section, .detail-card, .alert-banner all carry their own).
5Component consistency○See Section 3.2: three radii for “a card” (8/10/12), three input heights (36/40/44) and two border radii for inputs (4px in modals/auth, 8px in .detail-card), two different table header styles (sentence-case grey vs uppercase), two search-field implementations.
6Navigation◐Sidebar is a flat list of up to 14 items under two labels (“Workspace”, “Account”); related items (Customers/Vehicles, Quotes/Repair orders/Settlements) are not grouped. Active state is a solid #2e74b5 block — heavy. The header “garage select” is a link to /garages, not a selector: switching garage is a full page trip (Spec 3.9 calls it a selector). Breadcrumb is, on 9 list pages, just the garage name as plain text (not a trail, not clickable). No command/search, no recent items. Admin app: 3 items — fine.
7Forms◐Labels are always bound (htmlFor), hints/errors use aria-describedby: good bones. Gaps: no required marker, validation only on submit/server (fieldIssue), error text is small grey-adjacent red under the field, no inline success; label spacing differs by container (.modal label vs .detail-card label vs .auth-card label, plus 12 !important overrides to make .checkbox-label behave); .segmented has a <label> with no for; Quote/RepairOrder forms (515/460 lines) hand-roll line-item tables with 3 sticky bars of the same class (.quote-total-bar) stacked in one view; five inline style margins to space buttons.
8Tables○15 files render a raw <table>; no shared DataTable. No sortable headers, no sticky header, no row selection, no column alignment (amounts left-aligned, not tabular), no density option, no loading skeleton, no empty/zero-result distinction. Row actions are icon-only buttons (edit/delete) at 16px inside a 40px hit area with no tooltip. On ≤720px the table simply scrolls horizontally.
9Cards◐Everything is a white 1px-bordered 8px box (.table-card, .detail-card, .kpi-card, .plan-card, .candidate, .public-quote-card, .kanban-card). “Card” is used for tables, forms and content alike, so the page reads as a stack of identical boxes. KPI card = tinted icon square + label + number — the most recognizable “AI-template” pattern in the product.
10Buttons◐Two real variants (primary, secondary) plus .danger-button (filled) and .secondary-button.danger (outline); no ghost/tertiary despite Design System 3.1 listing it; one size (44px) plus ad-hoc 38/34/32 overrides inside parents; .primary-button is applied to <a> links; disabled primary uses cursor: wait (wrong signal for “not allowed”); loading state is text swap (“Saving…”), no spinner except 4 places; no hover/active/pressed transitions.
11Icons◐Lucide throughout (matches Design System). But sizes in use: 14, 16 (44×), 18, 19, 20, 22, 24, 30, 32, 110 vs documented 16/20/24; icons in buttons are 16 or 18 depending on the author; nav icons 20 with no stroke tuning; empty states use a 30px lucide glyph.
12Empty states◐One EmptyState component (icon + optional title + one sentence). Missing from the spec’s own requirement: a CTA for the next action (“No quotes yet — Create your first quote”, Spec 2.2). Zero-result searches and first-use look identical. Several pages show a bare <p class="muted"> instead (dashboard chart, recent orders).
13Loading states○Spec 2.2 requires skeletons, “never a full-screen spinner”; implementation is the text “Loading…” (QueryState) and … in KPI values. Zero skeletons. Layout jumps when data arrives.
14Error states◐QueryState shows an error AlertBanner with Retry: good. Mutation errors render as stacked banners at the top of the page (QuoteFormPage can show four at once) far from the button that caused them; there is no toast/snackbar layer for success (“Saved”) feedback at all.
15Responsive○Five media queries total (900/720/640). At ≤900 the sidebar becomes an icon rail by setting font-size: 0 on labels (no tooltips, no accessible-label guarantee); at ≤640 the sidebar is display: none and nothing replaces it — there is no navigation on a phone. Tables scroll sideways; Kanban is a 4-column grid with horizontal scroll; .auth-aside { min-height: 720px } forces tall auth pages on landscape phones.
16Accessibility◐Positives: semantic headings, labelled icon buttons (43 aria-label), aria-live on OTP timers, role="alert"/"status" on banners, lang is switched with the language. Negatives (measured): focus ring #93c5fd on white = 1.8:1 (needs 3:1); text-input border #d1d5db = 1.47:1 (needs 3:1 to identify the control); pagination buttons use #9ca3af text = 2.54:1 even when enabled; breadcrumb separators/divider 2.43:1; .soon and footer text 10–11px. Modal has no focus trap, no focus restore, no scroll lock, backdrop click does nothing, and registers Escape on window so stacked dialogs close together (Design System 3.6 requires a focus trap). document.title is never updated (static “VietGara Admin” on both apps); no skip link; Tabs (Inventory) have role="tab" but no arrow-key handling; Kanban cards are links (OK) but there is no keyboard path described for status change.
17Information density◐Default rows are tall (76px) and type is small (13–14px grey), i.e. low data per screen but also low legibility. Dashboard shows three KPIs, one 160px bar chart, then a list — little at-a-glance value for the “Minh” persona (UX 3).
18Interaction patterns◐Create/edit = modal for small entities (good) and full page for big ones (good). Gaps: no inline editing, no unsaved-changes guard except Role editor, no undo after destructive actions, delete confirm is generic (“Delete”) not consequence-stating as UX 6 requires, no keyboard shortcuts, no optimistic updates, search has no clear button / no result count. Kanban has no drag-and-drop (Spec 3.3 lists it as a component; the same section also says columns are derived, so the spec is internally inconsistent — see Section 11, Q7).
19Mobile usability○See 15. Plus: tap targets are 40px for icon buttons (<44, Design System 3.1/8), the modal is 32px padded and leaves little room for fields at 360px, sticky bars (.quote-total-bar) stack under the on-screen keyboard. Note the Flutter Owner App covers phone use; web on phone should be usable, not optimized.
20Visual identity○White cards on #f9fafb, navy #1f3864 sidebar with a tinted-blue active block, blue primary button, rounded avatar circles, a blue-tinted icon square on every card, and on the sign-in page a navy panel with a decorative ring and a 110px outline wrench. That palette-plus-layout is exactly the default output of “make me an admin dashboard”. Nothing in the UI says garage, vehicle or Vietnam beyond copy; the license-plate chip (.plate) is the only domain-specific element.

2.1. Rendered-UI review (browser, October 7, 2026)

The static audit above was followed by a visual pass of vietgara-owner-web in headless Chrome against the local stack (Kong + backend + Postgres + Mailpit), signed in as a seeded owner (5 customers, 6 vehicles, 5 stock items, 5 quotes, 2 repair orders, 1 settlement). Viewports: 1440 (desktop), 820 (tablet), 390 (phone). Screens: sign-in, sign-up, dashboard, customers, quotes (list and new), repair-order board, inventory, settlements (list and detail), staff, garages, subscription, plus the customer dialog, focus, hover and login-error states. vietgara-admin-web was inspected the same way as a platform admin (sign-in, Plans, Subscriptions, Account at 1440, and Plans and Subscriptions at 390); it shares the stylesheet and components byte-for-byte, so R1–R16’s shell, table, form and dialog findings apply to it too, and R17–R21 are specific to it.

Verdict on “would this pass as a professional production app?”: no — it reads as a competent prototype. The problems that decide that are below; each maps to a phase in Section 10.

#ScreenObservedSeverityFixed by
R1DashboardThe 30-day revenue “chart” renders as one giant solid blue slab when there is a single day of data (bar width = canvas / number of points). No axis, baseline, labels or tooltip; looks brokenHighPhase 4 (chart with fixed 30-day axis, axes, tooltip)
R2All pagesPageHeader has no bottom spacing: the description line touches the filter/search row, KPI cards and tables (e.g. Customers: search field sits directly under the sentence). KPI grid and the chart card also touch with 0 gap. Vertical rhythm is accidentalHighPhase 3 (PageLayout owns rhythm)
R3Quote form, Settlement detailSearchable select (customer, vehicle, insurer) is double-bordered: an input with its own border sits inside the wrapper’s border, and the search icon is stuck to the top-left corner. Clearly brokenHighPhase 2 (Combobox)
R4Quote formLine-item inputs inside the table have no visible border or affordance (quantity and price look like plain text); a disabled pink “Delete” button shows on a new quote; the sticky action bar overlaps the table’s last rowHighPhase 5 (LineItemsEditor, StickyActionBar)
R5Phone (390)No navigation at all (sidebar hidden). Customers table is wider than the viewport and the page itself scrolls sideways (~568px) with names wrapping one word per line. Top bar crams garage name, language select and two iconsHighPhase 1 + Phase 4
R6Sign-inThe language switcher floats centered above the form instead of top-right; the form is vertically centered while the switcher is not, leaving a ~140px dead gap. Tagline is 48px (the largest type in the product, against the 32px cap in v2). The auth error (“Email hoặc mật khẩu không đúng.”) is plain black text between the field and the button, with no icon, color or alert stylingHighPhase 3 (AuthLayout), Phase 2 (InlineError)
R7Tables, filtersNative <select> elements (quote status filter, repair-order technician filter, settlement status) render at the browser’s default 16px with a different height and font from the 14px search field beside them; the “Outstanding only” checkbox sits on a different baseline from its selectMediumPhase 2 (Select), Phase 4 (FilterBar)
R8TablesRows are ~76px tall; header cells are 12px uppercase grey and the first column bold, but amounts are left-aligned, ₫ has a visible underline-like descender glyph, and status badges are coloured dots with text only — no icon, so Draft/Sent look almost identical (both amber). Empty space at the right of inventory rowsMediumPhase 2 Badge, Phase 4 DataTable
R9Pagination“Trang trước / Trang sau” are rendered in light grey even when enabled (contrast 2.5 : 1); users cannot tell they are clickableMediumPhase 1
R10SidebarGroup label “TÀI KHOẢN” sits flush under the last item with no gap above it (the “Workspace” label has 24px above, this one 0); active item is a heavy solid-blue block; 14 items in two groupsMediumPhase 3
R11SubscriptionOn a hard reload the header briefly shows “Chưa có gara” and the nav omits permission-gated items until garages load (flash of an incomplete shell); usage meters are native <meter> elements, so their colors (yellow, green, grey) differ from the design palette; plan names and descriptions arrive in English inside a Vietnamese UI (“For small garages: customers, quotes…”)MediumPhase 1 (skeleton shell), Phase 4, content fix with the backend
R12Settlement detailStatus chip floats mid-header, not aligned to the title baseline or the action button; allocation amounts show raw digits (1740000) with no thousand separators; the three info cards stack at full width with little content, leaving big empty areasMediumPhase 5
R13DialogVisually acceptable, but label-to-previous-field spacing (~8px) is tighter than field-to-label spacing implies, the close button and the icon tile compete at the top, and the icon tile adds no informationLowPhase 2 (Dialog, Field)
R14Repair-order boardCards and columns are fine, but empty columns are just empty boxes, the technician filter looks like a stray control, and there is no overdue or technician cueLowPhase 5
R15Tablet (820)Icon rail works but gives no labels or tooltips for 14 icons; content area is fineLowPhase 3
R16ConsoleOne 404 on every page load (a static resource); needs identification before releaseLowPhase 1
R17Admin web sign-inThe admin web sign-in page shows the garage-owner marketing panel (“Quản lý gara ô tô trên một nền tảng duy nhất”, customers/quotes/stock bullets), which is the wrong audience and wrong product message for operators; same layout bugs as R6.MediumPhase 3 (AuthLayout takes per-app copy)
R18Admin web headerThe top bar has nothing on its left and the language select and sign-out icon sit at the far left edge, directly beside the sidebar, so the bar looks unfinished and carries no page or user context (no user name, no environment indicator for an operator tool)MediumPhase 3 (AppShell header)
R19Admin SubscriptionsThe “Usage” column shows 0 / 1 · 0 / 5 with no labels (garages / staff / transactions are indistinguishable); status is plain text with no badge or tone, so a “Past due (read-only)” user looks identical to “Trial”; every row repeats an “Extend” outlined button, so the action column dominates; rows are ~76px tall for two lines of content; the search field sits directly under the description with no gapMediumPhase 4 (DataTable, Badge, Meter, row menu)
R20Admin PlansCopy and formatting: “1 garages” (no plural rule), price shown as ₫299,000/month with the symbol first and Western separators in an English UI while the owner app uses 299.000 ₫; internal reference “(FR-LIC-05)” is shown to operators in the page description; the code column is monospace (good) but the status column mixes a dot badge with a grey “Hidden” tagLowPhase 4 + i18n pass (ICU plurals, one Money component)
R21Admin phone (390)Same as R5: no navigation, and the Plans table overflows (price column clipped, status and actions unreachable without sideways scrolling of the page itself)HighPhase 1 + Phase 4

What already looks right and should be kept: the Be Vietnam Pro type rendering of Vietnamese diacritics, the plate chip, the compact sidebar iconography, the dialog structure, and the overall information architecture.

Method for the implementation phases: every migrated screen is re-captured at 1440 / 820 / 390 and compared to its Storybook reference before merge; the capture script and seed data become part of vietgara-e2e so this review is repeatable (Phase 6).

3. Current Design Problems

3.1. Where the UI reads as default / AI-generated

PatternWhereWhy it reads as generic
Tinted rounded-square icon above or beside a number (.kpi-icon, .modal-icon, .garage-icon)Dashboard, every modal, headerDecoration with no information; repeats on every surface
Identical white bordered boxes for everythingall pagesNo hierarchy between primary content, secondary content and chrome
Giant H1 + one grey sentence under every titleall pages (PageHeader)Spends ~110px of vertical space on text the user already knows
Navy sidebar with solid-blue active blockshellThe stock “SaaS dashboard” silhouette
Auth page: dark panel, 3 checkmark bullets, decorative ring, huge faint iconAuthLayoutThe stock split-screen login; carries no product proof (no screenshot, no real figure)
Pill/dot status with arbitrary tones (active/pending/danger)StatusBadgeOnly 3 tones, so Draft, Sent, Approved, Paid, Overdue are forced into active/pending/danger and lose meaning
“Loading…” paragraphs, text “Saving…” buttonseverywhereReads as unfinished

3.2. Inconsistencies (measured)

PropertyValues foundTarget (Section 5)
Border radius4px ×7, 6px ×2, 8px ×28, 10px ×1, 12px ×2, 16px ×1, 999px ×5, 50% ×54 / 8 / 12 / 16 / full
Font sizes14 distinct (10–34 + clamp)7-step scale
Hex colors49 distinct~40 tokens, 0 literals in components
Icon sizes14, 16, 18, 19, 20, 22, 24, 30, 32, 11016 / 20 / 24
Control heights32, 34, 36, 38, 40, 4432 / 36 / 40 (44 on touch)
Input radius4px (modal, auth) vs 8px (detail card, search field, segmented)8px
Table cell height76px global, 12px padding overrides48px default, 40px compact
Link blue#2e74b5, #1e5f9d, #1e4f85one --link token

3.3. Structural debt

  • Dead CSS. At least 10 rule groups have no matching class in any .tsx (.staff-summary, .seat-bar, .last-active, .plan-grid, .invite-button, .filter-select, .search-input, .table-toolbar, .role-badge, .subscription-status, …) — leftovers from the first mock-up. Admin-web ships all of it plus owner-only styles (kanban, quotes, roles).
  • Duplicate and overriding rules. .plan-card, .row-actions, .person, .section-title, .subscription-summary, .search-row, .plate, .secondary-button are each defined twice with different values; later rules silently win, and .checkbox-label needs !important ×6 to beat its container selectors (12 !important in total).
  • Container-scoped styling. Inputs are styled via .modal input, .auth-card input, .detail-card input — a form field in a new container is unstyled. This is the root cause of the 4px/8px input mismatch.
  • Cross-repo duplication. 2,118 CSS lines and 13 components live twice (the CSS file and all 13 components are identical copies, bar one default route). A fix in one repo must be hand-copied to the other (the Flutter side solved this with vietgara_core; the web side has no equivalent).
  • Tailwind installed but unused, so the project pays for a build plugin and gets none of its token/utility benefits, while the CSS reimplements the same ideas by hand.
  • Domain logic mixed into pages. QuoteFormPage (515 lines) and RepairOrderFormPage (460) contain table, sticky bar, selects, mutations and layout in one component — they are the first candidates for extraction (Section 6.4).

3.4. Duplicated UI patterns that should become components

PatternOccurrencesBecomes
Page header (breadcrumb + title + description + actions)~29 (className="page")PageHeader (exists, needs redesign)
Search + filters toolbar above a list9 .search-rowListToolbar / FilterBar
<table> inside .table-card + PaginationFooter15 filesDataTable
Icon-only edit/delete buttons per row10 .row-actionsRowActions (menu on ≤md)
Primary/secondary/danger buttons by class~70Button (variants, sizes, loading)
Label + control + hint + errorevery formField + Input/Select/Textarea/Checkbox/Radio
Sticky action bar.save-bar, .quote-total-bar ×3, .public-quote-actionsStickyActionBar
Key-value details (dl).detail-card dl ×8DescriptionList
Status chipStatusBadge + .tag + .subscription-status + .badge + .kanban-countBadge (tone × style), Count
Usage meterSubscriptionPageMeter
Person cell (avatar + name + sub-line).person ×2 definitionsPersonCell

4. Proposed Visual Direction — “Workshop Precision”

4.1. Idea

VietGara’s users are garage owners, accountants and advisors working through repetitive, number-heavy tasks (quotes, stock, settlements), often with customers watching. The interface should feel like a well-run workshop: calm, orderly, exact, with numbers and vehicle identity as the heroes and chrome kept quiet. Three principles:

  1. Data first, chrome second. Smaller page titles, denser tables, and a quiet shell so the content carries the hierarchy. Weight and contrast, not boxes, separate things.
  2. Numbers and plates are the brand. Tabular figures for money and quantities, a distinctive license-plate chip as a recurring motif, a status vocabulary specific to garage work (Draft → Sent → Approved → In progress → Closed → Paid).
  3. Quiet by default, loud only for exceptions. One accent color for action; warning/danger only when something needs a decision (overdue, low stock, plan limit).

4.2. Decisions that change the look

AreaTodayProposed
ShellNavy sidebar, solid-blue active block, 72px topbar with the garage linkLight sidebar (surface color, hairline right border); active item = tinted background + 2px accent bar + stronger weight; grouped sections (Operations / Customers / Finance / Admin); collapsible to icon rail with tooltips. Navy is kept for the brand mark and the auth hero only. Topbar 56px with a real garage switcher popover, global search (Ctrl/⌘K), language, notifications, user menu. (Alternative A: keep a dark sidebar but in graphite, not navy — see Section 11, Q1.)
Page chromeBreadcrumb + 32px H1 + subtitleTitle 24/32 semibold, optional one-line description only on first-time empty states; real breadcrumb trail only on detail/edit pages; primary action right-aligned; tabs under the title where a page has views (Inventory: Stock / Movements).
SurfacesEverything is a bordered white cardThree levels: page (tinted), panel (white, hairline border, used for tables and forms), raised (popovers/modals, shadow). KPIs become a flat “stat strip” (one panel, dividers between figures, delta vs previous period, sparkline) instead of three icon cards.
Tables76px rows, grey sentence-case headers48px rows (40px compact), sticky uppercase-free 12px/600 header with sort affordance, right-aligned tabular numbers, row hover, optional bulk-select bar, skeleton rows while loading, mobile card layout.
ColorNavy + blue + 49 literalsCobalt-leaning “Gara Blue” primary (a slightly more saturated #1F66B3 than today’s #2E74B5, 5.8:1 with white vs 4.9:1), cool slate neutrals, semantic success/warning/danger/info each with 50/100/600/700 steps. Dark theme defined from the start (03_Design_System.md 3.2.1).
TypographyBe Vietnam Pro, 14 sizesBe Vietnam Pro (kept; self-hosted, variable, font-display: swap) + a mono face for plates/codes; tabular numerals on all money; 7-step scale; weights 400/500/600 (700 reserved for display).
Signature elementslicense-plate chip only(a) Plate chip refined: embossed border, mono caps, optional regional flag colour; (b) status vocabulary with icons; (c) spot illustrations for empty states — a small set of 6 duotone vehicle/workshop SVGs instead of a 30px glyph; (d) money always shown as 1.250.000 ₫ with the symbol de-emphasized.
Auth screensNavy panel, ring, 3 bullets, giant wrenchSplit layout whose left panel shows a cropped, real product view (the Kanban board / a quote) over a navy-to-blue gradient with a short proof line — “one place for quotes, repair orders, stock and receivables” — no decorative ring. Right side stays minimal: provider button, form, one link.
Mobile webNo navTop bar + sheet navigation, card-list tables, bottom-anchored primary actions (Section 7).

4.3. What we are deliberately not changing

  • Information architecture, flows, routes and permissions (UX Guideline 4–5 stand).
  • Be Vietnam Pro, Lucide, the blue brand family and the license-plate motif — continuity with the Flutter apps and brand.
  • i18n keys and copy (a copy pass for the new empty states and confirmations is listed in Phase 7 of Section 10 but is separate).
  • The Admin Web gets the same system, with the same shell minus garage switcher.

5. Design System

The design system is now specified in full in 03_Design_System.md (this folder), which is normative: identity and anti-goals, technology stack (React, TypeScript, Tailwind v4, shadcn/ui pattern over Radix, Lucide, CSS variables, Motion), typography, light and dark color tokens with computed contrast, spacing, radius, shadows, motion, rules for 27 components, patterns, responsive and accessibility rules, and the Flutter mapping. This section summarizes the decisions only.

AreaDecision
ColorPrimary #1F66B3 (Gara Blue); cool slate neutrals; one accent; success / warning / destructive / info with subtle variants; light and dark themes defined, toggle shipped in Phase 7
TypeBe Vietnam Pro (self-hosted) + JetBrains Mono; 9-step scale topping out at 32px; tabular numerals for money
ShapeRadius 4 / 8 / 12 / 16 / full; hairlines instead of shadows; shadows only on floating layers
MotionCSS for micro-interactions, Motion for presence and layout; 100 / 160 / 240ms; reduced-motion honored
SignaturePlate chip, ledger tables, status rail, status vocabulary, spot illustrations

6. Component Strategy

6.1. Approach and foundation

Build a VietGara web UI kit: headless, accessible primitives wrapped with the design tokens. Recommendation: Radix UI primitives + Tailwind v4 (already installed) + class-variance-authority, in the “shadcn/ui” ownership model — component source lives in our repo, we are not locked to a runtime component library, and Radix supplies the hard accessibility behaviors we currently lack (focus trap, roving tabindex, typeahead, dismissable layers, aria-* wiring). Alternatives considered:

OptionProsConsVerdict
Radix + Tailwind + cva (recommended)Uses the installed Tailwind; full visual control; minimal runtime; widely documented; unlocks Dialog, Popover, Dropdown, Tabs, Tooltip, Select, Checkbox, SwitchWe maintain the styled layer; Radix has no Combobox/DatePicker (use cmdk + Popover, and a small date input)✔
React Aria ComponentsBest-in-class a11y, has Combobox/DatePicker/TableSteeper API, own styling model, larger learning curveFallback for Combobox/DatePicker only if cmdk proves insufficient
MUI / Ant DesignComplete out of the boxStrong “default look” — the very thing we are escaping; heavy bundle; theming fights tokens✘
Keep hand-rolled CSSNo new dependencyPerpetuates every problem in Section 3✘

Other libraries (each justified by a removed pain): sonner (toasts; 0 today), cmdk (garage switcher, global search, SearchSelect replacement), @tanstack/react-table only if sorting/selection needs outgrow a small in-house DataTable (decision Q4, Section 11), react-hook-form + zod for the two large forms only (Q4).

6.2. Where it lives

The two apps share 2,118 CSS lines and 13 components today. The Flutter side already solved this with vietgara_core in vietgara-flutter-core, pinned by tag. Proposed web equivalent: a new repo vietgara-web-ui (TS source + tokens + Storybook), consumed by both apps as a tag-pinned dependency (github:viettechno/vietgara-web-ui#vX.Y.Z), mirroring the Flutter change flow (change in the kit → tag → bump in apps). Alternative: merge the two web apps into one npm-workspaces monorepo. Trade-off in Section 11, Q3. Either way: one source of tokens, one set of components, zero copies. Tokens are authored as JSON (W3C design-tokens shape) and compiled to CSS variables; the same JSON can later generate Dart constants so web and Flutter stay in step.

6.3. Component inventory

LayerComponentStatusNotes
FoundationsThemeProvider, tokens CSS, Icon, VisuallyHidden, Stack/Inline/Grid, Text/HeadingnewReplace per-container font-size rules
ActionsButton (primary, secondary, ghost, danger; sm/md/lg; loading, iconLeft/Right, asChild for <Link>), IconButton (required label, auto tooltip), ButtonGroup, SegmentedControl, Linknew (replaces ~70 class usages and .icon-button, .link-button, .segmented)
FormsField (label, required, hint, error, aria-describedby automatically), Input, NumberInput (currency/VND formatting), PasswordInput, Textarea, Select, Combobox (replaces SearchSelect), Checkbox, RadioGroup, Switch, OtpInput (exists), FormSection, FormActionsnew / migratedContainer-independent styling — fixes the 4px/8px mismatch at the root
Data displayDataTable (columns config, sort, sticky header, skeleton, empty, mobile card mode, row actions, selection later), Pagination, Badge/StatusBadge (tone × icon), Count, Avatar, PersonCell, PlateChip, Money, DescriptionList, StatStrip/Stat, Meter, Sparkline, RevenueChart (axes + tooltip), Timelinenew / migratedPlateChip and Money centralize the signature elements
LayoutAppShell (sidebar, topbar, content), NavGroup/NavItem, PageLayout, PageHeader, Panel, Tabs, Breadcrumbs, StickyActionBar, SplitView (list/detail), AuthLayoutmigratedOne place owns page padding and rhythm
FeedbackToast (success/info; error stays until dismissed per Design System 3.7), Banner (exists as AlertBanner), InlineError, Skeleton (text, row, card, stat), Spinner, EmptyState (illustration, title, body, primary CTA), ErrorState (with retry + support link), ProgressBarnew / migratedFills the missing loading/success layers
OverlaysDialog, ConfirmDialog (consequence-stating, typed confirm for destructive), Sheet (mobile nav, filters, detail panel), Popover, DropdownMenu, Tooltip, CommandPalettenew / migratedRadix-based; focus trap and restore for free
Domain (stay in apps)KanbanBoard/KanbanColumn/KanbanCard (adds overdue state, count, drag-and-drop if approved), LineItemsEditor (shared by quotes and repair orders), PermissionGrid, GarageSwitcher, LicenseBanner, PlanCard, AllocationFormextractPages shrink: QuoteFormPage 515 → ~200 lines

6.4. Consolidation and extraction map

TodayBecomes
.table-card + <table> ×15 + PaginationFooterDataTable
.search-row ×9, .search-field ×5, .search-input (dead)FilterBar with SearchInput
.primary-button/.secondary-button/.danger-button/.icon-button/.link-buttonButton / IconButton
Modal, FormModal, ConfirmDialogDialog + FormDialog + ConfirmDialog (same API for existing call sites to ease migration)
.quote-total-bar ×3, .save-bar, .public-quote-actionsStickyActionBar
.status, .tag, .badge, .kanban-count, .subscription-status, .role-badgeBadge, Count
Line-items table in Quote and Repair Order formsLineItemsEditor
KpiCard ×3StatStrip
EmptyState + bare <p class="muted">EmptyState / ErrorState / NoResults
2,118-line styles.csstokens + a ~150-line base.css; component styles co-located via Tailwind classes

7. Responsive Strategy

7.1. Breakpoints and posture

NameWidthLayoutPosture
base< 640Single column, 16px gutters, top bar + sheet navigationUsable, not optimized (Flutter Owner App is the phone product)
sm≥ 640Two-column forms, 2-up stat gridLarge phones, landscape
md≥ 768Sidebar becomes icon rail (with tooltips + visible active label via tooltip), tables show full columnsTablet (matches Spec 2.1)
lg≥ 1024Rail expands to full sidebar on user preferenceSmall laptop
xl≥ 1280Full sidebar, 12-col grid, 24px guttersPrimary design target (Spec 2.1)

Mobile-first CSS (Tailwind default); container queries (@container) for panels and stat strips so a component adapts to the space it gets, not the viewport.

7.2. Pattern rules

PatternDesktopTabletPhone
NavigationSidebar (collapsible)Icon rail + tooltipsMenu button → left Sheet with grouped nav, garage switcher at top; no display:none without a replacement
Page headerTitle + actions inlinesameTitle, then actions below; primary action becomes full width
DataTableFull columns, sticky headerHide low-priority columns (priority prop)Card list: first column as card title, 2–3 key fields, status badge, overflow menu; no horizontal scroll
FiltersInline FilterBarsameSearch always visible, filters collapse into a Sheet with “Apply” and active-filter chips
Forms2–3 columns2 columns1 column, 16px text inputs, labels above, numeric keyboards via inputmode
DialogCentered, up to 480/760sameBottom sheet (full height when form > 60 % viewport), safe-area padding
Sticky action barBottom of panelsameAnchored above the keyboard (env(keyboard-inset-height) / visualViewport), safe-area aware
Kanban4 columns3 visible + scrollSingle column with a status tab bar (snap-scroll optional)
ChartsAxis + tooltipsameFewer ticks, tap-to-show tooltip
Touch36px controls36px44px hit areas via @media (pointer: coarse)

7.3. Verification

Every PR touching layout is checked at 360, 768 and 1280 px (Storybook viewports + one Playwright smoke per top-level route in the existing vietgara-e2e repo, see Phase 6 in Section 10).

8. Animation and Motion Strategy

Principle: motion explains change; it never decorates. Total motion budget per interaction ≤ 240 ms; anything longer is progress, not animation.

MomentBehaviorTokens
Hover / press / focusBackground and border color transition; press = translateY(0.5px) or darker stepfast
Button loadingLabel stays (no width jump), spinner replaces icon; aria-busybase
Dialog / SheetFade + 8 px rise (dialog), slide from edge (sheet); backdrop fade; exit faster than enterslow / exit
Popover / menu / tooltipFade + 4 px offset, transform-origin at trigger; tooltip delay 400 msbase
TabsActive indicator slides to new tabbase
ToastSlide/fade in from bottom-right, auto-dismiss 5 s (error persists); swipe to dismiss on touchbase
Data loadingSkeletons matching final layout, shimmer ≤ 1.5 s loop; on arrival, 160 ms cross-fade (no layout shift)slow
List changesRow removed → collapse height 160 ms; row added/updated → 1.2 s soft highlight that fadesbase
Number change (KPI, totals)Value cross-fades; no count-up animationsbase
KanbanCard move: layout animation; if DnD is approved, lift (e2) + drop-target highlightbase
Route changeContent fade 100 ms only; focus moves to the page headingfast
Realtime (customer progress, later)Subtle pulse on newly arrived status, onceslow

Rules: animate only opacity and transform (and color); no animation on first paint of static content; @media (prefers-reduced-motion: reduce) disables transforms and shimmer, keeping instant opacity changes; no auto-playing loops except spinners/skeletons; spinners respect a 300 ms show-delay to avoid flicker.

9. Accessibility Strategy

9.1. Target

Raise the web target from “at least WCAG 2.1 Level A” (UX Guideline §7, NFR-US-04) to WCAG 2.2 Level AA for the main flows. The effort is small once tokens are fixed (most failures in Section 2 row 16 are token values), and it is the baseline a procurement-minded garage group or insurer partner will ask for. Changing NFR-US-04 needs product-owner sign-off (Section 11, Q6).

9.2. Requirements

AreaRequirementToday → fix
ContrastText ≥ 4.5 : 1, large text and UI boundaries/focus ≥ 3 : 1Focus 1.8 → 3.9; control border 1.47 → 3.6; pagination text 2.54 → ≥ 5; tokens are checked in CI (Section 9.3)
FocusVisible :focus-visible ring, 2 px + 2 px offset, never removed; focus not obscured by sticky bars (scroll-padding)Ring color fixed; sticky bars get scroll-padding-bottom (WCAG 2.2 · 2.4.11)
KeyboardEverything reachable and operable; no keyboard traps; logical order; skip-to-content link; shortcuts (⌘K, / to focus search, g then letter nav later) are documented and can be disabledAdd skip link; Radix handles menus/tabs/dialogs roving focus
DialogsFocus trap, initial focus, focus restore to trigger, Escape closes only the top layer, scroll lock, backdrop click closes non-destructive dialogsReplace hand-rolled Modal with Radix Dialog
FormsVisible label, required marker + aria-required, error summary on submit with links to fields, errors announced (role="alert" once), no placeholder-as-label, autocomplete tokens, accessible authentication (no cognitive test; OTP supports paste/autofill)Add error summary and autocomplete; Field wires ids
TargetsPointer targets ≥ 24 × 24 CSS px (2.5.8 AA) and ≥ 44 × 44 on touch40 → 44 hit areas
Tables<caption>/accessible name, scope, sortable headers as buttons with aria-sort, row actions labelled with the row subject (“Edit Nguyen Van A”)Add; today labels are generic (“Edit”)
Status & colorNever color alone: every Badge carries text and iconAlready true for banners; extend to badges and Kanban overdue
Live regionsToasts role="status" (errors role="alert"); loading regions aria-busy; route change announces titleAdd
Page titles & landmarksPer-route document.title (“Quotes · Garage A · VietGara”), one h1, header/nav/main landmarks, nav labelledTitles are static today
Languagelang follows language switch (already done); mark mixed-language stringsOK
Motionprefers-reduced-motion honored; no flashingAdd
Zoom/reflowUsable at 200 % zoom and 320 px width without horizontal scroll except data tables with a card fallbackFixed by responsive strategy
Drag-and-dropEvery drag has a keyboard/menu alternative (Design System 3.5)Required if DnD ships

9.3. Process

  • eslint-plugin-jsx-a11y in lint; vitest-axe on each component test; @storybook/addon-a11y on every story.
  • A token contrast test that fails the build if any text/* × surface/* or border/control × surface/* pair drops below its threshold (light and dark).
  • Manual pass per release: keyboard-only walkthrough of sign-in → create quote → record payment; NVDA/VoiceOver spot check on dialogs and tables; 200 % zoom.
  • Extend Design QA Checklist (03_Design_System.md §8) with these items.

10. Refactoring Plan (ordered by priority)

Ordering principle: fix what blocks users or multiplies cost first, then build the kit, then migrate screens by traffic and risk, then polish. Each phase is independently shippable, keeps tests green, and changes no business logic. Size: S ≈ ≤ 3 days, M ≈ 1 week, L ≈ 2 weeks (one engineer).

PhaseGoalWorkSizeExit criteria
0 — Decisions & spikeUnblockAnswer the Section 11 questions. 1-day spike: Radix Dialog + Button + tokens in owner-web behind current CSS to prove the stack and bundle cost. High-fidelity mock of 4 anchor screens (Dashboard, Quotes list, Quote form, Sign-in) in Figma or directly in Storybook.MDirection approved; bundle delta known
1 — Stop-the-bleeding fixes (can ship before the redesign)Fix what is broken today at low cost(a) Phone navigation: replace display:none sidebar with a sheet menu. (b) Contrast: focus ring, control borders, pagination, breadcrumb, 10–11px text. (c) Dialog: focus trap/restore, top-layer Escape, scroll lock. (d) Global td{height:76px} → 48px. (e) Per-route document.title; skip link. (f) Self-host fonts, drop CSS @import. (g) Delete dead CSS; dedupe the 8 double-defined rules. (h) Pagination buttons readable when enabled. (i) Rendered-review quick wins: page-header spacing, sign-in language switcher and error styling, sidebar group spacing, 404 console error, chart single-bar width (R1, R2, R5, R6, R9, R10, R16).S–MItems verified by hand + axe; no visual redesign yet
2 — Foundation (UI kit repo)One source of truthCreate vietgara-web-ui: tokens (light + dark), Tailwind @theme, base.css, fonts, Button, IconButton, Field/inputs, Badge, Panel, Dialog/ConfirmDialog, Tooltip, Toast, Skeleton, EmptyState/ErrorState, Avatar, PlateChip, Money; Storybook with a11y addon; token-contrast test; publish v0.1.0; consume from admin-web first (smallest app: 7 pages)LAdmin-web runs on the kit with identical behavior; its styles.css is deleted; tests pass
3 — Shell & layoutNew visual identity visible everywhereAppShell (sidebar groups, rail, sheet, topbar), GarageSwitcher, CommandPalette, PageLayout/PageHeader/Breadcrumbs/Tabs, StickyActionBar, new AuthLayout; apply to both appsMAll routes render inside the new shell, old shell CSS removed
4 — Data componentsBiggest daily-use winDataTable (+ mobile cards), Pagination, FilterBar, StatStrip, chart upgrade, DescriptionList; migrate in order Customers → Vehicles → Quotes → Settlements → Inventory → Staff → Partners → Garages → Roles/Subscription (simple, high-traffic first) and DashboardL15 hand-rolled tables gone; skeleton + empty + error states on each
5 — Complex screensHighest risk, lastExtract LineItemsEditor and migrate Quote form and Repair Order form; Kanban restyle + overdue state (+ DnD if approved); Settlement detail; Role editor grid; Garage settings; Public quote page (customer-facing — design it as the trust-critical screen per Spec 4.1)LPage components ≤ ~250 lines; keyboard-complete
6 — Quality gateKeep it fixedPlaywright smoke per route at 360/768/1280 in vietgara-e2e; visual regression on Storybook (Chromatic or Playwright snapshots); axe in CI; Lighthouse budget; update Design QA checklistMCI blocks regressions
7 — PolishThe “professional product” finishSpot illustrations, motion pass (Section 8), copy pass for empty states/confirmations in VI and EN, dark theme toggle (if Q5 = yes), onboarding checklist on first runMDesign review sign-off

Dependencies: 1 is independent; 2 → 3 → 4 → 5; 6 starts alongside 2 (tokens test) and widens; 7 last. Phases 1 + 2 + 3 deliver most of the visible change.

10.1. Risks

RiskMitigation
Big-bang rewrite stalls feature workKit-first, migrate page by page behind the same routes; old CSS and kit coexist until a page is migrated; each PR is shippable
Kit repo becomes a bottleneckSame tag-pin flow as vietgara-flutter-core; apps may pin different versions during migration
i18n strings grow with new states (empty CTAs, toasts, confirmations)Add keys in vi.ts and en.ts together (existing convention); copy review in Phase 7
Vietnamese text is longer than English in placesDesign with 30 % expansion; no fixed-width buttons; truncate with tooltip only for names/emails
Radix/Tailwind learning curveSpike in Phase 0; Storybook as living documentation
Tests assert on class names or textExisting tests are mostly behavioral (auth.test.tsx, roles.test.tsx); migrate them per page

11. Decisions (resolved)

All recommendations below were approved by the product owner on October 7, 2026.

#DecisionOutcome
Q1Visual directionLight shell, Gara Blue, slate neutrals (“Workshop Precision”)
Q2Brand colorPrimary moves to #1F66B3 on web and Flutter
Q3Shared codeNew vietgara-web-ui repo, tag-pinned by both web apps
Q4LibrariesRadix UI, class-variance-authority, sonner, cmdk, Motion; react-hook-form + zod for the two large forms only; in-house DataTable first
Q5Dark modeTokens now, user-facing toggle in Phase 7
Q6Accessibility targetWCAG 2.2 AA for web (amends NFR-US-04)
Q7Kanban drag-and-dropKeep the board read-only; keyboard-accessible status menu on cards
Q8FigmaStorybook + token JSON are the source of truth until a Figma library exists
Q9Phase 1 quick fixesShipped as separate PRs ahead of the redesign
Q10First implementation scopevietgara-admin-web first (proves the kit), then vietgara-owner-web; the four Flutter apps follow in the same refactor effort using vietgara_core

No code is changed until this documentation PR is approved.