fix(nav): only the most-specific sidebar item is active

On /career/builder/{id} both "Career Workspace" (/career) and "CV Builder"
(/career/builder) highlighted, because AppShell tested each item with
`pathname === to || pathname.startsWith(to + "/")` — so /career matched
every /career/... child. No "most specific wins" rule.

Add AppShell.activeNavTo(pathname, tos): the longest `to` that the path is
at or under wins, across both nav lists; every other item is inactive. A
child route never lights up a parent nav item. `selected` now compares
against that single computed activeTo. Exported as a pure function so the
ownership rule is unit-tested directly (sidebar-active-nav.test.ts):
exactly one active item for /career, /career/builder and
/career/builder/{id}, and no double-highlight.

Also give the breadcrumb/title in App.tsx explicit /career/builder ->
"CV Builder" ownership (it previously showed "Career Workspace"), and
reframe the Career Workspace header to the "Career Profile" product
framing: "This information powers your CVs, applications, cover letters
and AI assistance."

Frontend only — no change to CareerProfiles, CvVariants, CV generation,
extraction APIs, AI, permissions or tenant isolation. Plan for the deeper
information-architecture work is in docs/career-workspace-ux-refactor.md,
staged so the 1376-line CareerProfilePage and the live CV/extraction
pipeline are refactored incrementally with verification, not in one risky
rewrite.

