docs(plan): MUI migration Phase 1 (AppShell) implementation plan
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,357 @@
|
|||||||
|
# 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:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
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: Verify** — `npx 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:
|
||||||
|
```tsx
|
||||||
|
<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: Verify** — `npx 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):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
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 --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 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:** `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).
|
||||||
Reference in New Issue
Block a user