Files
app/docs/superpowers/plans/2026-06-06-mui-migration-phase-1-appshell.md
2026-06-06 19:57:53 +02:00

19 KiB
Raw Blame History

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: handleLogout sets a logout alert (setLogoutAlert()), closes the mobile drawer, and calls logout() 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 + onError fallback to /images/logo-{dark,light}.png; user chip (avatar initial + name + roleDisplay); logout button; mobile open/close.
  • ShortcutsHelp still 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 from src/admin/components/Sidebar.tsx verbatim: the MenuItem and MenuSection interfaces and the entire menuSections array (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 matchAlso separately in the NavLink className callback. The consolidated isItemActive above folds matchAlso in so a single function is the source of truth. Preserve every section/item exactly.

  • Step 2: Update Sidebar.tsx to import from navData (interim — Sidebar is deleted in Task 4). Remove the inline MenuItem/MenuSection/menuSections definitions and the inline isItemActive/hasItemPermission; import them from ../ui/navData. In the NavLink className callback, replace the inline active+matchAlso logic with isItemActive(item, location.pathname).

  • Step 3: Verifynpx tsc -b --noEmit clean; npm run build:client succeeds; 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 the Co-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 = menuSections mapped to filter items by hasItemPermission(item, hasPermission), dropping empty sections.
    • Root: a flex column Box, height: "100%", padding ~1.5.
    • Logo header: Box component="img" with src = /api/admin/company-settings/logo?variant=${theme === "dark" ? "dark" : "light"} and onError falling back to /images/logo-${theme}.png; sx={{ maxHeight: 40, maxWidth: "100%", objectFit: "contain" }}.
    • Nav: scrollable Box (flex: 1, overflowY: "auto"). For each section: a Typography variant="caption" subheader (uppercase, letterSpacing: ".06em", fontWeight: 700, color: "text.secondary", fontSize: ".64rem", px: 1.5), then a List disablePadding of items. Each item:
      <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>
      
      (Use MUI's selected prop 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 (MUI Avatar bgcolor: "primary.main" showing user?.fullName?.charAt(0) || user?.username?.charAt(0) || "U", plus name + roleDisplay Typography with noWrap), and a logout Button (color="inherit", fullWidth, the existing logout SVG as startIcon, label "Odhlásit se", onClick={onLogout}).
  • Step 2: Verifynpx tsc -b --noEmit clean; npm run build:client succeeds. (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: Verifynpx tsc -b --noEmit clean; npm run build:client succeeds.

  • 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 import import AdminLayout from "./components/AdminLayout";import AppShell from "./ui/AppShell";, and the route <Route element={<AdminLayout />}><Route element={<AppShell />}>. Remove the line import "./layout.css";.

  • Step 2: Delete src/admin/components/AdminLayout.tsx, src/admin/components/Sidebar.tsx, and src/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 --noEmit clean; npm run build (server+client) succeeds and the prod bundle has no broken refs; npx vitest run (147 pass). Confirm layout.css is 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 24), 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: navData exports MenuItem/MenuSection/menuSections/isItemActive/hasItemPermission; SidebarNav and (interim) Sidebar both consume them. AppShell props to SidebarNav: onLogout (required), onNavigate (optional).
  • Parity risks to watch in review: the consolidated isItemActive must reproduce the original matchAlso behavior (the original applied matchAlso only in the NavLink callback) — the spec reviewer must diff active states for /attendance/admin (matchAlso /attendance/create,/attendance/location) and /offers (matchExclude). The selected prop must not double-apply with NavLink's own active class. ScopedCssBaseline wraps 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).