Skip to content

Sidebar Nav Cleanup Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Remove the sidebar's duplicate top-of-page navigation bars and make nested sidebar items visually read as children of their parent group.

Architecture: Two independent, presentation-only changes. Task 1 deletes the redundant SubNavTabs pill-row from three layout components (it duplicates sidebar navigation that already exists) and removes the now-dead SubNavTabs component itself. Task 2 adds a CSS-only "connector rail + shrink" treatment to NavGroup.jsx's nested (depth > 0) items, distinguishing them visually from top-level sidebar buttons without changing any click/navigation behavior.

Tech Stack: React 18, Vite, Tailwind CSS 4 (utility classes), custom neo-brutalist CSS in client/src/standalone.css, Vitest + React Testing Library.

Global Constraints

  • Presentation-layer only. No routing, API, or data changes in either task.
  • No change to RadixShell.jsx's nav-tree construction (buildCatalogGroup, accountGroup, toolsGroup) — only how NavGroup renders what it's given.
  • Design doc: docs/superpowers/specs/2026-07-11-sidebar-nav-cleanup-design.md.

Task 1: Remove duplicate top-of-page sub-nav bars

Files:

  • Modify: client/src/pages/catalog/CatalogGameLayout.jsx
  • Modify: client/src/pages/catalog/CatalogGameSettingsLayout.jsx
  • Modify: client/src/pages/settings/SettingsLayout.jsx
  • Modify: client/src/pages/catalog/CatalogGameLayout.test.jsx
  • Modify: client/src/pages/catalog/CatalogGameSettingsLayout.test.jsx
  • Delete: client/src/components/SubNavTabs.jsx
  • Delete: client/src/components/SubNavTabs.test.jsx

Interfaces:

  • Consumes: nothing new — these files already exist from the earlier Navigation & Dashboard Restructure plan.

  • Produces: CatalogGameLayout, CatalogGameSettingsLayout, SettingsLayout no longer accept/use an isEmbedded prop (dropped from their signatures — App.jsx still passes it; React ignores the unconsumed prop, no App.jsx change needed).

  • [ ] Step 1: Update the failing tests first

Remove the sub-nav-button assertions from both catalog layout tests (they duplicate coverage that already exists via RadixShell.test.jsx/NavGroup.test.jsx, and the buttons are being removed from these pages).

Replace client/src/pages/catalog/CatalogGameLayout.test.jsx in full:

jsx
import { describe, it, expect, vi, beforeEach } from 'vitest';
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import { MemoryRouter, Routes, Route } from 'react-router-dom';

vi.mock('../../utils/api', () => ({
  default: { get: vi.fn(() => Promise.resolve({ data: { games: [{ id: 'mtg', name: 'Magic: The Gathering' }] } })) },
}));

import CatalogGameLayout from './CatalogGameLayout';

function renderAt(path) {
  return render(
    <MemoryRouter initialEntries={[path]}>
      <Routes>
        <Route path="/catalog/:game" element={<CatalogGameLayout />}>
          <Route path="singles" element={<div>singles-content</div>} />
        </Route>
        <Route path="/dashboard" element={<div>dashboard-page</div>} />
      </Routes>
    </MemoryRouter>
  );
}

describe('CatalogGameLayout', () => {
  beforeEach(() => vi.clearAllMocks());

  it('renders the game heading and outlet, with no duplicate sub-nav buttons', async () => {
    renderAt('/catalog/mtg/singles');
    await waitFor(() => expect(screen.getByText('singles-content')).toBeInTheDocument());
    expect(screen.getByRole('heading', { name: 'Magic: The Gathering' })).toBeInTheDocument();
    expect(screen.queryByRole('button', { name: 'Singles' })).not.toBeInTheDocument();
    expect(screen.queryByRole('button', { name: 'Sealed' })).not.toBeInTheDocument();
    expect(screen.queryByRole('button', { name: 'Sets' })).not.toBeInTheDocument();
    expect(screen.queryByRole('button', { name: 'Settings' })).not.toBeInTheDocument();
  });

  it('redirects to /dashboard for an unknown game', async () => {
    renderAt('/catalog/lorcana/singles');
    await waitFor(() => expect(screen.getByText('dashboard-page')).toBeInTheDocument());
  });
});

Replace client/src/pages/catalog/CatalogGameSettingsLayout.test.jsx in full:

