19 KiB
MUI Migration — Phase 1: AppShell — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax.
Goal: Replace the admin chrome (AdminLayout + Sidebar) with a MUI <AppShell> in the Soft-SaaS look — a Drawer-based sidebar (permanent on desktop, temporary on mobile) with a white active "pill", a minimal topbar (hamburger + theme toggle), reusing the existing permission-filtered nav data and preserving all behavior (auth/2FA guards, logout scale+blur animation, ShortcutsHelp).
Architecture: The valuable, well-tested parts are reused, not rewritten: the menuSections nav data (icons, permissions, active-match rules) and the active/permission logic move into navData.tsx. SidebarNav renders that data with MUI List. AppShell provides the responsive Drawer + topbar + auth guards + the framer-motion logout wrapper, and renders <Outlet/>. AdminLayout/Sidebar/layout.css are deleted when AppShell lands.
Tech Stack: React 18.3, MUI v7 (@mui/material), Emotion, framer-motion (kept for logout), react-router v6, TypeScript strict.
Spec: docs/superpowers/specs/2026-06-06-css-mui-migration-design.md (§5 AppShell, §7 aesthetic, §8 Phase 1, §9 animation).
Authoritative gates: npx tsc -b --noEmit (NOT -p tsconfig.json — that's a vacuous solution file), npm run build:client, npx vitest run. Live visual verification (dev server) is the USER's; implementers do build/typecheck/tests only and must NOT start the dev server.
Behavior parity checklist (must all be preserved from AdminLayout.tsx + Sidebar.tsx):
- Auth:
loading→ spinner;!isAuthenticated→<Navigate to="/login" replace/>; 2FA-setup redirect (user.require2FA && !user.totpEnabled && pathname !== "/"→/). - Logout:
handleLogoutsets a logout alert (setLogoutAlert()), closes the mobile drawer, and callslogout()after 400ms; the layout plays the framer-motion{scale:1.5, opacity:0, filter:"blur(12px)"}exit over 0.4s. - Sidebar: permission-filtered sections/items (empty sections hidden); custom active matching (
matchPrefix/matchAlso/matchExclude/end); company logo with light/dark variant +onErrorfallback to/images/logo-{dark,light}.png; user chip (avatar initial + name +roleDisplay); logout button; mobile open/close. ShortcutsHelpstill rendered;<Outlet/>for page content.
File Structure
| File | Responsibility | Action |
|---|---|---|
src/admin/ui/navData.tsx |
MenuItem/MenuSection types, the menuSections array (moved verbatim from Sidebar), pure helpers isItemActive(item, pathname) + hasItemPermission(item, hasPermission) |
Create (move) |
src/admin/ui/SidebarNav.tsx |
Renders navData with MUI List: logo header, sections, white-pill active items, user+logout footer |
Create |
src/admin/ui/AppShell.tsx |
Responsive Drawer + topbar + auth guards + logout animation + <Outlet/> + ShortcutsHelp |
Create |
src/admin/components/Sidebar.tsx |
(interim) import menuSections/helpers from navData instead of defining inline |
Modify, then Delete |
src/admin/components/AdminLayout.tsx |
Replaced by AppShell |
Delete |
src/admin/AdminApp.tsx |
Route uses <AppShell/> instead of <AdminLayout/>; remove import "./layout.css" |
Modify |
src/admin/layout.css |
Chrome styling now in MUI | Delete |
Import paths from src/admin/ui/ (same depth as components/): ../context/AuthContext, ../../context/ThemeContext, ../utils/api, ../hooks/..., ../theme.
Task 1: Extract nav data into navData.tsx
Files: Create src/admin/ui/navData.tsx; Modify src/admin/components/Sidebar.tsx.
- Step 1: Create
src/admin/ui/navData.tsx. MOVE (cut) the following fromsrc/admin/components/Sidebar.tsxverbatim: theMenuItemandMenuSectioninterfaces and the entiremenuSectionsarray (all 6 sections with their inline SVG icons — do not retype, move them unchanged). Then add the two pure helpers (extracted from Sidebar's inline closures), and export everything:
import { type ReactNode } from "react";
export interface MenuItem {
path: string;
label: string;
end?: boolean;
permission?: string | string[];
matchPrefix?: string;
matchAlso?: string[];
matchExclude?: string[];
icon: ReactNode;
}
export interface MenuSection {
label: string;
items: MenuItem[];
}
export const menuSections: MenuSection[] = [
/* ... the 6 sections, MOVED verbatim from Sidebar.tsx ... */
];
/** Active-state matching (mirrors the original Sidebar logic, incl. matchAlso). */
export function isItemActive(item: MenuItem, pathname: string): boolean {
if (item.matchPrefix) {
let active = pathname.startsWith(item.matchPrefix);
if (active && item.matchExclude) {
active = !item.matchExclude.some((ex) => pathname.startsWith(ex));
}
if (!active && item.matchAlso) {
active = item.matchAlso.some((p) => pathname.startsWith(p));
}
return active;
}
if (item.matchAlso && item.matchAlso.some((p) => pathname.startsWith(p))) {
return true;
}
if (item.end) return pathname === item.path;
return pathname.startsWith(item.path);
}
/** Permission check: true if no permission, or any listed permission is held. */
export function hasItemPermission(
item: MenuItem,
hasPermission: (p: string) => boolean,
): boolean {
if (!item.permission) return true;
if (Array.isArray(item.permission)) {
return item.permission.some((p) => hasPermission(p));
}
return hasPermission(item.permission);
}
Note: the original Sidebar computed
matchAlsoseparately in theNavLinkclassName callback. The consolidatedisItemActiveabove foldsmatchAlsoin so a single function is the source of truth. Preserve every section/item exactly.
-
Step 2: Update
Sidebar.tsxto import fromnavData(interim — Sidebar is deleted in Task 4). Remove the inlineMenuItem/MenuSection/menuSectionsdefinitions and the inlineisItemActive/hasItemPermission; import them from../ui/navData. In theNavLinkclassNamecallback, replace the inline active+matchAlso logic withisItemActive(item, location.pathname). -
Step 3: Verify —
npx tsc -b --noEmitclean;npm run build:clientsucceeds;npx vitest run(147 pass — no behavior change). -
Step 4: Commit
src/admin/ui/navData.tsx+src/admin/components/Sidebar.tsx(msg:refactor(mui): extract sidebar nav data + active/permission helpers to navData; end with theCo-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>trailer).
Task 2: SidebarNav.tsx (MUI drawer content)
Files: Create src/admin/ui/SidebarNav.tsx.
Renders the permission-filtered nav with MUI. Props: { onNavigate?: () => void; onLogout: () => void } (onNavigate closes the mobile drawer; onLogout is owned by AppShell).
-
Step 1: Create the component. Requirements (write complete MUI code):
const { user, hasPermission } = useAuth();(../context/AuthContext),const { theme } = useTheme();aliased from../../context/ThemeContext,const location = useLocation();.visibleSections=menuSectionsmapped to filter items byhasItemPermission(item, hasPermission), dropping empty sections.- Root: a flex column
Box,height: "100%", padding ~1.5. - Logo header:
Box component="img"withsrc=/api/admin/company-settings/logo?variant=${theme === "dark" ? "dark" : "light"}andonErrorfalling back to/images/logo-${theme}.png;sx={{ maxHeight: 40, maxWidth: "100%", objectFit: "contain" }}. - Nav: scrollable
Box(flex: 1, overflowY: "auto"). For each section: aTypography variant="caption"subheader (uppercase,letterSpacing: ".06em",fontWeight: 700,color: "text.secondary",fontSize: ".64rem",px: 1.5), then aList disablePaddingof items. Each item:(Use MUI's<ListItemButton component={NavLink} to={item.path} end={item.end} onClick={onNavigate} selected={isItemActive(item, location.pathname)} sx={{ borderRadius: 2, mb: 0.25, py: 0.6, color: "text.secondary", "& svg": { width: 18, height: 18 }, "&.Mui-selected, &.Mui-selected:hover": { bgcolor: "background.paper", color: "primary.main", fontWeight: 700, boxShadow: "0 2px 8px rgba(20,20,40,0.07)", }, }} > <ListItemIcon sx={{ minWidth: 32, color: "inherit" }}> {item.icon} </ListItemIcon> <ListItemText primary={item.label} primaryTypographyProps={{ fontSize: ".82rem", fontWeight: "inherit" }} /> </ListItemButton>selectedprop for the active "pill" — do NOT rely on NavLink's default active class, because the active rule is custom.) - Footer: top-bordered
Box(borderColor: "divider") with a user chip (MUIAvatarbgcolor: "primary.main"showinguser?.fullName?.charAt(0) || user?.username?.charAt(0) || "U", plus name +roleDisplayTypographywithnoWrap), and a logoutButton(color="inherit",fullWidth, the existing logout SVG asstartIcon, label "Odhlásit se",onClick={onLogout}).
-
Step 2: Verify —
npx tsc -b --noEmitclean;npm run build:clientsucceeds. (Component not yet mounted; visual check is later.) -
Step 3: Commit
src/admin/ui/SidebarNav.tsx(msg:feat(mui): SidebarNav — MUI List nav with white-pill active state; + trailer).
Task 3: AppShell.tsx (responsive layout + guards + animation)
Files: Create src/admin/ui/AppShell.tsx.
- Step 1: Create the component with this exact structure (fill the guard bodies from the parity checklist):
import { useState, useCallback } from "react";
import { Outlet, Navigate, useLocation } from "react-router-dom";
import { motion } from "framer-motion";
import Box from "@mui/material/Box";
import Drawer from "@mui/material/Drawer";
import IconButton from "@mui/material/IconButton";
import ScopedCssBaseline from "@mui/material/ScopedCssBaseline";
import { useAuth } from "../context/AuthContext";
import { useTheme as useAppTheme } from "../../context/ThemeContext";
import { setLogoutAlert } from "../utils/api";
import SidebarNav from "./SidebarNav";
import ShortcutsHelp from "../components/ShortcutsHelp";
const DRAWER_WIDTH = 248;
export default function AppShell() {
const { isAuthenticated, loading, user, logout } = useAuth();
const { theme, toggleTheme } = useAppTheme();
const [mobileOpen, setMobileOpen] = useState(false);
const [loggingOut, setLoggingOut] = useState(false);
const location = useLocation();
const handleLogout = useCallback(() => {
setLoggingOut(true);
setMobileOpen(false);
setLogoutAlert();
setTimeout(() => logout(), 400);
}, [logout]);
if (loading) {
return (
<ScopedCssBaseline>
<Box
sx={{
display: "flex",
minHeight: "100vh",
alignItems: "center",
justifyContent: "center",
bgcolor: "background.default",
}}
>
<div className="admin-spinner" />
</Box>
</ScopedCssBaseline>
);
}
if (!isAuthenticated) return <Navigate to="/login" replace />;
if (user?.require2FA && !user?.totpEnabled && location.pathname !== "/") {
return <Navigate to="/" replace />;
}
return (
<ScopedCssBaseline>
<motion.div
initial={{ opacity: 0, scale: 0.98 }}
animate={
loggingOut
? { scale: 1.5, opacity: 0, filter: "blur(12px)" }
: { scale: 1, opacity: 1, filter: "none" }
}
transition={{
duration: loggingOut ? 0.4 : 0.25,
ease: [0.4, 0, 0.2, 1],
}}
style={{ minHeight: "100vh" }}
>
<Box
sx={{
display: "flex",
minHeight: "100vh",
bgcolor: "background.default",
}}
>
{/* Desktop permanent drawer */}
<Drawer
variant="permanent"
sx={{
display: { xs: "none", md: "block" },
width: DRAWER_WIDTH,
flexShrink: 0,
"& .MuiDrawer-paper": {
width: DRAWER_WIDTH,
boxSizing: "border-box",
border: 0,
bgcolor: "background.default",
},
}}
>
<SidebarNav onLogout={handleLogout} />
</Drawer>
{/* Mobile temporary drawer */}
<Drawer
variant="temporary"
open={mobileOpen}
onClose={() => setMobileOpen(false)}
ModalProps={{ keepMounted: true }}
sx={{
display: { xs: "block", md: "none" },
"& .MuiDrawer-paper": {
width: DRAWER_WIDTH,
boxSizing: "border-box",
},
}}
>
<SidebarNav
onNavigate={() => setMobileOpen(false)}
onLogout={handleLogout}
/>
</Drawer>
{/* Main column */}
<Box
sx={{
flex: 1,
minWidth: 0,
display: "flex",
flexDirection: "column",
}}
>
<Box
component="header"
sx={{
display: "flex",
alignItems: "center",
gap: 1,
px: 2,
py: 1.5,
minHeight: 64,
}}
>
<IconButton
onClick={() => setMobileOpen(true)}
aria-label="Otevřít menu"
sx={{ display: { md: "none" } }}
>
{/* hamburger SVG (3 lines), 24x24, stroke currentColor */}
</IconButton>
<Box sx={{ flex: 1 }} />
<IconButton
onClick={toggleTheme}
aria-label={theme === "dark" ? "Světlý režim" : "Tmavý režim"}
title={theme === "dark" ? "Světlý režim" : "Tmavý režim"}
>
{/* sun SVG when theme==='dark' is off / moon — keep the two SVGs from AdminLayout, render the one for the current theme */}
</IconButton>
</Box>
<Box component="main" sx={{ flex: 1, px: { xs: 2, md: 3 }, pb: 4 }}>
<Outlet />
</Box>
</Box>
</Box>
<ShortcutsHelp />
</motion.div>
</ScopedCssBaseline>
);
}
Fill the two IconButton SVGs from the existing AdminLayout.tsx (the hamburger 3-line icon; the sun/moon — render the moon when theme === "dark", the sun when light, matching the current toggle's meaning). Keep the admin-spinner class for the loading state (it's defined in a CSS file that still exists in Phase 1).
-
Step 2: Verify —
npx tsc -b --noEmitclean;npm run build:clientsucceeds. -
Step 3: Commit
src/admin/ui/AppShell.tsx(msg:feat(mui): AppShell — responsive Drawer + topbar, guards + logout animation; + trailer).
Task 4: Wire AppShell, delete legacy chrome
Files: Modify src/admin/AdminApp.tsx; Delete src/admin/components/AdminLayout.tsx, src/admin/components/Sidebar.tsx, src/admin/layout.css.
-
Step 1: Swap the route. In
src/admin/AdminApp.tsx: change the importimport AdminLayout from "./components/AdminLayout";→import AppShell from "./ui/AppShell";, and the route<Route element={<AdminLayout />}>→<Route element={<AppShell />}>. Remove the lineimport "./layout.css";. -
Step 2: Delete
src/admin/components/AdminLayout.tsx,src/admin/components/Sidebar.tsx, andsrc/admin/layout.css. Then grep the repo for any remaining import of those modules and fix/remove (there should be none beyond AdminApp). -
Step 3: Verify (full gates) —
npx tsc -b --noEmitclean;npm run build(server+client) succeeds and the prod bundle has no broken refs;npx vitest run(147 pass). Confirmlayout.cssis no longer imported anywhere. -
Step 4: Commit (msg:
feat(mui): adopt AppShell, remove AdminLayout/Sidebar/layout.css; + trailer). -
Step 5: MANUAL verification (USER, dev server) — deferred: sidebar shows permission-filtered sections; active item is the white pill; navigation works + closes the mobile drawer; theme toggle works; logout plays the scale+blur animation; logo light/dark + fallback; responsive (permanent drawer ≥ md, hamburger + temporary drawer < md); no console errors; dark + light both correct.
Self-review notes (for the controller)
- Spec coverage: Phase 1 (spec §8) — AppShell chrome via MUI Drawer + topbar (Tasks 2–4), reusing nav data/permissions (Task 1), preserving the logout animation + guards (Task 3), deleting
layout.css(Task 4). Login stays OUT of this (it's a sibling route — Phase 3), untouched. - Type consistency:
navDataexportsMenuItem/MenuSection/menuSections/isItemActive/hasItemPermission;SidebarNavand (interim)Sidebarboth consume them.AppShellprops toSidebarNav:onLogout(required),onNavigate(optional). - Parity risks to watch in review: the consolidated
isItemActivemust reproduce the originalmatchAlsobehavior (the original appliedmatchAlsoonly in the NavLink callback) — the spec reviewer must diff active states for/attendance/admin(matchAlso/attendance/create,/attendance/location) and/offers(matchExclude). Theselectedprop must not double-apply with NavLink's own active class.ScopedCssBaselinewraps the shell so MUI reset applies; unmigrated page content inside<Outlet/>still uses its own CSS. - Deferred: all live visual checks (no dev server in implementer scope).