From 702fea5c902f085d4546ec66107eef75aee711d1 Mon Sep 17 00:00:00 2001 From: Haksung Jang Date: Tue, 28 Jul 2026 15:38:43 +0900 Subject: [PATCH 1/6] feat(shell): move the chrome to a global bar above the sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The complaint that started this: put TRUSCA next to its sister project and they read as the same class of tool. The clearest reason is the silhouette — both were a left rail running to the top of the window with a thin header beside it. Structure, not colour, is what the eye settles on first. So the bar now spans the full width and the sidebar starts beneath it. That shape is also one a single-scan tool cannot borrow: what the bar carries is the organisation, the team you are acting as, and search across everything — and a tool with no accounts has nothing to put there. - Global bar, 56 px, ink. It is a dark surface inside a light theme, so it gets its own `--topbar-*` foreground scale rather than borrowing the page's; without those tokens every call site would reach for a palette class, which the token lint blocks. Ink matches `--primary`, so the bar and the primary CTA read as one material. - Team switcher. `user.teams` was already in the store, so no backend change. Single-team users see a label; multi-team users can switch. Switching moves the active team the store already tracked (project creation follows it) and deliberately filters nothing — a control that quietly narrowed the dashboard would be a trap dressed as a shortcut. - Sidebar grows 224 -> 256 px and gains groups: Portfolio, Operations, Administration. A flat list did not say what kind of place each destination was. - The 48 px per-page header is gone; everything it held moved up. Live count badges on Scans and Approvals are deliberately not here. There is no existing query to read them from, and the cockpit work builds the aggregation this would need — inventing a second one now, to throw away in a few weeks, is not economy. --- apps/frontend/src/components/AppShell.tsx | 518 +++++++++++------- apps/frontend/src/components/CommandMenu.tsx | 21 +- apps/frontend/src/components/HeaderBell.tsx | 12 +- .../src/components/LanguageToggle.tsx | 9 +- apps/frontend/src/index.css | 45 +- apps/frontend/src/locales/en/common.json | 7 +- apps/frontend/src/locales/ko/common.json | 7 +- apps/frontend/src/stores/authStore.ts | 20 + apps/frontend/tailwind.config.ts | 17 + .../screenshots/capture_user_guide.spec.ts | 2 +- apps/frontend/tests/unit/App.test.tsx | 41 +- .../unit/design/statusTokenContrast.test.ts | 38 ++ 12 files changed, 524 insertions(+), 213 deletions(-) diff --git a/apps/frontend/src/components/AppShell.tsx b/apps/frontend/src/components/AppShell.tsx index 645de637..6abeac73 100644 --- a/apps/frontend/src/components/AppShell.tsx +++ b/apps/frontend/src/components/AppShell.tsx @@ -10,6 +10,7 @@ import { Scale, ScanLine, UserCircle2, + ChevronDown, Activity, Building2, ClipboardList, @@ -27,11 +28,19 @@ import { useCommandMenuShortcut, } from "@/components/CommandMenu"; import { BrandMark } from "@/components/BrandMark"; -import { BrandWordmark } from "@/components/BrandWordmark"; import { DemoBanner } from "@/components/DemoBanner"; import { HeaderBell } from "@/components/HeaderBell"; import { LanguageToggle } from "@/components/LanguageToggle"; import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuActiveCheck, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"; import { deriveInitials } from "@/lib/initials"; import { cn } from "@/lib/utils"; @@ -47,48 +56,69 @@ interface NavItem { end?: boolean; } -const MAIN_NAV: NavItem[] = [ - { - // W9-#50 — dedicated Dashboard at "/". Lives above Projects so it's the - // first landing point for authenticated users, matching the commercial - // SCA tool convention of opening on a portfolio overview rather - // than a list view. `end` keeps the active state from spilling onto every - // /projects/* descendant route. - to: "/", - labelKey: "nav.dashboard", - icon: LayoutDashboard, - testId: "nav-dashboard", - end: true, - }, - { - to: "/projects", - labelKey: "nav.projects", - icon: FolderOpen, - testId: "nav-projects", - }, - { - to: "/scans", - labelKey: "nav.scans", - icon: ScanLine, - testId: "nav-scans", - }, - { - to: "/approvals", - labelKey: "nav.approvals", - icon: ClipboardCheck, - testId: "nav-approvals", - }, +/** + * Grouped navigation (W14). + * + * The flat list did not say what kind of place each destination was. Under + * headings, the sidebar states the product's shape on sight: a portfolio you + * survey, operations you run, and administration. That reading is the thing + * a single-scan tool cannot borrow — its left rail is the table of contents + * for one result. + */ +interface NavGroup { + labelKey: string; + items: NavItem[]; +} + +const MAIN_NAV: NavGroup[] = [ { - to: "/policies", - labelKey: "nav.policies", - icon: Scale, - testId: "nav-policies", + labelKey: "nav.group.portfolio", + items: [ + { + // W9-#50 — dedicated Dashboard at "/". `end` keeps the active state + // from spilling onto every /projects/* descendant route. + to: "/", + labelKey: "nav.dashboard", + icon: LayoutDashboard, + testId: "nav-dashboard", + end: true, + }, + { + to: "/projects", + labelKey: "nav.projects", + icon: FolderOpen, + testId: "nav-projects", + }, + ], }, { - to: "/integrations", - labelKey: "nav.integrations", - icon: KeyRound, - testId: "nav-integrations", + labelKey: "nav.group.operations", + items: [ + { + to: "/scans", + labelKey: "nav.scans", + icon: ScanLine, + testId: "nav-scans", + }, + { + to: "/approvals", + labelKey: "nav.approvals", + icon: ClipboardCheck, + testId: "nav-approvals", + }, + { + to: "/policies", + labelKey: "nav.policies", + icon: Scale, + testId: "nav-policies", + }, + { + to: "/integrations", + labelKey: "nav.integrations", + icon: KeyRound, + testId: "nav-integrations", + }, + ], }, ]; @@ -164,16 +194,32 @@ function NavItemLink({ // W11-F polish — sidebar nav hover/active transitions land on the // W11-A 150 ms ease-out-soft tokens for parity with every other // hoverable affordance (buttons, dropdown items, tabs). - "flex items-center rounded-md py-2 text-sm font-medium transition-colors duration-fast ease-out-soft", + "relative flex items-center rounded-md py-2 text-sm font-medium transition-colors duration-fast ease-out-soft", collapsed ? "justify-center px-2" : "gap-2 px-3", "hover:bg-accent hover:text-accent-foreground", "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", - isActive ? "bg-primary/10 text-primary" : "text-foreground", + // W14 — the active row is the brand's one job in the sidebar: a + // teal indicator bar plus a tinted ground. The label stays ink + // rather than teal, so legibility never depends on the accent + // and colour is not the only thing marking the row. + isActive + ? "bg-brand-subtle text-foreground before:absolute before:inset-y-1 before:left-0 before:w-0.5 before:rounded-full before:bg-brand" + : "text-foreground", ) } > - - {collapsed ? null : {label}} + {({ isActive }) => ( + <> + + {collapsed ? null : {label}} + + )} ); @@ -200,38 +246,33 @@ function SidebarNav({ const { t } = useTranslation(); return ( <> -
- {collapsed ? ( - <> - - {t("app.name")} - - ) : ( - - - - - )} -
- -