jsx
import { describe, it, expect } from 'vitest';
import React from 'react';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import { MemoryRouter, Routes, Route } from 'react-router-dom';
import CatalogGameSettingsLayout from './CatalogGameSettingsLayout';

describe('CatalogGameSettingsLayout', () => {
  it('renders the shared-settings note and the outlet, with no duplicate sub-nav buttons', () => {
    render(
      <MemoryRouter initialEntries={['/catalog/mtg/settings/pricing']}>
        <Routes>
          <Route path="/catalog/:game/settings" element={<CatalogGameSettingsLayout />}>
            <Route path="pricing" element={<div>pricing-content</div>} />
          </Route>
        </Routes>
      </MemoryRouter>
    );
    expect(screen.getByText(/these settings apply to all of your enabled catalogs/i)).toBeInTheDocument();
    expect(screen.getByText('pricing-content')).toBeInTheDocument();
    expect(screen.queryByRole('button', { name: 'Pricing' })).not.toBeInTheDocument();
    expect(screen.queryByRole('button', { name: 'Sync Settings' })).not.toBeInTheDocument();
    expect(screen.queryByRole('button', { name: 'Metafields' })).not.toBeInTheDocument();
  });
});
  • [ ] Step 2: Run to verify they fail

Run: npm run test:client -- client/src/pages/catalog/CatalogGameLayout.test.jsx client/src/pages/catalog/CatalogGameSettingsLayout.test.jsx Expected: FAIL — both tests' queryByRole('button', ...) negative assertions fail against the current components, which still render the SubNavTabs buttons (e.g. CatalogGameLayout's test fails on expect(screen.queryByRole('button', { name: 'Singles' })).not.toBeInTheDocument() because that button currently exists).

  • [ ] Step 3: Update the three layout components

Replace client/src/pages/catalog/CatalogGameLayout.jsx in full:

jsx
import { useEffect, useState } from 'react';
import { Navigate, Outlet, useParams } from 'react-router-dom';
import api from '../../utils/api';
import { Text } from '@/components/retroui';

export default function CatalogGameLayout() {
  const { game } = useParams();
  const [games, setGames] = useState(null);

  useEffect(() => {
    let cancelled = false;
    api.get('/games').then(({ data }) => {
      if (!cancelled) setGames(data.games || []);
    }).catch(() => {
      if (!cancelled) setGames([]);
    });
    return () => { cancelled = true; };
  }, []);

  if (games === null) {
    return <Text as="p" className="text-sm text-muted-foreground">Loading...</Text>;
  }

  const gameInfo = games.find((g) => g.id === game);
  if (!gameInfo) {
    return <Navigate to="/dashboard" replace />;
  }

  return (
    <div className="space-y-4 sm:space-y-6">
      <div className="space-y-1 max-w-5xl mx-auto">
        <Text as="h1" className="text-2xl sm:text-3xl font-bold">{gameInfo.name}</Text>
      </div>
      <Outlet />
    </div>
  );
}

Replace client/src/pages/catalog/CatalogGameSettingsLayout.jsx in full:

jsx
import { Outlet } from 'react-router-dom';
import { Text } from '@/components/retroui';

export default function CatalogGameSettingsLayout() {
  return (
    <div className="space-y-4 sm:space-y-6">
      <div className="max-w-5xl mx-auto">
        <Text as="p" className="text-xs text-muted-foreground">
          These settings apply to all of your enabled catalogs, not just this one.
        </Text>
      </div>
      <Outlet />
    </div>
  );
}

Replace client/src/pages/settings/SettingsLayout.jsx in full:

jsx
import { Outlet } from 'react-router-dom';

export default function SettingsLayout() {
  return (
    <div className="space-y-4 sm:space-y-6">
      <div className="space-y-1 max-w-5xl mx-auto">
        <h1 className="text-2xl sm:text-3xl font-bold">Store Settings</h1>
      </div>
      <Outlet />
    </div>
  );
}
  • [ ] Step 4: Run to verify the tests pass

Run: npm run test:client -- client/src/pages/catalog/CatalogGameLayout.test.jsx client/src/pages/catalog/CatalogGameSettingsLayout.test.jsx Expected: PASS (2 test files, 3 tests total)

  • [ ] Step 5: Delete the now-dead SubNavTabs component and its test
bash
git rm client/src/components/SubNavTabs.jsx client/src/components/SubNavTabs.test.jsx
  • [ ] Step 6: Confirm nothing else references SubNavTabs

