Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion apps/desktop-ui/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { ThemeProvider } from "@/components/theme-provider"
import { cn } from "@/lib/utils"
import { GeistSans } from 'geist/font/sans'
import { GeistMono } from 'geist/font/mono'
import { Courier_Prime } from 'next/font/google'

import { NextIntlClientProvider } from 'next-intl';
import { getLocale, getMessages } from 'next-intl/server';
Expand All @@ -15,6 +16,13 @@ import "./globals.css";

import { siteMetadata } from "@/lib/metadata"

// Brand wordmark only — 700 is the heaviest weight Courier Prime ships.
const courierPrime = Courier_Prime({
subsets: ['latin'],
weight: ['400', '700'],
variable: '--font-courier-prime',
})

export const metadata: Metadata = {
metadataBase: new URL(siteMetadata.url),
title: {
Expand Down Expand Up @@ -102,7 +110,8 @@ export default async function RootLayout({
return (
<html lang={htmlLang} dir={dir} suppressHydrationWarning className={cn(
GeistSans.variable,
GeistMono.variable
GeistMono.variable,
courierPrime.variable
)}>
<head>
{/* Firebase Auth & token refresh */}
Expand Down
29 changes: 9 additions & 20 deletions apps/desktop-ui/src/components/shell/top-bar.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
'use client'

import { useEffect, useState } from 'react'
import Image from 'next/image'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { Settings, LogOut, User as UserIcon, HelpCircle, Moon, Grid2x2Plus } from 'lucide-react'
import { Logo } from '@/components/logo'
import { ModeToggle } from '@/components/modeToggle'
import { TooltipProvider } from '@/components/ui/tooltip'
import { TopNavStrip, NavIcon } from '@/components/shell/top-nav-strip'
Expand Down Expand Up @@ -77,30 +75,21 @@ export function TopBar() {
data-tauri-drag-region
className={cn(
'flex h-14 w-full shrink-0 items-center gap-3 border-b border-border bg-[hsl(var(--surface-2))]',
!isTauri ? 'px-4' : isFullscreen ? 'pl-4 pr-6' : 'pl-[74px] pr-6',
!isTauri ? 'px-4' : 'pr-6',
)}
style={isTauri ? { paddingLeft: isFullscreen ? 16 : 92 } : undefined}
>
{/* Brand → dashboard. Icon (logo mark) + a larger wordmark, sized
independently so the wordmark can grow without the mark. */}
{/* Brand → dashboard. Text wordmark in Courier Prime, no logo mark. */}
<button
onClick={() => router.push('/dashboard')}
className="flex shrink-0 items-center gap-2.5 rounded-md px-1.5 py-1.5 transition-colors hover:bg-foreground/[0.06]"
className="flex shrink-0 items-center rounded-md px-1.5 py-1.5 transition-colors hover:bg-foreground/[0.06]"
aria-label="Go to dashboard"
>
<Logo size={28} showText={false} />
<span className="relative hidden h-8 w-40 sm:block">
<Image
src="/logo-text-light.png"
alt="MyDevTools"
fill
className="object-contain object-left dark:hidden"
/>
<Image
src="/logo-text-dark.png"
alt="MyDevTools"
fill
className="hidden object-contain object-left dark:block"
/>
<span
className="text-[15px] font-bold leading-none tracking-wide"
style={{ fontFamily: 'var(--font-courier-prime), ui-monospace, monospace' }}
>
mydevtools<span className="text-primary">.tech</span>
</span>
</button>

Expand Down
17 changes: 2 additions & 15 deletions apps/desktop-ui/src/components/tools/tool-header.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
'use client';

import { CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { getRouteConfig } from '@/lib/route-config';
import { normalizePinnedToolPath } from '@/lib/pinned-tools-path';
import { toolCategoryMap } from '@/lib/tool-categories';
import { categoryAccent } from '@/components/dashboard/types';
import { cn } from '@/lib/utils';

// Pin/favorite toggle removed from tool pages — pinning is managed from the
Expand All @@ -24,30 +20,21 @@ interface ToolHeaderProps {
className?: string;
}

export function ToolHeader({ title, description, toolId, className }: ToolHeaderProps) {
export function ToolHeader({ title, description, className }: ToolHeaderProps) {
const hasHeading = Boolean(title?.trim()) || Boolean(description?.trim());

// Without a heading the header existed only to hold the pin button — now gone.
if (!hasHeading) return null;

// Compact single-row header — the tool's identity already lives in the tab
// and sidebar, so the in-page header stays out of the content's way.
const Icon = getRouteConfig(normalizePinnedToolPath(toolId))?.icon;
const slug = toolId.split('/').filter(Boolean).pop() ?? toolId;
const accent = categoryAccent(toolCategoryMap[slug] ?? '');

// and sidebar, so the in-page header stays out of the content's way (no icon).
return (
<CardHeader
className={cn(
'flex flex-row items-center gap-3 space-y-0 border-b border-border/40 px-4 py-3 sm:px-5',
className
)}
>
{Icon ? (
<span className={cn('flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ring-1 ring-inset ring-border/50', accent.bg, accent.text)}>
<Icon className="h-4 w-4" strokeWidth={2} />
</span>
) : null}
<div className="flex min-w-0 flex-1 flex-col">
{title?.trim() ? (
<CardTitle className="truncate text-sm font-semibold tracking-tight">{title}</CardTitle>
Expand Down
24 changes: 7 additions & 17 deletions apps/desktop-ui/src/components/tools/tool-page-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import * as React from 'react'
import { cn } from '@/lib/utils'

interface ToolPageHeaderProps {
icon: React.ElementType
/** Accepted for call-site compatibility; the header no longer renders an icon. */
icon?: React.ElementType
title: string
description: React.ReactNode
accent?: { bg: string; text: string }
Expand All @@ -14,30 +15,19 @@ interface ToolPageHeaderProps {
}

/**
* Page-level header for a tool page: accented icon chip + title + description,
* shown at every breakpoint. Mirrors DashboardSectionHeader's icon-chip
* hierarchy so a tool page reads as part of the same product as the dashboard.
* Page-level header for a tool page: title + description, shown at every
* breakpoint. `icon`/`accent` are still accepted so call sites don't churn,
* but nothing is rendered for them.
*/
export function ToolPageHeader({
icon: Icon,
icon: _icon,
title,
description,
// `accent` kept for API compatibility but no longer used — the header icon
// follows the user-selected accent (--primary) on every tool, not the fixed
// per-category color. Section headers still use CATEGORY_ACCENT.
accent: _accent,
className,
}: ToolPageHeaderProps) {
return (
<div className={cn('flex items-start gap-3', className)}>
<span
className={cn(
'mt-0.5 flex h-11 w-11 shrink-0 items-center justify-center rounded-xl shadow-sm ring-1 ring-inset ring-border/60',
'bg-primary/10 text-primary',
)}
>
<Icon className="h-[22px] w-[22px]" aria-hidden />
</span>
<div className={cn('flex items-start', className)}>
<div className="flex min-w-0 flex-col gap-0.5">
<h1
title={title}
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
"center": true,
"titleBarStyle": "Overlay",
"hiddenTitle": true,
"trafficLightPosition": { "x": 14, "y": 21 }
"trafficLightPosition": { "x": 14, "y": 27 }
}
],
"security": {
Expand Down
192 changes: 192 additions & 0 deletions docs/superpowers/specs/2026-08-05-left-rail-shell-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
# Left rail shell — design

Date: 2026-08-05
Scope: `apps/desktop-ui`
Status: approved, ready for implementation plan

## Goal

Replace the top bar with a left navigation rail. The rail carries the brand, Dashboard,
a curated **Apps** section, Pinned tools, open tool tabs, and the profile menu. A thin
drag strip remains at the very top of the window solely to host the macOS traffic lights
and the ⌘K button.

## Current state

- `components/sidebar/client-layout.tsx` renders `<TopBar/>` then a single `<main>`. No
left panel is rendered at all.
- `components/shell/top-bar.tsx` (187 lines) — brand, `<TopNavStrip/>`, ⌘K `NavIcon`,
avatar dropdown (Profile / Settings / Help / Theme / Sign out), macOS traffic-light
inset + fullscreen detection.
- `components/shell/top-nav-strip.tsx` (427 lines) — Dashboard icon, hover-open Pinned
dropdown, horizontally scrolling open-tab chips with edge fades, ⌥1–9 / ⌥W keyboard
handling. Exports `NavIcon`, consumed by `top-bar.tsx`.
- `components/sidebar/app-sidebar.tsx` (87 lines) — orphaned. Uses the shadcn `Sidebar`
with `collapsible="icon" variant="floating"`, a Dashboard row and a Pinned `NavGroup`.
Nothing imports it.
- `components/sidebar/nav-user.tsx` (227 lines) — orphaned profile card.
- `SidebarProvider` is already mounted in `client-layout.tsx`, so the shadcn sidebar
context, ⌘B toggle and persisted collapse state exist and are unused.

## Target layout

```
┌──────────┬────────────────────────────┐
│ ●●● │ (drag) ⌘K │ h-9 TopStrip, full width
├──────────┼────────────────────────────┤
│ mydevtools.tech │
│ ⌂ Dashboard │
│ │
│ APPS ⚙ │
│ ⚡ API Client • │
│ ▤ Data Explorer │
│ ✎ Notes • × │
│ │
│ PINNED │
│ { } JSON Formatter │
│ │
│ OPEN │
│ ⌗ Regex Tester × │
│──────────│ │
│ (A) akhil│ tool content │
└──────────┴────────────────────────────┘
```

Rail: 240px expanded, 48px icon-only collapsed (tooltips on hover), ⌘B toggle, collapse
state persisted by the existing shadcn sidebar cookie. Below the `md` breakpoint the rail
becomes the shadcn offcanvas Sheet; the existing `MobileNav` bottom bar is untouched.

## Components

### `components/shell/top-strip.tsx` (new)

Replaces `top-bar.tsx`. `h-9`, `data-tauri-drag-region`, `border-b`,
`bg-[hsl(var(--surface-2))]`.

Carried over verbatim from `top-bar.tsx`:

- the `isTauri` / `isFullscreen` mounted-guarded state and the resulting left padding
(`px-4` on web, `pl-[74px]` in a non-fullscreen Tauri window, `pl-4` fullscreen);
- the `NavIcon` component itself, moved here from `top-nav-strip.tsx` (it is the only
surviving consumer);
- the ⌘K button dispatching `new CustomEvent('open-command-palette')`.

Everything else in `top-bar.tsx` moves to the rail (brand, account dropdown) or is
deleted (`TopNavStrip`).

### `hooks/use-tab-shortcuts.ts` (new)

The ⌥1–9 / ⌥W `keydown` effect lifted out of `TopNavStrip`, unchanged in behaviour,
including the `e.code`-based (layout-independent) key matching and the input/textarea/
contenteditable guard. Also exports the `closeTabAndNavigate(path)` callback that both
the shortcut handler and the rail's row `×` buttons use, so close-and-pick-next-tab logic
lives in exactly one place. Called once from `AppSidebar`.

### `components/sidebar/app-sidebar.tsx` (rewrite)

Keeps `Sidebar collapsible="icon" variant="floating"`. Sections top to bottom:

1. **Brand row** — `mydevtools.tech` wordmark in Courier Prime, navigates to
`/dashboard`. Hidden when collapsed (the icon rail shows nothing in its place).
2. **Dashboard row** — the existing row with the framer-motion `layoutId="sidebar-active-pill"`
active indicator.
3. **APPS** — `NavGroup` rows in user order. `NavGroup` gains one optional prop,
`onReorder?: (paths: string[]) => void`; when supplied it wraps its rows in a
framer-motion `Reorder.Group` / `Reorder.Item` pair, and when omitted it renders exactly
as today (so Pinned and Open are unaffected). A `⚙` button in the section header opens a
`DropdownMenu` checklist of every default app to hide/show. Drag and the `⚙` menu are
disabled while the rail is collapsed.
4. **PINNED** — the existing `NavGroup` fed by `buildPinnedNavItems`, unchanged. The
empty-state block ("No pinned tools yet") is retained.
5. **OPEN** — a `NavGroup` listing open tabs whose path is in neither the visible Apps
list nor the Pinned list. Section is not rendered when empty.
6. **Footer** — `NavUser`.

Row labels and icons resolve through the existing `getSidebarToolMeta` and
`getToolMessageKey` helpers, matching how the top strip resolves them today.

### `components/sidebar/nav-group.tsx` (edit)

The open-tab affordance is added once, inside the shared row renderer, so Apps, Pinned and
Open all inherit it:

- subscribe to `useTabStore` and mark a row open when its path matches an open tab;
- open + not active → a 4px dot at the row's right edge;
- hovered (or active) and open → the dot is replaced by a `×` calling
`closeTabAndNavigate`;
- collapsed rail → dot only, no `×` (no room, and the tooltip already carries the label).

A closed tool row behaves exactly as today.

### `components/sidebar/nav-user.tsx` (edit)

Currently an orphaned profile card. It absorbs the menu that dies with `top-bar.tsx`:
Profile, Settings, Help, the `ModeToggle` theme row, and Sign out (destructive styling),
plus the logged-out variant showing only Theme and Sign in. Data comes from the existing
`useAppUser` / `useSignOut` hooks. Displays avatar + name expanded, avatar only collapsed.

### `components/sidebar/client-layout.tsx` (edit)

`<TopBar/>` → `<TopStrip/>`, and `<AppSidebar/>` is rendered as the first child of the
existing `<div className="flex min-h-0 w-full flex-1">` row, before `<main>`. The existing
`state === 'collapsed'` padding branch on `<main>` stays.

### Deleted

`components/shell/top-bar.tsx`, `components/shell/top-nav-strip.tsx`.

## State

New `store/app-rail-store.ts`, shaped after `store/pinned-tools-store.ts`:

```ts
interface AppRailStore {
orderByWorkspace: Record<string, string[]> // app paths, user order
hiddenByWorkspace: Record<string, string[]> // app paths the user hid
setOrder: (workspaceId: string, paths: string[]) => void
toggleHidden: (workspaceId: string, path: string) => void
}
```

zustand `persist`, key `app-rail-storage`, version 1, localStorage only. Deliberately **not**
synced to the backend: pinned tools are a server-owned preference, but rail order is local
window chrome.

A selector resolves the visible list: start from `DEFAULT_APPS`, order by the stored array,
append any default app missing from it (so apps shipped in a later release show up at the
bottom instead of vanishing), drop the hidden ones, drop paths no longer in `DEFAULT_APPS`.

`DEFAULT_APPS` — the eleven heavyweight tools that have real routes:

```
/app/api-client /app/data-explorer /app/sql-client
/app/s3-drive /app/redis-commander /app/notes
/app/bookmarks /app/snippet-manager /app/to-do
/app/password-manager /app/environment-manager
```

`api-key-vault` is excluded — it is a component, not a route.

## i18n

New `Navigation` keys — `apps`, `open`, `manageApps` — plus `dashboard` and `pinned`, which
the dying top strip hardcoded in English. Added to `messages/en.json` and all 26 other
locales. No ICU plurals involved, so no per-locale plural-category work.

## Testing

- `store/__tests__/app-rail-store.test.ts` — reorder persists; hide removes a row from the
visible list; a `DEFAULT_APPS` entry absent from a stored order appears at the end; a
stored path no longer in `DEFAULT_APPS` is dropped.
- `components/sidebar/__tests__/app-sidebar-rbac.test.tsx` — existing suite, updated for
the new rail structure.
- Manual: `pnpm dev:tauri` — traffic lights draggable and not overlapped, ⌘B collapse,
⌥1–9 / ⌥W still switch and close tabs, close `×` on a row picks the neighbouring tab.

## Out of scope

- Drag-resizable rail width (collapse to icons only).
- Backend sync of app order or hidden apps.
- Mobile redesign — the rail uses the shadcn offcanvas Sheet below `md` and `MobileNav`
stays as is.
- Any change to tool pages, `NavBar`, or `TabContent`.