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
+111
View File
@@ -0,0 +1,111 @@
# Career Workspace + CV Builder UX refactor
> 2026-07-20. UX/product restructuring against v1.0.0. **Frontend-only** — no change to
> CareerProfiles, CvVariants, the CV generation pipeline, extraction APIs, AI services, permissions,
> or tenant isolation.
>
> This document is the plan. It is delivered **staged**: Phase 0 (the sidebar bug and the framing
> copy) is implemented and tested now; Phases 14 are scoped for incremental delivery because they
> require surgery on the 1376-line `CareerProfilePage` and touch the production CV/extraction flow,
> where a single large rewrite would risk the "do not break" list. Each later phase is independently
> shippable and verifiable.
## Current problems
1. **Sidebar double-highlight.** On `/career/builder/{id}`, both "Career Workspace" and "CV Builder"
light up. Root cause: `AppShell` used `pathname === to || pathname.startsWith(to + "/")` per item,
so `/career` matched every `/career/...` child. No "most specific wins" rule. *(Fixed — Phase 0.)*
2. **Too many competing concepts on one page.** `CareerWorkspacePage` is a thin shell around
`CareerProfilePage` (1376 lines), which bundles: a structured-CV editor, extraction-run history, a
"CV structure overview", rewrite **templates** with a PDF carousel (a second, template-driven CV
builder), and field-level review metadata. A user cannot tell which artifact is "their CV".
3. **Internal vocabulary leaks to users** — "structured CV", "JSON structure", "extraction schema",
"structure overview". These are implementation concepts.
4. **Extraction is opaque.** Upload runs, the profile changes, but the user never sees *what* changed
and cannot approve it. History is shown as a primary section instead of the *result* of an import.
5. **Two CV builders.** The template-driven rewrite/PDF flow inside the profile page overlaps the real
CV Builder (`/career/builder`), which already owns templates, layout, styling, variants and PDF.
## New information architecture
**Product rule:** *Career Profile* holds your information; *CV Builder* creates documents from it.
| Surface | Owns | Does NOT own |
|---|---|---|
| **Career Profile** (`/career`) | Personal info, professional summary, work experience, education, skills, projects, certifications, languages. The facts. | Templates, layout, styling, PDF, variants |
| **CV Builder** (`/career/builder`, `/career/builder/{id}`) | Templates, layout, styling, section order/visibility, variants, PDF generation | Career facts (it *reads* the profile) |
Career Profile page structure (target):
- **Header** — "Career Profile", subtitle *"This information powers your CVs, applications, cover
letters and AI assistance."*, profile completeness %, last updated, quick action → CV Builder.
- **Sections** (user-facing labels only): Personal information · Professional summary · Work
experience · Education · Skills · Projects · Certifications · Languages.
- **Import CV** — current source (filename, date, status) + `[Upload new CV]`; after extraction a
review screen (below). History moves to Settings → Advanced → Import history.
## Removed / relocated concepts
| Concept | Disposition |
|---|---|
| "Structured CV Editor" | Renamed and reframed to **Career Profile editor** (same fields, user vocabulary) |
| "CV Structure Overview" | Removed from the user surface; if needed for debugging, move under admin/developer tools |
| "Template-driven CV Builder" (rewrite templates + PDF carousel inside the profile page) | **Removed** — the CV Builder already provides templates, layouts, styling, sections, customization and PDF. One CV Builder only |
| Extraction run history as a primary section | **Relocated** to Settings → Advanced → Import history |
| Internal terms ("structured CV", "JSON", "schema") in labels/help text | Replaced with plain language |
## Route ownership (authoritative)
```
/career → Career Profile (Career Workspace nav item)
/career/builder → CV Builder (CV Builder nav item)
/career/builder/{id} → CV Builder (child of CV Builder, NOT Career Profile)
```
Rule: the sidebar item whose `to` is the **longest prefix** the current path is at or under wins;
all others are inactive. A child route never activates a parent nav item.
## Implementation plan
### Phase 0 — Sidebar bug + framing (DONE, this change)
- `AppShell.activeNavTo(pathname, tos)` — exported pure function; longest-owning `to` wins. `selected`
now compares against the single computed `activeTo` across both nav lists.
- Breadcrumb/title in `App.tsx`: explicit `/career/builder` → "CV Builder" ownership before the
`/career` fallback (previously `/career/builder` showed "Career Workspace").
- Career Workspace header reframed to the "Career Profile" product framing.
- Tests: `sidebar-active-nav.test.ts` — asserts exactly one active item for `/career`,
`/career/builder`, `/career/builder/{id}`, and that no item double-highlights.
### Phase 1 — Career Profile editor sections (frontend)
Reorganise `CareerProfilePage` presentation into the eight named sections with user-facing labels;
strip internal vocabulary from headings/help. No data-model or API change — the same
`StructuredCvProfile` shape is read and saved. Ship behind the existing page; verify save/load of each
section against the existing profile API.
### Phase 2 — Import CV review screen
Add a post-upload "New information found" review (Experience / Skills / Languages / Education) with
`[Accept all] · [Review individually] · [Discard]`, diffing the extracted profile against the current
one **client-side** (no backend change — the extraction API already returns the structured result and
field metadata). Nothing is written until the user accepts. Relocate run history to Settings →
Advanced → Import history.
### Phase 3 — Remove the second CV builder
Delete the rewrite-template + PDF-carousel flow from `CareerProfilePage`. Confirm the CV Builder
(`/career/builder`) covers templates/layout/styling/variants/PDF first (it does). Verify public CV and
PDF generation still work end to end.
### Phase 4 — WYSIWYG for long-form fields
Rich editor (bold, lists, links, undo/redo) for professional summary, work descriptions, achievements,
projects, cover letters. **Store clean HTML or Markdown only** — no editor-specific state — and the
renderer consumes the same format. Choose a small dependency already compatible with the stack, or a
minimal contentEditable wrapper; decide at Phase 4 to avoid a premature dependency.
## Preserved (verified not touched)
CareerProfiles, CvVariants, CV generation, public CV pages, extraction APIs, AI services, permissions,
tenant isolation. Phases 14 are frontend-only; any that appears to need a backend change is a signal
to re-scope, not to change the model.
## Why staged
The removals and the review/WYSIWYG surfaces all require editing the 1376-line `CareerProfilePage` and
the live extraction/generation path. Delivering them as one change would put the v1.0.0 CV pipeline at
risk with no incremental verification. Each phase above is small enough to ship and verify on its own.
+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("/trash")) return [t("home"), t("trash")];
if (path.startsWith("/settings")) return [t("home"), t("settings")]; if (path.startsWith("/settings")) return [t("home"), t("settings")];
if (path.startsWith("/profile")) return [t("home"), t("account"), t("profile")]; 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("/career")) return [t("home"), "Career Workspace"];
if (path.startsWith("/settings/connected-accounts")) return [t("home"), t("settings"), "Connected accounts"]; 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")]; 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("/trash")) return t("trash");
if (path.startsWith("/settings")) return t("settings"); if (path.startsWith("/settings")) return t("settings");
if (path.startsWith("/profile")) return t("profile"); 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("/career")) return "Career Workspace";
if (path.startsWith("/settings/connected-accounts")) return "Connected accounts"; if (path.startsWith("/settings/connected-accounts")) return "Connected accounts";
if (path.startsWith("/admin/audit")) return t("auditLog"); 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"; 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 -- // 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. // a deliberate signature element, not derived from theme tokens.
const SIDEBAR_BG = "#0f172a"; const SIDEBAR_BG = "#0f172a";
@@ -129,6 +143,12 @@ export default function AppShell({
}; };
}, [nav, navBottom]); }, [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[]]>) => ( const renderNavList = (groups: Array<[string, NavItem[]]>) => (
<Box sx={{ px: desktopNavCollapsed ? 0.75 : 1.25, pt: 1 }}> <Box sx={{ px: desktopNavCollapsed ? 0.75 : 1.25, pt: 1 }}>
{groups.map(([section, rows]) => ( {groups.map(([section, rows]) => (
@@ -140,7 +160,7 @@ export default function AppShell({
) : null} ) : null}
<List sx={{ px: desktopNavCollapsed ? 0.25 : 0.75, pt: desktopNavCollapsed ? 0.25 : 0.75 }}> <List sx={{ px: desktopNavCollapsed ? 0.25 : 0.75, pt: desktopNavCollapsed ? 0.25 : 0.75 }}>
{rows.map((item) => { {rows.map((item) => {
const selected = pathname === item.to || pathname.startsWith(item.to + "/"); const selected = item.to === activeTo;
return ( return (
<ListItemButton <ListItemButton
key={item.to} 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 }}> <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 }}> <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> <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" }}> <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> </Typography>
</Box> </Box>
<Button variant="contained" startIcon={<DescriptionOutlinedIcon />} onClick={() => navigate("/career/builder")}>Open CV Builder</Button> <Button variant="contained" startIcon={<DescriptionOutlinedIcon />} onClick={() => navigate("/career/builder")}>Open CV Builder</Button>
</Paper> </Paper>
<Alert severity="info" sx={{ borderRadius: 3 }}> <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> </Alert>
<Paper sx={{ borderRadius: 4, p: { xs: 1.5, md: 2.5 }, boxShadow: "0px 8px 24px -12px rgba(15,23,42,0.12)" }}> <Paper sx={{ borderRadius: 4, p: { xs: 1.5, md: 2.5 }, boxShadow: "0px 8px 24px -12px rgba(15,23,42,0.12)" }}>
<CareerProfilePage /> <CareerProfilePage />