Run: grep -rn "SubNavTabs" client/src --include=*.jsx --include=*.js Expected: no output.

  • [ ] Step 7: Run the full client suite

Run: npm run test:client Expected: PASS — no failures elsewhere (in particular, confirm no other test imported SubNavTabs indirectly).

  • [ ] Step 8: Lint

Run: npx eslint client/src/pages/catalog/CatalogGameLayout.jsx client/src/pages/catalog/CatalogGameSettingsLayout.jsx client/src/pages/settings/SettingsLayout.jsx client/src/pages/catalog/CatalogGameLayout.test.jsx client/src/pages/catalog/CatalogGameSettingsLayout.test.jsx Expected: no errors.

  • [ ] Step 9: Commit
bash
git add client/src/pages/catalog/CatalogGameLayout.jsx client/src/pages/catalog/CatalogGameSettingsLayout.jsx client/src/pages/settings/SettingsLayout.jsx client/src/pages/catalog/CatalogGameLayout.test.jsx client/src/pages/catalog/CatalogGameSettingsLayout.test.jsx
git commit -m "fix(client): remove duplicate top-of-page sub-nav (sidebar is now the sole nav for these sections)"

Task 2: Sidebar hierarchy — connector rail + shrink treatment for nested items

Files:

  • Modify: client/src/components/NavGroup.jsx
  • Modify: client/src/standalone.css
  • Modify: client/src/components/NavGroup.test.jsx

Interfaces:

  • Consumes: nothing new.

  • Produces: NavGroup applies a nav-link-nested CSS class (plus text-xs instead of text-sm) to every button it renders at depth > 0. Depth-0 buttons are unaffected — same classes as before.

  • [ ] Step 1: Write the failing test

Add a new test to the end of the describe('NavGroup', ...) block in client/src/components/NavGroup.test.jsx (keep all 4 existing tests unchanged, just append this one before the closing });):

jsx
  it('applies the nested-item treatment only to items below depth 0', () => {
    render(
      <>
        <NavGroup
          item={{ key: 'dashboard', label: 'Dashboard', path: '/dashboard' }}
          depth={0}
          isActive={() => false}
          expandedKeys={new Set()}
          onToggle={() => {}}
          onNavigate={() => {}}
        />
        <NavGroup
          item={{ key: 'general', label: 'General', path: '/settings/general' }}
          depth={1}
          isActive={() => false}
          expandedKeys={new Set()}
          onToggle={() => {}}
          onNavigate={() => {}}
        />
      </>
    );
    expect(screen.getByRole('button', { name: 'Dashboard' })).not.toHaveClass('nav-link-nested');
    expect(screen.getByRole('button', { name: 'General' })).toHaveClass('nav-link-nested');
  });
  • [ ] Step 2: Run to verify it fails

Run: npm run test:client -- client/src/components/NavGroup.test.jsx Expected: FAIL — nav-link-nested class doesn't exist yet, so toHaveClass('nav-link-nested') on the "General" button fails.

  • [ ] Step 3: Update NavGroup.jsx

Replace client/src/components/NavGroup.jsx in full:

jsx
import { ChevronDownIcon, ChevronRightIcon } from '@radix-ui/react-icons';
import { Badge } from '@/components/retroui';

