Architecture¶
Project Setup¶
| Tool | Version / Config |
|---|---|
| Node.js | LTS (20+) |
| Package manager | npm / bun |
| Build tool | Vite 7 |
| TypeScript | 5.9 (strict mode) |
| Linter | ESLint (Vite default + React hooks) |
vite.config.ts¶
Standard Vite React plugin configuration. No custom port, no proxy — the app connects to a mock data layer in development. In production, API calls would replace mock imports.
Entry Point¶
index.html¶
Standard HTML shell with <div id="root"> and a <script type="module" src="/src/main.tsx"> entry point. The viewport meta tag ensures correct scaling on the tablet's 1024 × 616 px display.
src/main.tsx¶
Standard React 19 ReactDOM.createRoot render. Mounts <App /> into #root.
Application Root – src/App.tsx¶
App.tsx composes the entire application:
No React Context providers are used — global state is managed exclusively via three Zustand stores (authStore, cartStore, workflowStore).
AuthGuard¶
function AuthGuard({ children }: { children: React.ReactNode }) {
const user = useAuthStore((s) => s.user);
if (!user) return <Navigate to="/login" replace />;
return <>{children}</>;
}
AuthGuard wraps the root / route. Any navigation to a protected route while unauthenticated redirects to /login. After login, the user is redirected based on role: approvers go to /approvals, requesters go to /history.
Route Structure¶
<Routes>
{/* Role-aware login redirect */}
<Route path="/login" element={
user ? <Navigate to={user.role === 'approver' ? '/approvals' : '/history'} replace />
: <LoginPage />
} />
<Route path="/" element={<AuthGuard><TabletLayout /></AuthGuard>}>
{/* Role-aware index redirect */}
<Route index element={
<Navigate to={user?.role === 'approver' ? '/approvals' : '/history'} replace />
} />
{/* Approver routes */}
<Route path="approvals" element={<ApprovalsPage />} />
{/* Requester routes */}
<Route path="history" element={<RequestHistoryPage />} />
<Route path="history/create" element={<CreateRequestPage />} />
<Route path="history/checkout" element={<CheckoutPage />} />
<Route path="history/container" element={<ContainerSelectionPage />} />
<Route path="history/container-checkout" element={<ContainerCheckoutPage />} />
<Route path="history/return-trolley" element={<ReturnTrolleyPage />} />
{/* Shared routes */}
<Route path="staging" element={<StagingAreaPage />} />
<Route path="inventory" element={<WIPInventoryPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="profile" element={<ProfilePage />} />
</Route>
<Route path="*" element={<Navigate to="/login" replace />} />
</Routes>
The / route renders TabletLayout as its shell. All child routes render into the <Outlet /> inside TabletLayout.
Layout System¶
Viewport Budget¶
md+ (≥ 900px)
┌───────────────────────────────────────────────┐
│ ┌──────────┐ ┌─────────────────────────────┐│
│ │ │ │ ││
│ │ Sidebar │ │ Page content (Outlet) ││
│ │ 153px │ │ flex: 1, overflow ││
│ │ │ │ ││
│ └──────────┘ └─────────────────────────────┘│
└───────────────────────────────────────────────┘
← Outer box: fills viewport (max 1366px), p: 1.5, gap: 1.5, bgcolor: #e9e9e9 →
< md (< 900px — mobile)
┌──────────────────────┐
│ ☰ [top bar] │
│ ┌────────────────────┤
│ │ │
│ │ Page content │
│ │ (Outlet) │
│ │ │
│ └────────────────────┤
└──────────────────────┘
Sidebar → temporary Drawer (opened via hamburger)
Both the sidebar card and the main content card have borderRadius: '10px' and border: '1px solid #e0e0e0' to create a floating card effect on a grey background. Page content fills 100% of the content card height via flex layout.
TabletLayout.tsx¶
<Box sx={{ display: 'flex', height: '100%', overflow: 'hidden',
bgcolor: '#e9e9e9', p: 1.5, gap: 1.5, boxSizing: 'border-box' }}>
{/* Permanent sidebar card — md+ only */}
{!isMobile && (
<Box sx={{ width: SIDEBAR_WIDTH, flexShrink: 0, height: '100%',
border: '1px solid #e0e0e0', borderRadius: '10px',
overflow: 'hidden', bgcolor: '#fff' }}>
<TabletSidebar />
</Box>
)}
{/* Temporary drawer — mobile only */}
{isMobile && (
<Drawer variant="temporary" open={mobileOpen} onClose={...}
sx={{
'& .MuiDrawer-paper': { width: SIDEBAR_WIDTH, height: '100vh' },
'@supports (height: 100dvh)': { '& .MuiDrawer-paper': { height: '100dvh' } },
}}>
<TabletSidebar onClose={...} />
</Drawer>
)}
{/* Content card — flex: 1 */}
<Box component="main" sx={{ flex: 1, display: 'flex', flexDirection: 'column',
overflow: 'hidden', minWidth: 0,
border: '1px solid #e0e0e0', borderRadius: '10px',
bgcolor: '#fff' }}>
{/* Mobile hamburger top bar */}
{isMobile && (
<Box sx={{ px: 1, py: 0.5, borderBottom: '1px solid #e0e0e0',
display: 'flex', alignItems: 'center', flexShrink: 0 }}>
<IconButton onClick={openDrawer}><MenuIcon /></IconButton>
</Box>
)}
<Box sx={{ flex: 1, overflow: 'hidden', display: 'flex',
flexDirection: 'column', minHeight: 0 }}>
<Outlet />
</Box>
</Box>
</Box>
isMobile = useMediaQuery(theme.breakpoints.down('md')) — triggers below 900 px.
Layout Width Stability¶
The App wrapper in App.tsx uses width: '100%' to anchor the layout to a definite viewport width:
// App.tsx
<Box sx={{ width: '100%', maxWidth: 1366, mx: 'auto', height: '100%', overflow: 'hidden' }}>
mx: 'auto' on a flex item of a column flex container (#root) suppresses align-items: stretch, which would otherwise make the wrapper's width content-determined rather than viewport-filling. The explicit width: '100%' ensures the layout always fills the available width regardless of how much content each page renders. maxWidth: 1366 caps and centres the layout on very wide displays.
The minHeight: 0 Pattern¶
This is the most critical layout rule. Every flex container in the scroll chain must have minHeight: 0 — without it, a flex child with overflow: auto cannot shrink below its intrinsic content height, preventing scrollbars and pushing content off-screen.
Required pattern for every page:
{/* Outer page box — fills the content card Outlet */}
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0 }}>
{/* Fixed sub-header — never scrolls */}
<Box sx={{ ..., flexShrink: 0 }}>
<IconButton>Back</IconButton>
<Typography>Page Title</Typography>
</Box>
{/* Scrollable content area */}
<Box sx={{ flex: 1, minHeight: 0, overflow: 'auto', p: 2.5 }}>
{/* page content */}
</Box>
{/* Fixed submit bar — never scrolls */}
<Box sx={{ p: 2, borderTop: '1px solid', borderColor: 'divider', flexShrink: 0 }}>
<Button>Submit</Button>
</Box>
</Box>
Component Architecture¶
TabletSidebar.tsx¶
Exported constant: SIDEBAR_WIDTH = 153 (px).
The sidebar contains four zones (top to bottom):
- Logo — "AtiFlow v2.0" wordmark (teal accent on "Flow")
- Workflow selector — MUI
Selectdropdown bound toworkflowStore.activeWorkflow. Changing it callssetActiveWorkflow(wf), immediately updating all pages that read fromworkflowStore. - Nav items — Role-based primary routes. Active state:
rgba(0,169,157,0.19)background whenpathname.startsWith(item.path).
| Role | Nav items |
|---|---|
| Requester | Request History (/history), Staging Area (/staging), WIP Inventory (/inventory) |
| Approver | Requests (/approvals), Staging Area (/staging), WIP Inventory (/inventory) |
- Bottom utilities — Settings (
/settings), Help (no route), then a divider and the Profile row (/profile) with user avatar. The profile subtitle shows "Approver" or "Operator" based onuser.role.
The sidebar renders null when user is null (during the login flow).
State Management Architecture¶
Three independent Zustand stores replace the old monolithic AppContext:
| Store | File | Responsibility |
|---|---|---|
useAuthStore |
src/stores/authStore.ts |
User session (user, login, logout) |
useCartStore |
src/stores/cartStore.ts |
Material cart, container cart, return trolley flag |
useWorkflowStore |
src/stores/workflowStore.ts |
Active workflow selection |
See State Management for full store documentation.
Routing Details¶
Navigation Patterns¶
Three navigation patterns are used:
useNavigate(path)— direct programmatic navigation (after form submit, button click)useNavigate(-1)— browser history back (back arrow buttons in sub-headers)<Navigate to="..." replace />— redirect without history entry (auth guard, post-login)
Multi-Step Flows (same route, local state)¶
Some pages implement their own internal navigation using useState:
ReturnTrolleyPage:method = null | 'qr' | 'details'controls which screen rendersStagingAreaPage:selectedArea = null | StagingAreacontrols list vs. detail view
This keeps the URL stable (no back/forward navigation between sub-steps), which is intentional for industrial touch UIs where back-button confusion must be minimised.
Responsive Design¶
The application is fully responsive across all major screen sizes on a single codebase. Responsive behaviour is implemented via MUI sx breakpoint syntax — no separate mobile build or stylesheet.
Breakpoints¶
| Name | Range | Layout behaviour |
|---|---|---|
xs |
0 – 599 px | Single-column, full-width elements, most table columns hidden |
sm |
600 – 899 px | Wider single-column, cards begin showing side-by-side |
md |
900 – 1199 px | Two-panel layout (sidebar + content card); this is the native tablet size |
lg |
1200 px+ | Same as md; maxWidth: 1366 caps the layout width |
The sidebar switches from permanent to a temporary Drawer below md (< 900 px).
Per-page Responsive Changes¶
| Page | xs / sm behaviour |
|---|---|
| Login | Left decorative panel hidden; form full-width |
| Request History | Grid collapses to 2 columns (name + status); date/time/ID columns hidden |
| Approvals | Grid collapses to 2 columns; ID and Time columns hidden |
| WIP Inventory | Overview sidebar hidden; card grid switches to 2 columns; status/quantity columns hidden in list |
| Staging Area | SA cards go full-width at xs; detail header wraps vertically |
| Create Request | Wizard step scroller allows horizontal scroll; select fields go full-width; rows wrap vertically |
| Container Selection | Select fields go full-width; rows wrap vertically |
| Return Trolley | Select fields go full-width; rows wrap vertically |
Content that overflows a card scrolls horizontally within the card via overflowX: 'auto' on scroll containers. Card widths are always constant — set by flex: 1 on the main content card.
Browser Compatibility¶
Viewport Height (100dvh)¶
src/index.css uses a @supports progressive enhancement to switch html to 100dvh on browsers that support it:
html, body, #root {
height: 100%; /* base — chains percentage through parent */
}
@supports (height: 100dvh) {
html { height: 100dvh; } /* dynamic viewport height — excludes mobile browser chrome */
}
All layout containers (App.tsx, TabletLayout.tsx) use height: '100%' rather than height: '100vh' so they inherit through this chain. The MUI Drawer paper (which is position: fixed) keeps 100vh as its base with an @supports override to 100dvh.
| Browser | 100dvh support |
Fallback behaviour |
|---|---|---|
| Safari iOS / macOS | 15.4+ | height: 100% (= 100vh equivalent) |
| Chrome | 108+ | height: 100% (= 100vh equivalent) |
| Firefox | 101+ | height: 100% (= 100vh equivalent) |
Scrollbar Styling¶
Two layers of scrollbar styling are applied to cover all browsers:
/* Firefox */
* {
scrollbar-width: thin;
scrollbar-color: rgba(0, 0, 0, 0.15) transparent;
}
/* Chrome, Safari, Edge */
::-webkit-scrollbar { width: 4px; height: 4px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: rgba(0, 0, 0, 0.15); border-radius: 4px; }
Inline scroll containers that need custom colours (e.g. the trip history table in Request History) also carry scrollbarWidth and scrollbarColor in their sx props alongside the &::-webkit-scrollbar rules.
scrollbarGutter: 'stable'¶
All scroll containers use scrollbarGutter: 'stable' to pre-reserve scrollbar space on platforms with classic (non-overlay) scrollbars (Windows Chrome, Windows Firefox), preventing layout shift when a scrollbar appears or disappears. Safari silently ignores this property — on Safari, scrollbars are always overlay and take no space, so no layout shift occurs without it.
Feature Support Matrix¶
| Feature | Chrome | Firefox | Safari (macOS/iOS) | Edge |
|---|---|---|---|---|
100dvh |
✓ 108+ | ✓ 101+ | ✓ 15.4+ | ✓ (Chromium) |
scrollbar-gutter: stable |
✓ 94+ | ✓ 97+ | ignored (overlay scrollbars) | ✓ |
scrollbar-width / scrollbar-color |
— | ✓ 64+ | — | — |
-webkit-scrollbar |
✓ | — | ✓ | ✓ |
overscroll-behavior: none |
✓ 63+ | ✓ 59+ | ✓ 16+ | ✓ |
Flex gap |
✓ 84+ | ✓ 63+ | ✓ 14.1+ | ✓ |
Data Layer¶
All data is mocked in src/data/mock.ts. In production, these imports would be replaced with API calls. The Zustand store action signatures (addToCart, setContainerCart, etc.) are designed to remain stable when switching to real data.
| Export | Type | Description |
|---|---|---|
mockWorkflows |
Workflow[] |
3 workflows with different strategies |
mockDeviceUser |
DeviceUser |
Requester operator (Arjun, PA01, Station 001) |
mockApproverUser |
DeviceUser |
Approver supervisor (Priya, AP01, Station AP1) |
mockApprovalRequests |
ApprovalRequest[] |
5 pending approval requests |
mockMaterials |
MaterialSKU[] |
4 SKUs with 9 Sub-SKU types total |
mockContainers |
Container[] |
3 container types (Trolley/Pallet/Bin) with 7 subtypes |
mockStagingAreas |
StagingArea[] |
3 areas (all 40 rows × 5 cols = 200 cells each) |
mockRequests |
Request[] |
7 requests across different workflows/statuses |
mockInventory |
InventoryRow[] |
8 rows of inventory data |