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>
91 lines
2.7 KiB
TypeScript
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>
|
|
);
|
|
}
|