// Recursive sidebar nav row. A node with `children` renders as a
// collapsible group; a node with `path` renders as a leaf link.
// Used for the 2-3 level Store Settings / per-game Catalog tree in RadixShell.
//
// Items below depth 0 get the `nav-link-nested` treatment (thinner border,
// smaller shadow/padding, a connector rail via ::before — see
// standalone.css) plus text-xs instead of text-sm, so nested items read
// visually as children of their parent group rather than as peers of the
// top-level sidebar buttons.
export default function NavGroup({ item, depth, isActive, expandedKeys, onToggle, onNavigate }) {
  const hasChildren = Array.isArray(item.children) && item.children.length > 0;
  const paddingClass = depth === 0 ? '' : depth === 1 ? 'pl-4' : 'pl-8';
  const textSizeClass = depth === 0 ? 'text-sm' : 'text-xs';
  const nestedClass = depth === 0 ? '' : 'nav-link-nested';

  if (!hasChildren) {
    const active = isActive(item.path);
    return (
      <button
        onClick={() => onNavigate(item)}
        disabled={item.disabled}
        className={`nav-link ${textSizeClass} ${nestedClass} w-full ${paddingClass} ${item.disabled ? 'nav-link-disabled' : active ? 'nav-link-active' : 'nav-link-default'} ${item.comingSoon ? 'justify-between' : ''}`}
      >
        <span>{item.label}</span>
        {item.comingSoon && <Badge variant="info">Coming Soon</Badge>}
      </button>
    );
  }

  const isExpanded = expandedKeys.has(item.key);

  return (
    <div>
      <button
        onClick={() => onToggle(item.key)}
        className={`nav-link ${textSizeClass} ${nestedClass} w-full ${paddingClass} justify-between nav-link-default`}
        aria-expanded={isExpanded}
      >
        <span>{item.label}</span>
        {isExpanded ? <ChevronDownIcon className="w-3.5 h-3.5" /> : <ChevronRightIcon className="w-3.5 h-3.5" />}
      </button>
      {isExpanded && (
        <div className="space-y-1 mt-1">
          {item.children.map((child) => (
            <NavGroup
              key={child.key}
              item={child}
              depth={depth + 1}
              isActive={isActive}
              expandedKeys={expandedKeys}
              onToggle={onToggle}
              onNavigate={onNavigate}
            />
          ))}
        </div>
      )}
    </div>
  );
}
  • [ ] Step 4: Run to verify the new test passes

Run: npm run test:client -- client/src/components/NavGroup.test.jsx Expected: PASS (5 tests: the 4 existing ones plus the new one).

  • [ ] Step 5: Add the CSS for .nav-link-nested

In client/src/standalone.css, find this existing block (inside the @layer components { ... } that defines .nav-link, .nav-link-default, .nav-link-active, .nav-link-disabled — the disabled rule is the last one in that group, immediately before the /* Card styles - Neo-Brutalist */ comment):

css
  .nav-link-disabled {
    color: var(--muted-foreground);
    cursor: not-allowed;
    opacity: 0.6;
  }

Add this immediately after it (still inside the same @layer components block, still before /* Card styles - Neo-Brutalist */):

css
  .nav-link-nested {
    border-width: 2px;
    box-shadow: 2px 2px 0px #000000;
    padding: 0.4375rem 0.625rem;
    margin-bottom: 6px;
    position: relative;
  }

  .nav-link-nested::before {
    content: '';
    position: absolute;
    left: -11px;
    top: 0;
    bottom: 0;
    width: 2px;
    background: #000000;
    opacity: 0.25;
  }

(No new :hover rule needed — the existing base .nav-link:hover rule already applies box-shadow: 1px 1px 0px #000000;, which matches what a shrunk hover state should look like.)

Then find the dark-mode block near the end of the file (search for .dark .nav-link:hover):

css
.dark .nav-link:hover {
  box-shadow: 1px 1px 0px rgb(255 255 255 / 0.08) !important;
}

Add this immediately after it, so it wins the cascade over the earlier .dark .nav-link, .dark .nav-link-active { box-shadow: 3px 3px 0px rgb(255 255 255 / 0.08) !important; } rule for nested items specifically:

css
.dark .nav-link-nested,
.dark .nav-link-nested.nav-link-active {
  box-shadow: 2px 2px 0px rgb(255 255 255 / 0.08) !important;
}

.dark .nav-link-nested::before {
  background: #ffffff;
}
  • [ ] Step 6: Manually verify in the browser (CSS changes aren't covered by jsdom tests)

Run npm run dev, open the app, expand a sidebar group with nested items (e.g. Store Settings, or a Catalog game group), and confirm:

  • Nested items are visibly smaller/thinner than top-level items, with a faint vertical line connecting them to their parent.

  • Toggle dark mode and confirm the connector line and shadow are still visible (not invisible-black-on-dark).

  • Confirm clicking still navigates correctly (no behavior change, only appearance).

  • [ ] Step 7: Run the full client suite and lint

Run: npm run test:client Expected: PASS.

Run: npx eslint client/src/components/NavGroup.jsx client/src/components/NavGroup.test.jsx Expected: no errors.

  • [ ] Step 8: Commit
bash
git add client/src/components/NavGroup.jsx client/src/components/NavGroup.test.jsx client/src/standalone.css
git commit -m "feat(client): add connector-rail styling to distinguish nested sidebar items from top-level ones"