Files
mcpctl/src/web/src/components/ui/tabs.tsx
Michal e8c3803fac feat(web): bold redesign — Tailwind v4 + shadcn-style primitives + Skills/Proposals/Revisions UI
Phase 6 of the Skills + Revisions + Proposals work. The web UI gets a
new design language and first-class affordances for everything the
backend now supports.

## Visual direction

- Tailwind v4 with custom @theme block (oklch tokens). Dark-mode-only
  (internal tool — light mode doubles QA surface).
- Inter for UI, JetBrains Mono for code/IDs (loaded via Google Fonts;
  trivial to swap for self-hosted geist later — the fallback stack
  reads identically).
- Sidebar layout (always-visible at desktop widths) replacing the
  previous top-bar nav. Pending-proposals badge polls every 30 s so
  reviewers see a queue building without refreshing.
- Lucide icons throughout.
- Spacing and radii on Tailwind defaults.

Existing inline-styled pages (Projects, Agents, AgentDetail,
ProjectPrompts, PersonalityDetail, Login) continue to work unchanged
inside the new Layout — Tailwind doesn't conflict with their inline
styles. A follow-up can migrate them incrementally.

## What's added

### Build infra (src/web/)
- package.json: tailwindcss@^4 + @tailwindcss/vite, lucide-react,
  class-variance-authority, clsx, tailwind-merge, diff, geist (held
  for future self-hosting).
- vite.config.ts: registers the @tailwindcss/vite plugin.
- src/index.css: Tailwind import + @theme tokens + @layer base.
- src/main.tsx: imports index.css.
- src/lib/utils.ts: shadcn-style cn() helper.

### shadcn-style primitives (src/components/ui/)
Hand-written rather than generated via `npx shadcn` so the repo doesn't
depend on a CLI tool that needs interactive runtime:

- button.tsx — variants: primary / secondary / ghost / danger / link;
  sizes: sm / md / lg / icon.
- card.tsx — Card + Header/Title/Description/Content/Footer subparts.
- badge.tsx — variants: default / info / success / warning / danger /
  outline.
- input.tsx — Input + Textarea + Label.
- tabs.tsx — no-dep accessible Tabs (no Radix needed for our use).
- separator.tsx — h/v separator with role=separator.

### Diff component (src/components/Diff.tsx)
Wraps the `diff` package (already added in PR-2) for inline unified-
diff display with color-coded add/remove rows. Used by both the
proposal review page and the skill revision-history tab.

### New pages (src/pages/)
- Dashboard.tsx — at-a-glance home. Counts for skills, prompts,
  projects, agents, proposals; pending-proposals call-out card if any.
- Skills.tsx — list view, separated into Global vs Project/Agent-
  scoped sections.
- SkillDetail.tsx — name + semver + description; tabs for SKILL.md /
  Files / Metadata / History. History tab shows revisions with
  click-to-diff against the live body.
- Proposals.tsx — queue with Pending/Approved/Rejected tabs. Pending
  count is highlighted in amber.
- ProposalDetail.tsx — full body, diff against current resource (or
  "would create new" if it doesn't exist), approve button + reject-
  with-required-note flow.

### usePolling hook (src/hooks/)
Tiny polling-with-cancellation hook used by Layout and Proposals.

### Layout rewrite (src/components/Layout.tsx)
Sidebar with nav items: Dashboard, Projects, Agents, Skills,
Proposals. Lucide icons. Active-route highlighting via NavLink.
Pending-proposals warning badge on the Proposals item.

### Routes (src/App.tsx)
New routes: /dashboard, /skills, /skills/:name, /proposals,
/proposals/:id. Default redirects to /dashboard.

### API types (src/api.ts)
Type defs for Skill, VisibleSkill, Proposal, Revision (with the
shapes the new pages consume).

## Tests

Existing 7 web tests still pass (Login + api). New page-level tests
deferred — the new pages are mostly compositions of primitives and
fetch hooks that round-trip to the backend; the backend tests already
cover what they call. PR-7 polish can add render-and-click tests if
coverage drift surfaces.

## Verification

- `pnpm --filter @mcpctl/web build` clean, no warnings.
- `pnpm test:run` whole monorepo: 162 test files / 2157 tests green.
- Visual smoke deferred — needs a running mcpd to populate the
  fixtures. Manual smoke tested locally is the next step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 17:54:55 +01:00

91 lines
2.7 KiB
TypeScript

import * as React from 'react';
import { cn } from '../../lib/utils';
/**
* Tiny no-dep Tabs primitive. Doesn't need Radix for our use case —
* just tracks the active tab via state and re-renders the matching
* panel. ARIA roles are set so screen readers parse it as tabs.
*/
interface TabsContextValue {
value: string;
setValue: (v: string) => void;
}
const TabsContext = React.createContext<TabsContextValue | null>(null);
export function Tabs({
defaultValue,
value: valueProp,
onValueChange,
className,
children,
}: {
defaultValue?: string;
value?: string;
onValueChange?: (v: string) => void;
className?: string;
children: React.ReactNode;
}): React.JSX.Element {
const [internal, setInternal] = React.useState(defaultValue ?? '');
const value = valueProp ?? internal;
const setValue = React.useCallback(
(v: string) => {
if (valueProp === undefined) setInternal(v);
onValueChange?.(v);
},
[valueProp, onValueChange],
);
return (
<TabsContext.Provider value={{ value, setValue }}>
<div className={cn('flex flex-col gap-3', className)}>{children}</div>
</TabsContext.Provider>
);
}
export function TabsList({ className, children }: { className?: string; children: React.ReactNode }): React.JSX.Element {
return (
<div
role="tablist"
className={cn(
'inline-flex h-9 items-center justify-start gap-1 rounded-md border border-(--color-border) bg-(--color-surface) p-1',
className,
)}
>
{children}
</div>
);
}
export function TabsTrigger({ value, className, children }: { value: string; className?: string; children: React.ReactNode }): React.JSX.Element {
const ctx = React.useContext(TabsContext);
if (!ctx) throw new Error('TabsTrigger must be used within Tabs');
const active = ctx.value === value;
return (
<button
role="tab"
aria-selected={active}
onClick={() => ctx.setValue(value)}
className={cn(
'inline-flex h-7 items-center justify-center rounded px-3 text-sm font-medium transition-colors',
active
? 'bg-(--color-canvas) text-(--color-fg) shadow-sm'
: 'text-(--color-fg-muted) hover:text-(--color-fg)',
className,
)}
>
{children}
</button>
);
}
export function TabsContent({ value, className, children }: { value: string; className?: string; children: React.ReactNode }): React.JSX.Element | null {
const ctx = React.useContext(TabsContext);
if (!ctx) throw new Error('TabsContent must be used within Tabs');
if (ctx.value !== value) return null;
return (
<div role="tabpanel" className={cn('focus-visible:outline-none', className)}>
{children}
</div>
);
}