3 Commits

Author SHA1 Message Date
sb-iam 07509c62bd docs(graph): record the shadcn/ruixen component convention
UI composes from the ruixen registry (npx shadcn add ruixen.com/r/[component])
via @/components/ui/* + design tokens; only the SVG canvas is bespoke.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 19:48:05 -07:00
sb-iam a49501b3c4 refactor(ui): compose Team Memory from the shadcn/ruixen primitives
Per the unifying template (npx shadcn add ruixen.com/r/[component]): the chrome
now uses Button + Badge + the app's Tailwind utility patterns (matching StatPill/
App.tsx) inside the page shell, instead of a hand-rolled .pm-* stylesheet. Only
the SVG node-link canvas stays bespoke (3 SVG-only CSS rules). No behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 19:46:48 -07:00
sb-iam 7c572fbbcb feat(ui): re-theme Team Memory graph to match the shadcn app
The app is light shadcn; GraphView was hardcoded dark-Bauhaus and clashed.
Re-theme to shadcn tokens (var(--card)/--foreground/--muted-foreground/
--border, var(--primary) for active) so the panel is theme-aware (light now,
follows dark mode if toggled) and native to the command-center. Node-kind
encoding kept (shapes + light-readable hues); labels are sentence-case.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 19:39:04 -07:00
2 changed files with 206 additions and 207 deletions
+11 -1
View File
@@ -79,10 +79,20 @@ Additive routes in `backend/src/server.ts` (shared file — additive only).
- `backend/src/graph/store.ts` — `loadPodGraph`, `seedGraph`, `reachFrom` (`$graphLookup`) - `backend/src/graph/store.ts` — `loadPodGraph`, `seedGraph`, `reachFrom` (`$graphLookup`)
- `backend/src/graph/seed.ts` — `pnpm graph:seed` (writes demo into `team_model` + graph collections) - `backend/src/graph/seed.ts` — `pnpm graph:seed` (writes demo into `team_model` + graph collections)
- `frontend/src/lib/graph.ts` — `fetchPodGraph(podId)` - `frontend/src/lib/graph.ts` — `fetchPodGraph(podId)`
- `frontend/src/components/GraphView.tsx` — dark-Bauhaus SVG graph (toggle from `App.tsx`) - `frontend/src/components/GraphView.tsx` — shadcn-themed SVG graph (theme-aware; toggle from `App.tsx`)
## Demo-first plan ## Demo-first plan
1. Serve `createDemoPodGraph()` from the route (demo-stable, no DB dependency on the demo path). 1. Serve `createDemoPodGraph()` from the route (demo-stable, no DB dependency on the demo path).
2. `pnpm graph:seed` writes the same graph into Mongo so `$graphLookup` is real, not a mock. 2. `pnpm graph:seed` writes the same graph into Mongo so `$graphLookup` is real, not a mock.
3. Swap `loadPodGraph` to read live `team_model.graph` once the ingest pipeline populates it. 3. Swap `loadPodGraph` to read live `team_model.graph` once the ingest pipeline populates it.
## Component convention
UI is built from the shared **shadcn / ruixen** registry — add primitives with
`npx shadcn@latest add "https://ruixen.com/r/[component]"` and compose from
`@/components/ui/*` (`Button`, `Badge`, `Card`, …) using the design tokens
(`var(--card)` / `--foreground` / `--border` / …). Only the SVG node-link **canvas**
in `GraphView.tsx` is bespoke (3 SVG-only CSS rules); the chrome (header, toggles,
metric cards, detail panel, legend) is composed from the primitives + the app's
Tailwind utility patterns. No hand-rolled component stylesheets.
+195 -206
View File
@@ -1,24 +1,36 @@
import { useEffect, useMemo, useState, type CSSProperties } from 'react'; import { useEffect, useMemo, useState, type CSSProperties } from 'react';
import type { PodGraph, PodGraphNode, PodGraphEdge, PodGraphNodeKind } from '@podman/shared'; import type { PodGraph, PodGraphNode, PodGraphEdge, PodGraphNodeKind } from '@podman/shared';
import { fetchPodGraph } from '../lib/graph.js'; import { fetchPodGraph } from '../lib/graph.js';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
type Mode = 'risk' | 'learn' | 'all'; type Mode = 'risk' | 'learn' | 'all';
// Fixed, light-readable hues for the node/edge encoding (kept stable across
// light/dark so kinds stay distinguishable; the chrome uses shadcn tokens).
const BLUE = '#2563eb';
const SLATE = '#475569';
const SLATE_EDGE = '#94a3b8';
const SLATE_FAINT = '#cbd5e1';
const AMBER = '#d97706';
const RED = '#dc2626';
const VIOLET = '#7c3aed';
const KIND_COLOR: Record<PodGraphNodeKind, string> = { const KIND_COLOR: Record<PodGraphNodeKind, string> = {
engineer: '#3B5BFF', engineer: BLUE,
file: '#ECE7DA', file: SLATE,
feature: '#F6C445', feature: AMBER,
collision: '#E2403A', collision: RED,
intervention: '#8b6cff', intervention: VIOLET,
}; };
const EDGE: Record<PodGraphEdge['kind'], { c: string; w: number; dash?: boolean }> = { const EDGE: Record<PodGraphEdge['kind'], { c: string; w: number; dash?: boolean }> = {
owns: { c: '#3B5BFF', w: 2.6 }, owns: { c: BLUE, w: 2.6 },
editing: { c: '#ECE7DA', w: 2 }, editing: { c: SLATE_EDGE, w: 2 },
touches: { c: '#5d5d66', w: 1.6 }, touches: { c: SLATE_FAINT, w: 1.6 },
collides: { c: '#E2403A', w: 3.2 }, collides: { c: RED, w: 3.2 },
warns: { c: '#F6C445', w: 3.2 }, warns: { c: AMBER, w: 3.2 },
learned_from: { c: '#8b6cff', w: 2.4, dash: true }, learned_from: { c: VIOLET, w: 2.4, dash: true },
}; };
function NodeShape({ node }: { node: PodGraphNode }) { function NodeShape({ node }: { node: PodGraphNode }) {
@@ -26,7 +38,7 @@ function NodeShape({ node }: { node: PodGraphNode }) {
const { x, y } = node; const { x, y } = node;
switch (node.kind) { switch (node.kind) {
case 'engineer': case 'engineer':
return <rect x={x - 15} y={y - 15} width={30} height={30} fill={c} />; return <rect x={x - 15} y={y - 15} width={30} height={30} rx={4} fill={c} />;
case 'file': case 'file':
return ( return (
<rect <rect
@@ -34,6 +46,7 @@ function NodeShape({ node }: { node: PodGraphNode }) {
y={y - 15} y={y - 15}
width={30} width={30}
height={30} height={30}
rx={4}
fill="none" fill="none"
stroke={c} stroke={c}
strokeWidth={2.6} strokeWidth={2.6}
@@ -81,16 +94,19 @@ function highlightFor(graph: PodGraph, mode: Mode, selected: string | null): Hig
} }
const LEGEND: Array<{ label: string; swatch: CSSProperties }> = [ const LEGEND: Array<{ label: string; swatch: CSSProperties }> = [
{ label: 'engineer', swatch: { background: '#3B5BFF' } }, { label: 'engineer', swatch: { background: BLUE } },
{ label: 'file', swatch: { border: '2px solid #ECE7DA' } }, { label: 'file', swatch: { border: `2px solid ${SLATE}` } },
{ label: 'feature', swatch: { background: '#F6C445', borderRadius: '50%' } }, { label: 'feature', swatch: { background: AMBER, borderRadius: '50%' } },
{ { label: 'collision', swatch: { background: RED, clipPath: 'polygon(50% 0,100% 100%,0 100%)' } },
label: 'collision', { label: 'intervention', swatch: { background: VIOLET, transform: 'rotate(45deg)' } },
swatch: { background: '#E2403A', clipPath: 'polygon(50% 0,100% 100%,0 100%)' },
},
{ label: 'intervention', swatch: { background: '#8b6cff', transform: 'rotate(45deg)' } },
]; ];
function statusColor(status: string): string {
if (status === 'risk') return RED;
if (status === 'learned') return VIOLET;
return 'var(--foreground)';
}
export function GraphView({ podId, onClose }: { podId: string; onClose: () => void }) { export function GraphView({ podId, onClose }: { podId: string; onClose: () => void }) {
const [graph, setGraph] = useState<PodGraph | null>(null); const [graph, setGraph] = useState<PodGraph | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -128,201 +144,174 @@ export function GraphView({ podId, onClose }: { podId: string; onClose: () => vo
setSelected(null); setSelected(null);
} }
const toggleVariant = (m: Mode) => (mode === m && !selected ? 'default' : 'outline');
return ( return (
<div className="pm-graph"> <div className="min-h-screen bg-background text-foreground">
<style>{` <div className="mx-auto w-full max-w-7xl px-4 py-4 sm:px-6 lg:px-8">
.pm-graph{--bg:#0c0c0e;--panel:#141417;--line:#2a2a31;--paper:#ECE7DA;--mut:#8d897e;--red:#E2403A;--yel:#F6C445;--vio:#8b6cff; <style>{`
font-family:'Space Grotesk',system-ui,sans-serif;background:var(--bg);color:var(--paper);border:1px solid var(--line);border-radius:14px;overflow:hidden} .pm-node{cursor:pointer}
.pm-graph *{box-sizing:border-box} .pm-lbl{fill:var(--foreground);font-size:11px;font-weight:500}
.pm-hd{display:flex;align-items:center;justify-content:space-between;padding:16px 20px;border-bottom:3px solid var(--paper)} .pm-dim{opacity:.18;transition:opacity .25s}
.pm-ttl{font-weight:800;font-size:18px;letter-spacing:.14em;text-transform:uppercase;font-family:Archivo,'Space Grotesk',sans-serif} `}</style>
.pm-sub{font-size:10px;letter-spacing:.3em;color:var(--mut);text-transform:uppercase;margin-top:5px}
.pm-x{background:transparent;border:1px solid var(--line);color:var(--paper);font-size:11px;letter-spacing:.1em;text-transform:uppercase;padding:7px 12px;border-radius:2px;cursor:pointer}
.pm-x:hover{border-color:var(--paper)}
.pm-bar{display:flex;gap:8px;padding:12px 16px;border-bottom:1px solid var(--line);flex-wrap:wrap}
.pm-btn{font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--paper);background:transparent;border:1px solid var(--line);padding:7px 12px;cursor:pointer;border-radius:2px}
.pm-btn:hover{border-color:var(--paper)}
.pm-btn.on{background:var(--red);border-color:var(--red);color:#fff}
.pm-grid{display:grid;grid-template-columns:180px 1fr 240px}
.pm-col{padding:14px}
.pm-railR{border-left:1px solid var(--line);background:#17171b}
.pm-st{font-size:11px;letter-spacing:.24em;text-transform:uppercase;color:var(--mut);margin:2px 0 12px}
.pm-kpi{border:1px solid var(--line);border-left:5px solid var(--vio);padding:10px 11px;margin-bottom:10px}
.pm-num{font-weight:800;font-size:26px;line-height:.9;font-variant-numeric:tabular-nums;font-family:Archivo,sans-serif}
.pm-klab{font-size:10px;letter-spacing:.16em;text-transform:uppercase;color:var(--mut);margin-top:6px}
.pm-kdet{font-size:10px;color:var(--mut);margin-top:5px;line-height:1.4}
.pm-canvas{background:var(--panel);border-left:1px solid var(--line);border-right:1px solid var(--line);min-height:472px}
.pm-canvas svg{width:100%;height:auto;display:block}
.pm-node{cursor:pointer}
.pm-lbl{font-weight:500;font-size:11px;letter-spacing:.06em;fill:var(--paper);text-transform:uppercase}
.pm-dim{opacity:.12;transition:opacity .25s}
.pm-dkind{font-size:10px;letter-spacing:.24em;text-transform:uppercase;color:var(--mut)}
.pm-dname{font-weight:800;font-size:20px;margin:5px 0 8px;font-family:Archivo,sans-serif}
.pm-drow{display:flex;justify-content:space-between;font-size:12px;padding:6px 0;border-bottom:1px solid var(--line);color:var(--mut)}
.pm-drow b{color:var(--paper);font-weight:500}
.pm-note{font-size:12px;color:var(--mut);line-height:1.5;margin-top:10px}
.pm-legend{display:flex;gap:14px;flex-wrap:wrap;padding:10px 16px;border-top:1px solid var(--line);font-size:10px;letter-spacing:.06em;text-transform:uppercase;color:var(--mut)}
.pm-lg{display:flex;align-items:center;gap:6px}
.pm-sw{width:13px;height:13px;display:inline-block}
@media(max-width:760px){.pm-grid{grid-template-columns:1fr}.pm-railR{border-left:0;border-top:1px solid var(--line)}.pm-canvas{border:0;border-top:1px solid var(--line)}}
`}</style>
<div className="pm-hd"> <div className="overflow-hidden rounded-xl border bg-card text-card-foreground">
<div> <div className="flex items-center justify-between border-b px-5 py-4">
<div className="pm-ttl">Team memory</div> <div>
<div className="pm-sub">What PodMan learned · {podId}</div> <h2 className="text-base font-medium">Team memory</h2>
</div> <p className="mt-0.5 text-xs text-muted-foreground">What PodMan learned · {podId}</p>
<button className="pm-x" onClick={onClose}> </div>
Pods <Button variant="outline" size="sm" onClick={onClose}>
</button> Pods
</div> </Button>
</div>
<div className="pm-bar"> <div className="flex flex-wrap gap-2 border-b px-4 py-3">
<button <Button variant={toggleVariant('risk')} size="sm" onClick={() => pick('risk')}>
className={`pm-btn ${mode === 'risk' && !selected ? 'on' : ''}`} Risk path
onClick={() => pick('risk')} </Button>
> <Button variant={toggleVariant('learn')} size="sm" onClick={() => pick('learn')}>
Risk path Learning edges
</button> </Button>
<button <Button variant={toggleVariant('all')} size="sm" onClick={() => pick('all')}>
className={`pm-btn ${mode === 'learn' && !selected ? 'on' : ''}`} Whole graph
onClick={() => pick('learn')} </Button>
> </div>
Learning edges
</button>
<button
className={`pm-btn ${mode === 'all' && !selected ? 'on' : ''}`}
onClick={() => pick('all')}
>
Whole graph
</button>
</div>
{error && ( {error && <p className="px-4 py-4 text-sm text-destructive">Graph error: {error}</p>}
<p style={{ padding: '16px', color: '#ff7d76', fontSize: 13 }}>Graph error: {error}</p> {!graph && !error && (
)} <p className="px-4 py-4 text-sm text-muted-foreground">Loading graph</p>
{!graph && !error && ( )}
<p style={{ padding: '16px', color: '#8d897e', fontSize: 13 }}>Loading graph</p>
)}
{graph && ( {graph && (
<> <>
<div className="pm-grid"> <div className="grid lg:grid-cols-[190px_1fr_250px]">
<div className="pm-col"> <div className="space-y-3 p-4">
<div className="pm-st">Workflow metrics</div> <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{graph.metrics.map((m) => ( Workflow metrics
<div className="pm-kpi" key={m.label}> </p>
<div className="pm-num">{m.value}</div> {graph.metrics.map((m) => (
<div className="pm-klab">{m.label}</div> <div key={m.label} className="rounded-lg border bg-card px-3 py-2.5">
<div className="pm-kdet">{m.detail}</div> <p className="text-2xl font-medium tabular-nums">{m.value}</p>
<p className="mt-1 text-xs font-medium uppercase text-muted-foreground">
{m.label}
</p>
<p className="mt-1 text-xs leading-snug text-muted-foreground">{m.detail}</p>
</div>
))}
</div> </div>
))}
</div>
<div className="pm-canvas"> <div className="min-h-[472px] border-y bg-card lg:border-x lg:border-y-0">
<svg viewBox="0 0 720 472" role="img" aria-label="PodMan team-memory graph"> <svg
{graph.edges.map((e) => { viewBox="0 0 720 472"
const a = nodeById.get(e.source); role="img"
const b = nodeById.get(e.target); aria-label="PodMan team-memory graph"
if (!a || !b) return null; className="block h-auto w-full"
const s = EDGE[e.kind];
return (
<line
key={e.id}
className={dimEdge(e.id) ? 'pm-dim' : undefined}
x1={a.x}
y1={a.y}
x2={b.x}
y2={b.y}
stroke={s.c}
strokeWidth={hotEdge(e.id) ? s.w + 1.6 : s.w}
strokeDasharray={s.dash ? '7 6' : undefined}
strokeLinecap="round"
/>
);
})}
{graph.nodes.map((n) => (
<g
key={n.id}
className={`pm-node ${dimNode(n.id) ? 'pm-dim' : ''}`}
role="button"
tabIndex={0}
aria-label={`${n.kind}: ${n.label}`}
onClick={() => setSelected((cur) => (cur === n.id ? null : n.id))}
onKeyDown={(ev) => {
if (ev.key === 'Enter' || ev.key === ' ') {
ev.preventDefault();
setSelected((cur) => (cur === n.id ? null : n.id));
}
}}
> >
<NodeShape node={n} /> {graph.edges.map((e) => {
<text className="pm-lbl" x={n.x} y={n.y + 33} textAnchor="middle"> const a = nodeById.get(e.source);
{n.label.toUpperCase()} const b = nodeById.get(e.target);
</text> if (!a || !b) return null;
</g> const s = EDGE[e.kind];
return (
<line
key={e.id}
className={dimEdge(e.id) ? 'pm-dim' : undefined}
x1={a.x}
y1={a.y}
x2={b.x}
y2={b.y}
stroke={s.c}
strokeWidth={hotEdge(e.id) ? s.w + 1.6 : s.w}
strokeDasharray={s.dash ? '7 6' : undefined}
strokeLinecap="round"
/>
);
})}
{graph.nodes.map((n) => (
<g
key={n.id}
className={`pm-node ${dimNode(n.id) ? 'pm-dim' : ''}`}
role="button"
tabIndex={0}
aria-label={`${n.kind}: ${n.label}`}
onClick={() => setSelected((cur) => (cur === n.id ? null : n.id))}
onKeyDown={(ev) => {
if (ev.key === 'Enter' || ev.key === ' ') {
ev.preventDefault();
setSelected((cur) => (cur === n.id ? null : n.id));
}
}}
>
<NodeShape node={n} />
<text className="pm-lbl" x={n.x} y={n.y + 33} textAnchor="middle">
{n.label}
</text>
</g>
))}
</svg>
</div>
<div className="border-t bg-muted p-4 lg:border-l lg:border-t-0">
{sel ? (
<>
<p className="text-xs uppercase tracking-wide text-muted-foreground">
{sel.kind}
</p>
<h3 className="mb-3 mt-1 text-lg font-medium">{sel.label}</h3>
<div className="flex items-center justify-between border-b py-1.5 text-sm text-muted-foreground">
<span>Status</span>
<Badge variant="outline" style={{ color: statusColor(sel.status) }}>
{sel.status}
</Badge>
</div>
<div className="flex items-center justify-between border-b py-1.5 text-sm text-muted-foreground">
<span>Relationships</span>
<span className="font-medium text-foreground">{relCount}</span>
</div>
<p className="mt-2.5 text-sm leading-relaxed text-muted-foreground">
{sel.summary}
</p>
</>
) : (
<>
<p className="text-xs uppercase tracking-wide text-muted-foreground">
Continual learning
</p>
<h3 className="mb-3 mt-1 text-lg font-medium">It learned</h3>
<p className="text-sm leading-relaxed text-muted-foreground">
The violet{' '}
<span className="font-medium" style={{ color: VIOLET }}>
learned_from
</span>{' '}
edges are ownership PodMan retained from accepted interventions the graph
gets sharper every session. Click any node to trace its relationships.
</p>
</>
)}
</div>
</div>
<div className="flex flex-wrap gap-3 border-t px-4 py-2.5 text-xs text-muted-foreground">
{LEGEND.map((l) => (
<span key={l.label} className="flex items-center gap-1.5">
<span className="inline-block size-3" style={l.swatch} />
{l.label}
</span>
))} ))}
</svg> <span className="flex items-center gap-1.5">
</div> <span className="inline-block h-[3px] w-3" style={{ background: RED }} />
collides
<div className="pm-col pm-railR"> </span>
{sel ? ( <span className="flex items-center gap-1.5">
<> <span className="inline-block h-[3px] w-3" style={{ background: VIOLET }} />
<div className="pm-dkind">{sel.kind}</div> learned_from
<div className="pm-dname">{sel.label}</div> </span>
<div className="pm-drow"> </div>
<span>Status</span> </>
<b )}
style={{ </div>
color: </div>
sel.status === 'risk'
? '#E2403A'
: sel.status === 'learned'
? '#b7a4ff'
: '#ECE7DA',
}}
>
{sel.status}
</b>
</div>
<div className="pm-drow">
<span>Relationships</span>
<b>{relCount}</b>
</div>
<div className="pm-note">{sel.summary}</div>
</>
) : (
<>
<div className="pm-dkind">Continual learning</div>
<div className="pm-dname">It learned</div>
<div className="pm-note">
Violet <b style={{ color: '#b7a4ff' }}>learned_from</b> edges are ownership
PodMan retained from accepted interventions the graph gets sharper every
session. Click any node to trace its relationships.
</div>
</>
)}
</div>
</div>
<div className="pm-legend">
{LEGEND.map((l) => (
<span className="pm-lg" key={l.label}>
<span className="pm-sw" style={l.swatch} />
{l.label}
</span>
))}
<span className="pm-lg">
<span className="pm-sw" style={{ background: '#E2403A', height: 3 }} />
collides
</span>
<span className="pm-lg">
<span className="pm-sw" style={{ background: '#8b6cff', height: 3 }} />
learned_from
</span>
</div>
</>
)}
</div> </div>
); );
} }