Verified: tsc clean, frontend build clean, 135 frontend tests pass
(128 + 7 new nav tests). Backend untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cesnimda
2026-07-20 02:29:50 +02:00
parent 4db8c08958
commit 4f69d395be
5 changed files with 180 additions and 4 deletions
+2
View File
@@ -85,6 +85,7 @@ function breadcrumbsFor(path: string, t: (k: any) => string): string[] {
if (path.startsWith("/trash")) return [t("home"), t("trash")];
if (path.startsWith("/settings")) return [t("home"), t("settings")];
if (path.startsWith("/profile")) return [t("home"), t("account"), t("profile")];
if (path.startsWith("/career/builder")) return [t("home"), "Career Workspace", "CV Builder"];
if (path.startsWith("/career")) return [t("home"), "Career Workspace"];
if (path.startsWith("/settings/connected-accounts")) return [t("home"), t("settings"), "Connected accounts"];
if (path.startsWith("/admin/audit")) return [t("home"), t("admin"), t("auditLog")];
@@ -104,6 +105,7 @@ function titleFor(path: string, t: (k: any) => string): string {
if (path.startsWith("/trash")) return t("trash");
if (path.startsWith("/settings")) return t("settings");
if (path.startsWith("/profile")) return t("profile");
if (path.startsWith("/career/builder")) return "CV Builder";
if (path.startsWith("/career")) return "Career Workspace";
if (path.startsWith("/settings/connected-accounts")) return "Connected accounts";
if (path.startsWith("/admin/audit")) return t("auditLog");
+21 -1
View File
@@ -47,6 +47,20 @@ function initialsFrom(s?: string) {
const DESKTOP_SIDEBAR_KEY = "appShellDesktopSidebarCollapsed";
// Which single nav item owns the current path. A child route must not light up its parent:
// /career/builder/5 belongs to "CV Builder" (/career/builder), not "Career Workspace" (/career),
// even though the old `pathname.startsWith(to + "/")` test matched both. Explicit ownership =
// the LONGEST `to` that the path is at or under wins; everything else is inactive. Exported so the
// rule is unit-testable without rendering the shell.
export function activeNavTo(pathname: string, tos: string[]): string | null {
let best: string | null = null;
for (const to of tos) {
const owns = pathname === to || pathname.startsWith(to + "/");
if (owns && (best === null || to.length > best.length)) best = to;
}
return best;
}
// The nav rail stays a fixed dark navy regardless of the app's light/dark theme toggle --
// a deliberate signature element, not derived from theme tokens.
const SIDEBAR_BG = "#0f172a";
@@ -129,6 +143,12 @@ export default function AppShell({
};
}, [nav, navBottom]);
// Compute the one active destination across BOTH nav lists, so the most specific route wins.
const activeTo = useMemo(
() => activeNavTo(pathname, [...nav, ...navBottom].map((i) => i.to)),
[pathname, nav, navBottom],
);
const renderNavList = (groups: Array<[string, NavItem[]]>) => (
<Box sx={{ px: desktopNavCollapsed ? 0.75 : 1.25, pt: 1 }}>
{groups.map(([section, rows]) => (
@@ -140,7 +160,7 @@ export default function AppShell({
) : null}
<List sx={{ px: desktopNavCollapsed ? 0.25 : 0.75, pt: desktopNavCollapsed ? 0.25 : 0.75 }}>
{rows.map((item) => {
const selected = pathname === item.to || pathname.startsWith(item.to + "/");
const selected = item.to === activeTo;
return (
<ListItemButton
key={item.to}
@@ -0,0 +1,43 @@
import { activeNavTo } from "./layout/AppShell";
// Route ownership under test (the real sidebar entries):
// /career -> Career Workspace
// /career/builder -> CV Builder
// A child route must activate exactly one nav item — the most specific owner.
const TOS = ["/dashboard", "/jobs", "/career", "/career/builder", "/settings"];
describe("sidebar active nav ownership", () => {
test("/career activates Career Workspace only", () => {
expect(activeNavTo("/career", TOS)).toBe("/career");
});
test("/career/builder activates CV Builder only, not Career Workspace", () => {
expect(activeNavTo("/career/builder", TOS)).toBe("/career/builder");
});
test("/career/builder/{id} activates CV Builder only (the reported bug)", () => {
// Previously /career matched via startsWith('/career/') AND /career/builder matched — both lit up.
expect(activeNavTo("/career/builder/42", TOS)).toBe("/career/builder");
});
test("a plain child of /career (not /builder) still belongs to Career Workspace", () => {
expect(activeNavTo("/career/anything-else", TOS)).toBe("/career");
});
test("unrelated routes are unaffected and exact matches win", () => {
expect(activeNavTo("/jobs", TOS)).toBe("/jobs");
expect(activeNavTo("/settings/connected-accounts", TOS)).toBe("/settings");
});
test("a path owned by no nav item activates nothing", () => {
expect(activeNavTo("/admin/system", TOS)).toBeNull();
});
test("exactly one item is ever active (no double highlight)", () => {
for (const path of ["/career", "/career/builder", "/career/builder/7", "/jobs"]) {
const active = activeNavTo(path, TOS);
const matches = TOS.filter((t) => t === active);
expect(matches).toHaveLength(1);
}
});
});
@@ -15,15 +15,15 @@ export default function CareerWorkspacePage() {
<Box sx={{ display: "grid", gap: 2 }}>
<Paper sx={{ p: 2.5, borderRadius: 4, boxShadow: "0px 8px 24px -12px rgba(15,23,42,0.12)", display: "flex", alignItems: "center", justifyContent: "space-between", flexWrap: "wrap", gap: 1 }}>
<Box>
<Typography variant="h5" sx={{ fontWeight: 900, mb: 0.5 }}>Career Workspace</Typography>
<Typography variant="h5" sx={{ fontWeight: 900, mb: 0.5 }}>Career Profile</Typography>
<Typography sx={{ color: "text.secondary" }}>
Maintain the master career profile that powers your CVs, tailored application material, and future portfolio outputs.
This information powers your CVs, applications, cover letters and AI assistance.
</Typography>
</Box>
<Button variant="contained" startIcon={<DescriptionOutlinedIcon />} onClick={() => navigate("/career/builder")}>Open CV Builder</Button>
</Paper>
<Alert severity="info" sx={{ borderRadius: 3 }}>
Your master profile is the source of truth. Job-specific CV drafts remain separate and never overwrite it.
Your career profile holds your information. The CV Builder creates documents from it job-specific CVs stay separate and never overwrite your profile.
</Alert>
<Paper sx={{ borderRadius: 4, p: { xs: 1.5, md: 2.5 }, boxShadow: "0px 8px 24px -12px rgba(15,23,42,0.12)" }}>
<CareerProfilePage />