-
+
+
+ {service}
+ {node && (
+
+
+
+ )}
+
+
+
+
+
+
+
+ {/* Tabs switch Overview / Why / Impact / Dependencies inside the popup
+ on every breakpoint, so the popup is self-sufficient; the desktop
+ side rail mirrors the same nav (both drive the shared tab state). */}
+
+ {INSPECTOR_TABS.map((t) => (
+ onTab(t.id)}
+ >
+ {t.label}
+
+ ))}
+
+
+
+ {loading ? (
+
+ ) : error ? (
+
+
Couldn’t load the service graph: {error}
+
+ Retry
+
+
+ ) : !node ? (
+
+
+ {service} isn’t in the current service graph — it
+ may have stopped reporting.
+
+
+ ) : (
+
+ )}
-
{children}
)
}
-/** Mounts when ?service= is present (App gates the lazy import on that). */
+/** Mounts when ?service= is present (App gates the lazy import on that).
+ * The popup is the sole surface — it carries the category tabs + content on
+ * every breakpoint; there is no docked side rail. */
export default function ServiceInspector() {
const { service, closeInspector } = useInvestigation()
- const isXs = useMediaQuery('(max-width: 767px)')
+ const search = useSearch()
+ // ?tab= targets a registry category (palette verbs / shared links); it seeds
+ // local state, re-seeded whenever the param or inspected service changes
+ // (adjust-during-render, not an effect).
+ const tabParam = readParam(search, 'tab')
+ const seedTab = isInspectorTabId(tabParam) ? tabParam : INSPECTOR_TABS[0].id
+ const [tab, setTab] = useState(seedTab)
+ const [seedKey, setSeedKey] = useState({ tabParam, service })
+ if (seedKey.tabParam !== tabParam || seedKey.service !== service) {
+ setSeedKey({ tabParam, service })
+ setTab(seedTab)
+ }
if (!service) return null
- if (isXs) {
- return (
-
-
-
- )
- }
-
- return (
-
{
- if (e.key === 'Escape') closeInspector()
- }}
- >
-
-
- )
+ return
}
diff --git a/ui/src/components/inspector/__tests__/ServiceInspector.test.tsx b/ui/src/components/inspector/__tests__/ServiceInspector.test.tsx
index 66cbea8..be10c9a 100644
--- a/ui/src/components/inspector/__tests__/ServiceInspector.test.tsx
+++ b/ui/src/components/inspector/__tests__/ServiceInspector.test.tsx
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
+import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { Router } from 'wouter'
@@ -102,7 +102,7 @@ afterEach(() => {
vi.unstubAllGlobals()
})
-function renderInspector(path = '/map?service=payments&trail=svc:payments') {
+function renderInspector(path = '/?service=payments') {
const memory = memoryLocation({ path, record: true })
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
render(
@@ -117,17 +117,34 @@ function renderInspector(path = '/map?service=payments&trail=svc:payments') {
describe('ServiceInspector', () => {
it('renders nothing without ?service', () => {
- renderInspector('/map')
+ renderInspector('/')
expect(screen.queryByRole('complementary')).not.toBeInTheDocument()
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
- it('shows header with status, mono name, health % and a close button', async () => {
+ it('the popup carries the name + category tabs, with no docked side rail', async () => {
renderInspector()
- expect(
- await screen.findByRole('heading', { name: 'payments' }),
- ).toBeInTheDocument()
- // header health % + overview health row both show 73%
- expect(await screen.findAllByText('73%')).not.toHaveLength(0)
+ const popup = await screen.findByRole('dialog')
+ expect(within(popup).getByRole('heading', { name: 'payments' })).toBeInTheDocument()
+ const tablist = within(popup).getByRole('tablist', { name: /inspector sections/i })
+ for (const name of ['Overview', 'Why', 'Impact', 'Dependencies']) {
+ expect(within(tablist).getByRole('tab', { name })).toBeInTheDocument()
+ }
+ // The popup stands alone — there is no docked rail.
+ expect(screen.queryByRole('complementary')).not.toBeInTheDocument()
+ })
+
+ it('opens a details popup with the active category content + a close button', async () => {
+ renderInspector()
+ const popup = await screen.findByRole('dialog')
+ expect(await within(popup).findByText('42/s')).toBeInTheDocument()
+ expect(within(popup).getByText('4.2%')).toBeInTheDocument()
+ expect(within(popup).getByText('230ms')).toBeInTheDocument()
+ expect(within(popup).getByRole('meter', { name: /health score/i })).toHaveAttribute(
+ 'aria-valuenow',
+ '73',
+ )
+ expect(within(popup).getByText('p99 above 200ms')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /close inspector/i })).toBeInTheDocument()
})
@@ -137,143 +154,64 @@ describe('ServiceInspector', () => {
expect(screen.getByTestId('inspector-skeleton')).toBeInTheDocument()
})
- it('overview tab renders the stat grid, health meter and alerts', async () => {
+ it('picking a tab switches the popup content', async () => {
+ const user = userEvent.setup()
renderInspector()
- expect(await screen.findByText('42/s')).toBeInTheDocument()
- expect(screen.getByText('4.2%')).toBeInTheDocument()
- expect(screen.getByText('230ms')).toBeInTheDocument()
- expect(screen.getByRole('meter', { name: /health score/i })).toHaveAttribute(
- 'aria-valuenow',
- '73',
- )
- expect(screen.getByText('p99 above 200ms')).toBeInTheDocument()
+ const popup = await screen.findByRole('dialog')
+ await user.click(within(popup).getByRole('tab', { name: 'Dependencies' }))
+ expect(within(popup).getByRole('region', { name: /upstream callers/i })).toBeInTheDocument()
+ const downstream = within(popup).getByRole('region', { name: /downstream dependencies/i })
+ expect(within(downstream).getByText('db')).toBeInTheDocument()
})
- it('?tab= deep-links a registry tab (palette verbs)', async () => {
- renderInspector('/map?service=payments&tab=why')
- await screen.findByRole('tab', { name: /why/i })
- expect(screen.getByRole('tab', { name: /why/i })).toHaveAttribute(
- 'aria-selected',
- 'true',
- )
+ it('?tab= deep-links a category (palette verbs)', async () => {
+ renderInspector('/?service=payments&tab=why')
+ const popup = await screen.findByRole('dialog')
+ expect(within(popup).getByRole('tab', { name: 'Why' })).toHaveAttribute('aria-selected', 'true')
expect(
- screen.getByRole('button', { name: /run root-cause analysis/i }),
+ await within(popup).findByRole('button', { name: /run root-cause analysis/i }),
).toBeInTheDocument()
})
it('an invalid ?tab= falls back to Overview', async () => {
- renderInspector('/map?service=payments&tab=nope')
- await screen.findByText('42/s')
- expect(screen.getByRole('tab', { name: /overview/i })).toHaveAttribute(
+ renderInspector('/?service=payments&tab=nope')
+ const popup = await screen.findByRole('dialog')
+ expect(within(popup).getByRole('tab', { name: 'Overview' })).toHaveAttribute(
'aria-selected',
'true',
)
})
- it('dependencies tab lists upstream/downstream; row click pushes the trail', async () => {
+ it('a downstream dependency click switches the inspected service', async () => {
const user = userEvent.setup()
const memory = renderInspector()
- await screen.findByText('42/s') // graph loaded, tabs mounted
-
- await user.click(screen.getByRole('tab', { name: /dependencies/i }))
- const upstream = screen.getByRole('region', { name: /upstream callers/i })
- const downstream = screen.getByRole('region', { name: /downstream dependencies/i })
- expect(within(upstream).getByText('checkout')).toBeInTheDocument()
- expect(within(downstream).getByText('db')).toBeInTheDocument()
-
+ const popup = await screen.findByRole('dialog')
+ await user.click(within(popup).getByRole('tab', { name: 'Dependencies' }))
+ const downstream = within(popup).getByRole('region', { name: /downstream dependencies/i })
await user.click(within(downstream).getByRole('button', { name: /db/ }))
- await waitFor(() =>
- expect(screen.getByRole('heading', { name: 'db' })).toBeInTheDocument(),
- )
- // pushed, not replaced: both frames in the URL trail
- expect(memory.history.at(-1)).toContain('trail=svc%3Apayments%2Csvc%3Adb')
+ await waitFor(() => expect(memory.history.at(-1)).toContain('service=db'))
})
- it('close button clears ?service and unmounts the panel', async () => {
+ it('close button clears ?service and unmounts the popup', async () => {
const user = userEvent.setup()
renderInspector()
- await screen.findByRole('heading', { name: 'payments' })
+ await screen.findByRole('dialog')
await user.click(screen.getByRole('button', { name: /close inspector/i }))
- expect(screen.queryByRole('complementary')).not.toBeInTheDocument()
- })
-
- it('Escape inside the panel closes it', async () => {
- const user = userEvent.setup()
- renderInspector()
- await screen.findByRole('heading', { name: 'payments' })
- screen.getByRole('button', { name: /close inspector/i }).focus()
- await user.keyboard('{Escape}')
- expect(screen.queryByRole('complementary')).not.toBeInTheDocument()
+ await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
})
it('shows a not-found state for a service missing from the graph', async () => {
- renderInspector('/map?service=ghost')
+ renderInspector('/?service=ghost')
expect(
await screen.findByText(/isn’t in the current service graph/i),
).toBeInTheDocument()
})
it('shows an error state with retry when the graph fails', async () => {
- graphResponder = () =>
- Promise.resolve(new Response('boom', { status: 500 }))
+ graphResponder = () => Promise.resolve(new Response('boom', { status: 500 }))
renderInspector()
expect(await screen.findByRole('alert')).toHaveTextContent(/couldn’t load/i)
expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument()
})
- describe('xs bottom sheet', () => {
- beforeEach(() => {
- const mql = (query: string): MediaQueryList =>
- ({
- matches: query === '(max-width: 767px)',
- media: query,
- onchange: null,
- addListener: () => {},
- removeListener: () => {},
- addEventListener: () => {},
- removeEventListener: () => {},
- dispatchEvent: () => false,
- }) as MediaQueryList
- vi.stubGlobal('matchMedia', mql)
- })
-
- it('renders as a bottom-sheet dialog with a drag handle at 50dvh', async () => {
- renderInspector()
- const dialog = await screen.findByRole('dialog')
- expect(dialog).toHaveStyle({ height: '50dvh' })
- expect(screen.getByTestId('sheet-handle')).toBeInTheDocument()
- })
-
- it('drag up snaps the sheet to 92dvh', async () => {
- renderInspector()
- const dialog = await screen.findByRole('dialog')
- const handle = screen.getByTestId('sheet-handle')
- // jsdom innerHeight = 768 → threshold 115px; -200px is a real drag up
- fireEvent.pointerDown(handle, { pointerId: 1, clientY: 600 })
- fireEvent.pointerMove(handle, { pointerId: 1, clientY: 500 })
- fireEvent.pointerUp(handle, { pointerId: 1, clientY: 400 })
- expect(dialog).toHaveStyle({ height: '92dvh' })
- })
-
- it('swipe down past the threshold dismisses the sheet', async () => {
- renderInspector()
- await screen.findByRole('dialog')
- const handle = screen.getByTestId('sheet-handle')
- fireEvent.pointerDown(handle, { pointerId: 1, clientY: 300 })
- fireEvent.pointerMove(handle, { pointerId: 1, clientY: 450 })
- fireEvent.pointerUp(handle, { pointerId: 1, clientY: 600 })
- await waitFor(() =>
- expect(screen.queryByRole('dialog')).not.toBeInTheDocument(),
- )
- })
-
- it('a small drag stays at the current snap', async () => {
- renderInspector()
- const dialog = await screen.findByRole('dialog')
- const handle = screen.getByTestId('sheet-handle')
- fireEvent.pointerDown(handle, { pointerId: 1, clientY: 300 })
- fireEvent.pointerUp(handle, { pointerId: 1, clientY: 330 })
- expect(dialog).toHaveStyle({ height: '50dvh' })
- })
- })
})
diff --git a/ui/src/components/map/FlowMap.module.css b/ui/src/components/map/FlowMap.module.css
index a8d28bd..90400b7 100644
--- a/ui/src/components/map/FlowMap.module.css
+++ b/ui/src/components/map/FlowMap.module.css
@@ -1,332 +1,190 @@
+/*
+ * FlowMap — React Flow (@xyflow/react v12) themed to the OtelContext dark token
+ * system. FlowMap.tsx imports React Flow's base stylesheet
+ * (@xyflow/react/dist/style.css); the rules here (a) theme React Flow's own DOM
+ * (background grid, panes, minimap, handles) to our --bg-inset instrument
+ * surface and (b) style the custom node/edge classes the component renders.
+ *
+ * Token-only colours (tokens.css). All motion is keyed off --dur-* tokens, which
+ * the global prefers-reduced-motion block zeros — so reduced motion collapses
+ * every transition/animation here without a local media query.
+ */
+
+/* ---- the wrapper that holds the whole map ---- */
.container {
- position: relative;
flex: 1;
+ position: relative;
min-height: 0;
+ min-width: 0;
border: 1px solid var(--stroke-1);
border-radius: var(--radius-2);
background: var(--bg-inset);
+ /* React Flow's
draws the grid — no CSS background-image here. */
overflow: hidden;
}
-.container:focus-visible {
- outline: 2px solid var(--accent);
- outline-offset: -2px;
-}
-
-.svg {
- display: block;
- position: relative;
- z-index: 1;
- width: 100%;
- height: 100%;
- /* pan/pinch are ours — but ONLY on the SVG, never the page */
- touch-action: none;
- cursor: grab;
-}
-
-.svg:active {
- cursor: grabbing;
-}
-
-/* ---- nodes ---- */
-.node {
+/* ---- custom node: the token-themed status chip ---- */
+/* Outline-led: the FULL status hue (passed inline as --node-color) carries the
+ * border; the fill is the opaque inset surface so edges + the grid pass BEHIND
+ * the chip and never bleed through the label. Reads as a status-coloured
+ * outline against the field. */
+.chip {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ padding: 0 10px;
+ border: 1.5px solid var(--node-color);
+ border-radius: var(--radius-2);
+ background: var(--bg-inset);
+ font-size: var(--text-xs);
+ color: var(--text-1);
cursor: pointer;
- /* Placement is a CSS transform (style.transform) so it can interpolate:
- * living physics travels nodes between polls, and the status-tier gravity
- * offset eases on a tier flip. transform-box/origin make the local SVG
- * coordinate frame the basis so translate() reads in user units. */
- transform-box: fill-box;
- transform-origin: 0 0;
- transition:
- transform var(--dur-3) var(--ease),
- opacity var(--dur-2) var(--ease);
-}
-
-.nodeRect {
- /* Status-tinted raised surface + a status-weighted border so a node reads as
- * a solid chip in BOTH themes (on light, a flat white --bg-raised on the
- * --bg-inset canvas with a --stroke-2 hairline was all but invisible).
- * --node-color is the
- * status hue (var(--crit)/--warn/--ok/--unknown); mixing a sliver into the
- * fill and a strong share into the stroke keeps saturation rationed to status
- * while making the box legible against the canvas. */
- fill: color-mix(in srgb, var(--node-color) 12%, var(--bg-raised));
- stroke: color-mix(in srgb, var(--node-color) 60%, var(--stroke-2));
- stroke-width: 1.5;
- /* Scale lifts in the rect's OWN local box (fill-box), so the selection
- * lift composes cleanly with the 's inline layout translate instead of
- * fighting it. GPU transform only — no filter/shadow. */
- transform-box: fill-box;
- transform-origin: center;
+ box-sizing: border-box;
+ /* GPU transforms + border/shadow only — no SVG filter/blur. */
transition:
- stroke var(--dur-2) var(--ease),
- transform var(--dur-3) var(--ease);
-}
-
-@media (hover: hover) {
- .node:hover .nodeRect {
- stroke: var(--text-3);
- }
-}
-
-.nodeSelected {
- stroke: var(--accent);
- stroke-width: 2;
+ border-color var(--dur-2) var(--ease),
+ box-shadow var(--dur-2) var(--ease),
+ transform var(--dur-2) var(--ease);
}
-/* Select → focus-distortion: the inspected node lifts (1.0→1.015) as the field
- * dims around it — the constellation's hero interaction, matched to the
- * ServiceRow fallback. Travel is keyed off --dur-3, so the global reduced-motion
- * block (zeros --dur-*) collapses the travel while this static end-state (and
- * the accent ring above) is preserved. */
-.node[data-selected] .nodeRect {
+/* Inspected node: accent ring + a small lift. The lift travels on --dur-2, so
+ * reduced motion (dur 0) preserves the static accent end-state. */
+.selected {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 1px var(--accent);
transform: scale(1.015);
-}
-
-.nodeFocused {
- stroke: var(--accent);
- stroke-width: 2;
- stroke-dasharray: 4 3;
-}
-
-/* Anomaly prominence — failing services dominate the field. A fuller --crit
- * fill + a heavier border makes a critical chip read at a glance, in both
- * themes. Only FILL + WIDTH are set (not stroke COLOR), so the accent
- * selection/focus ring still wins when a critical node is inspected. */
-.node[data-status='critical'] .nodeRect {
- fill: color-mix(in srgb, var(--node-color) 24%, var(--bg-raised));
- stroke-width: 2;
-}
-
-.node[data-status='degraded'] .nodeRect {
- fill: color-mix(in srgb, var(--node-color) 16%, var(--bg-raised));
-}
-
-/* The pulse beacon: a --crit halo the size of the chip that flashes then
- * expands+fades outward — a glance answers "what's failing now". Behind the
- * chip; pure opacity/scale (no SVG filter/blur). Static end-state is invisible
- * (opacity 0), so reduced motion simply shows the stronger crit chip above. */
-.nodePulse {
- fill: none;
- stroke: var(--crit);
- stroke-width: 2;
- opacity: 0;
- pointer-events: none;
- transform-box: fill-box;
transform-origin: center;
}
-@media (prefers-reduced-motion: no-preference) {
- .node[data-status='critical'] .nodePulse {
- animation: node-pulse 1.8s var(--ease) infinite;
- }
-
- /* Pause with the rest of the ambient motion when the tab is backgrounded. */
- .container[data-visible='false'] .nodePulse {
- animation-play-state: paused;
- }
-}
-
-/* Battery: no per-node pulse on coarse pointers (phones). The static crit chip
- * carries the signal there. */
-@media (pointer: coarse) {
- .nodePulse {
- animation: none;
- }
-}
-
-@keyframes node-pulse {
- 0% {
- opacity: 0.55;
- transform: scale(1);
- }
- 70% {
- opacity: 0;
- transform: scale(1.25);
- }
- 100% {
- opacity: 0;
- transform: scale(1.25);
- }
-}
-
-/* Blast-radius cone (?impact=): --crit tint over the node fill, strength
- * by depth via inline fillOpacity (lib/impact.impactAlpha). */
-.nodeImpact {
- fill: var(--crit);
- pointer-events: none;
-}
-
-.nodeImpactRoot {
- stroke: var(--crit);
- stroke-width: 2;
-}
-
-.nodeDot {
- fill: var(--node-color);
- /* Status tone interpolates between polls on a tier change. */
- transition: fill var(--dur-2) var(--ease);
-}
-
-/* Galaxy marker: in a large, zoomed-out field each un-surfaced service renders
- * as a solid status-colored dot instead of a chip, so ~120 services read as a
- * dense even disc rather than a smear of overlapping labels. Centered on the
- * node, so edges connect at the same point as the chip. A hairline keeps the
- * dot crisp on both themes; the selection/focus/impact stroke classes (shared
- * with the chip) override it when the node is emphasized. */
-.nodeMarker {
- fill: var(--node-color);
- stroke: color-mix(in srgb, var(--node-color) 55%, var(--stroke-2));
- stroke-width: 1;
- transition:
- fill var(--dur-2) var(--ease),
- stroke var(--dur-2) var(--ease);
+/* Field-wide focus-distortion: non-neighbour fade. */
+.dimmed {
+ opacity: 0.5;
+}
+
+/* Blast-radius root (?impact=): same accent emphasis as the inspected node. */
+.impactRoot {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 1px var(--accent);
+}
+
+/* Hidden centred handle: edges attach here, but FloatingEdge draws
+ * centre-to-centre so position is cosmetic. Must visually disappear AND not
+ * eat clicks. !important beats React Flow's base .react-flow__handle (a 6×6
+ * #333/#141414 dot with a border). */
+.handle {
+ position: absolute !important;
+ left: 50% !important;
+ top: 50% !important;
+ width: 1px !important;
+ height: 1px !important;
+ min-width: 0 !important;
+ min-height: 0 !important;
+ border: 0 !important;
+ background: transparent !important;
+ opacity: 0 !important;
+ pointer-events: none !important;
+ transform: translate(-50%, -50%);
}
-@media (hover: hover) {
- .node:hover .nodeMarker {
- stroke: var(--text-3);
- }
+/* Status dot — the same hue as the chip outline. */
+.dot {
+ flex: none;
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: var(--node-color);
}
-.nodeName {
- fill: var(--text-1);
+/* Service name — mono, truncates with an ellipsis when the chip is too narrow. */
+.name {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
font-family: var(--font-mono);
- font-size: var(--text-xs);
- pointer-events: none;
+ color: var(--text-1);
}
-.nodeErr {
- fill: var(--text-2);
+/* Error-rate suffix — mono, tabular so digits line up across chips. */
+.err {
+ flex: none;
font-family: var(--font-mono);
font-size: var(--text-2xs);
- text-anchor: end;
- pointer-events: none;
+ font-variant-numeric: tabular-nums;
+ color: var(--text-2);
}
-/* ---- edges ---- */
+/* ---- custom edge: FloatingEdge renders a raw ---- */
+/* Hairline link, legible on both themes against the field. --edge-w is set
+ * inline from the call count. */
.edge {
fill: none;
- stroke: var(--stroke-2);
- stroke-width: var(--edge-w, 1);
- stroke-linecap: round;
- transition: opacity var(--dur-2) var(--ease);
+ stroke: var(--text-3);
+ stroke-width: var(--edge-w, 1.5);
+ opacity: 0.75;
}
+/* Failing edge: --crit, full strength, marching dashes that drain toward the
+ * target. The march runs on calc(var(--dur-3) * 6) — so reduced motion
+ * (--dur-3: 0ms) yields a 0s duration and the dashes stop, leaving a static
+ * red dashed line. */
.edgeFailing {
stroke: var(--crit);
+ opacity: 1;
+ stroke-dasharray: 4 4;
+ animation: march calc(var(--dur-3) * 6) linear infinite;
}
-/* Animated dashes ONLY on failing edges AND only when motion is allowed. The
- * march flows toward the path end (source→target); core-ward edges reverse it
- * so failure always reads as draining inward to the core. Paused when the tab
- * is hidden (visibilitychange-gated via data-visible on .container). */
-@media (prefers-reduced-motion: no-preference) {
- .edgeFailing {
- stroke-dasharray: 7 5;
- animation: edge-dash 1s linear infinite;
- }
-
- .edgeFailing[data-coreward='reverse'] {
- animation-direction: reverse;
- }
-
- .container[data-visible='false'] .edgeFailing {
- animation-play-state: paused;
- }
-}
-
-/* Ambient motion is a desktop affordance only: on coarse pointers (phones,
- * the pinch-zoom surface) the marching dashes are battery cost with no hover
- * context, so the failing edge renders as a static --crit stroke. */
-@media (pointer: coarse) {
- .edgeFailing {
- animation: none;
- }
-}
-
-@keyframes edge-dash {
+@keyframes march {
to {
- stroke-dashoffset: -12;
+ /* one full dash + gap (4 + 4) → seamless loop. */
+ stroke-dashoffset: -8;
}
}
-/* Selection: 1-hop neighborhood stays at full opacity, the rest dims. */
-.dimmed {
- opacity: 0.35;
-}
-
-/* ---- radial dial backdrop ---- */
-.dial {
- pointer-events: none;
-}
+/* ============================================================================
+ * React Flow base overrides (file-scope :global — these target React Flow's
+ * OWN DOM, which is a sibling of our chips, not nested under any module class).
+ * ==========================================================================*/
-/* Concentric ring guides — the instrument graticule, in dial form. Faint
- * hairlines so numerals/nodes stay the focus. */
-.ring {
- fill: none;
- stroke: var(--stroke-1);
- stroke-width: 1;
- transition: stroke-opacity var(--dur-3) var(--ease);
+/* Let our --bg-inset container surface show through React Flow's root. */
+:global(.react-flow) {
+ background: transparent;
}
-/* ---- cinematic depth (ringDepth-gated) ---- */
-/* Inner rings read bright, outer rings recede — a cheap parallax-of-opacity
- * depth cue. No SVG filters/blur. --ring-depth is 0 (inner) .. 1 (outer). */
-.depth .ring {
- stroke-opacity: calc(1 - 0.7 * var(--ring-depth, 0));
+/* Our .chip fully owns the node look — neutralise React Flow's node wrapper
+ * defaults (padding/border/background/radius) so they can't fight it. */
+:global(.react-flow__node) {
+ padding: 0;
+ border: 0;
+ border-radius: 0;
+ background: transparent;
+ color: var(--text-1);
+ font-family: var(--font-sans);
}
-/* ONE shared radial-gradient behind the pinned core. position:absolute HTML
- * sibling of the ; pointer-events:none so it never eats clicks. Static
- * end-state, so reduced-motion is a no-op here. */
-.depth::before {
- content: '';
- position: absolute;
- inset: 0;
- pointer-events: none;
- background: radial-gradient(
- closest-side at 50% 50%,
- var(--accent-edge) 0%,
- transparent 70%
- );
- z-index: 0;
+/* Drop React Flow's default node focus box; keep a visible a11y ring on the
+ * chip via :focus-visible (nodes are focusable={true} in FlowMap.tsx). */
+:global(.react-flow__node):focus,
+:global(.react-flow__node):focus-visible {
+ outline: none;
+ box-shadow: none;
}
-
-/* Health core: numerals are the source of truth, centered on the dial. */
-.coreLabel {
- fill: var(--text-1);
- font-family: var(--font-mono);
- font-variant-numeric: tabular-nums;
- font-size: var(--text-lg);
- text-anchor: middle;
+:global(.react-flow__node):focus-visible .chip {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
}
-.coreUnit {
- fill: var(--text-3);
- font-family: var(--font-mono);
- font-size: var(--text-2xs);
- letter-spacing: 0.06em;
- text-anchor: middle;
+/* ---- grid: renders an SVG of s
+ * inside .react-flow__background. Colour those lines a subtle --stroke-1 (and
+ * set the lib's pattern-colour var as a belt-and-braces fallback). ---- */
+.grid {
+ --xy-background-pattern-color: var(--stroke-1);
}
-
-/* Health Core overlay: an absolutely-centered HTML sibling of the ,
- * PINNED at viewport center while the field pans/zooms behind it. The host
- * provides the actual gauge/odometer content; this just centers it and lifts
- * it above the glow. Children opt back into pointer events as needed. */
-.core {
- position: absolute;
- top: 50%;
- left: 50%;
- z-index: 1;
- transform: translate(-50%, -50%);
- pointer-events: none;
- display: flex;
- flex-direction: column;
- align-items: center;
- text-align: center;
+.grid :global(.react-flow__background-pattern path),
+.grid :global(.react-flow__background-pattern) path {
+ stroke: var(--stroke-1);
+ stroke-width: 1;
}
-.core > * {
- pointer-events: auto;
-}
diff --git a/ui/src/components/map/FlowMap.tsx b/ui/src/components/map/FlowMap.tsx
index 595d364..1c3667f 100644
--- a/ui/src/components/map/FlowMap.tsx
+++ b/ui/src/components/map/FlowMap.tsx
@@ -1,45 +1,45 @@
import {
useCallback,
- useEffect,
useImperativeHandle,
useMemo,
- useRef,
- useState,
type CSSProperties,
type ReactNode,
type Ref,
} from 'react'
import {
- NODE_H,
- NODE_W,
- compareIds,
- edgePath,
- edgeWidth,
- fitTransform,
- layoutGraph,
- neighborsOf,
- walkFrom,
- zoomAt,
- type GraphEdgeRef,
- type Layout,
- type LayoutNode,
- type Transform,
- type WalkDir,
-} from '@/lib/dagLayout'
-import { layoutRadial, walkRadial } from '@/lib/radialLayout'
+ Background,
+ BackgroundVariant,
+ Handle,
+ Position,
+ ReactFlow,
+ ReactFlowProvider,
+ getStraightPath,
+ useInternalNode,
+ useReactFlow,
+ type Edge,
+ type EdgeProps,
+ type EdgeTypes,
+ type InternalNode,
+ type Node,
+ type NodeProps,
+ type NodeTypes,
+} from '@xyflow/react'
+import '@xyflow/react/dist/style.css'
+import { NODE_H, NODE_W, compareIds, edgeWidth, neighborsOf, type GraphEdgeRef } from '@/lib/dagLayout'
+import { layoutRadial } from '@/lib/radialLayout'
import { formatPercent } from '@/lib/format'
-import { impactAlpha } from '@/lib/impact'
import { nodeStatus, statusToken } from '@/lib/triage'
import type { SystemEdge, SystemNode } from '@/types/api'
import styles from './FlowMap.module.css'
-export type LayoutMode = 'radial' | 'dag'
+// React Flow service map. We keep our OWN deterministic radial layout
+// (radialLayout.ts — phyllotaxis, no physics) and feed those fixed positions to
+// React Flow, which owns the boilerplate: pan / zoom / pinch, fit-view, the grid
+// background, the minimap and a11y focus. Nodes are React components, so the
+// token-themed status chips port over directly; edges are straight, drawn
+// centre-to-centre (chips are opaque, so the segment under a chip is occluded).
-// SVG flow map. Layout is recomputed ONLY when the node/edge SET hash
-// changes — metric-only poll updates just re-render fills/widths through
-// CSS variables on stable positions. Pan/zoom is a CSS transform on the
-// inner ; wheel/pinch/drag never trigger React layout work beyond one
-// state update.
+export type LayoutMode = 'radial' | 'dag'
export interface FlowMapHandle {
/** Fit the whole graph into the current viewport. */
@@ -49,520 +49,232 @@ export interface FlowMapHandle {
interface FlowMapProps {
nodes: readonly SystemNode[]
edges: readonly SystemEdge[]
- /** Layout paradigm: radial gravity constellation (default) or layered DAG. */
+ /** Layout paradigm. Only 'radial' is used today; kept for API stability. */
mode?: LayoutMode
/** The inspected service — accent ring + 1-hop neighborhood emphasis. */
selectedId: string | null
/**
- * Blast-radius cone (?impact=): service → BFS depth, root at 0. When set
- * it owns the emphasis — cone nodes tint --crit by depth, the rest dims.
+ * Blast-radius cone (?impact=): service → BFS depth, root at 0. When set it
+ * owns the emphasis — cone nodes tint by depth, the rest dims.
*/
impact?: ReadonlyMap | null
onSelect: (id: string) => void
onClearSelection: () => void
- onFocusFilter: () => void
- /**
- * Health Core overlay — an absolutely-centered HTML sibling of the
- * (NOT a foreignObject), so HTML gauges/odometers compose unchanged and the
- * core stays PINNED at viewport center while the field pans/zooms behind it.
- * When undefined, the in- coreLabel/coreUnit fallback renders.
- */
+ /** Reserved (palette filter). No-op in the React Flow map. */
+ onFocusFilter?: () => void
+ /** Reserved overlay slots — kept for API stability, unused here. */
core?: ReactNode
- /**
- * In- center health-count fallback (the "X/Y HEALTHY" text). Defaults
- * on; pass false to keep the dial center FREE (e.g. when health lives in a
- * corner card and the map owns the center). Independent of `core`.
- */
centerLabel?: boolean
- /**
- * Field-wide focus-distortion: non-neighbor fade. Defaults to
- * `selectedId != null` to preserve the current selection-dims behavior.
- */
+ /** Field-wide focus-distortion: non-neighbor fade. Defaults to selected!=null. */
dim?: boolean
- /** Opt-in cinematic ring-depth + core glow. Default flat (false). */
ringDepth?: boolean
ref?: Ref
}
-const KEY_DIRS: Record = {
- ArrowLeft: 'left',
- ArrowRight: 'right',
- ArrowUp: 'up',
- ArrowDown: 'down',
+type Emphasis = 'normal' | 'selected' | 'neighbor' | 'dim'
+
+interface ServiceNodeData {
+ node: SystemNode
+ emphasis: Emphasis
+ impactDepth: number | null
+ [key: string]: unknown
}
+type ServiceNode = Node
-/** Threshold for semantic zoom: err% labels appear at and above this scale. */
-export const SEMANTIC_ZOOM = 0.8
+const FIT_OPTIONS = { padding: 0.18 } as const
-/** Below this zoom a large field hides node names to avoid label pile-up at
- * ~120 services; small fields always label (and the selected/focused/neighbor
- * nodes always label regardless of zoom). */
-export const NAME_ZOOM = 0.5
-const NAME_ALWAYS_MAX = 24
+/** Centre of an internal node in flow coordinates. */
+function nodeCentre(n: InternalNode): { x: number; y: number } {
+ return {
+ x: n.internals.positionAbsolute.x + (n.measured?.width ?? NODE_W) / 2,
+ y: n.internals.positionAbsolute.y + (n.measured?.height ?? NODE_H) / 2,
+ }
+}
-/** Radius of the status-dot marker rendered for un-labelled nodes in a large,
- * zoomed-out field — the "galaxy" shape. Centered on the node so edges connect
- * identically whether the node draws as a dot or a chip. */
-const DOT_R = 16
+/** The token-themed status chip — a React Flow custom node. */
+function ServiceMapNode({ data }: NodeProps) {
+ const { node, emphasis, impactDepth } = data
+ const status = nodeStatus(node.status)
+ const cls = [
+ styles.chip,
+ emphasis === 'selected' && styles.selected,
+ emphasis === 'dim' && styles.dimmed,
+ impactDepth === 0 && styles.impactRoot,
+ ]
+ .filter(Boolean)
+ .join(' ')
+ return (
+
+ {/* Hidden centred handles — edges attach here; the floating edge draws
+ centre-to-centre regardless, so position is cosmetic. */}
+
+
+
+ {node.id}
+ {formatPercent(node.metrics.error_rate)}
+
+ )
+}
-/** Fit padding (px) and the small-field legibility floor for the initial fit. */
-const FIT_PADDING = 24
-const FIT_FLOOR = 0.5
-const FIT_FLOOR_MAX_NODES = 24
+/** Straight edge drawn centre-to-centre between the two node boxes. */
+function FloatingEdge({ source, target, data }: EdgeProps) {
+ const s = useInternalNode(source)
+ const t = useInternalNode(target)
+ if (!s || !t) return null
+ const a = nodeCentre(s)
+ const b = nodeCentre(t)
+ const [d] = getStraightPath({ sourceX: a.x, sourceY: a.y, targetX: b.x, targetY: b.y })
+ const failing = Boolean(data?.failing)
+ return (
+
+ )
+}
-const IDENTITY: Transform = { x: 0, y: 0, k: 1 }
+const NODE_TYPES: NodeTypes = { service: ServiceMapNode }
+const EDGE_TYPES: EdgeTypes = { floating: FloatingEdge }
function isEdgeFailing(status: string | undefined): boolean {
return status === 'critical' || status === 'failing'
}
-/** Center of a node box in layout coordinates. */
-function nodeCenter(n: LayoutNode): { x: number; y: number } {
- return { x: n.x + NODE_W / 2, y: n.y + NODE_H / 2 }
-}
-
-/**
- * Edge path bowed along the dial: a quadratic whose control point is pushed
- * outward, perpendicular to the chord, so dependencies arc around the center
- * instead of cutting straight across it. Endpoints sit on the node-box edges
- * facing each other. Deterministic — no randomness, derived only from coords.
- */
-function radialEdgePath(from: LayoutNode, to: LayoutNode, cx: number, cy: number): string {
- const a = nodeCenter(from)
- const b = nodeCenter(to)
- const mx = (a.x + b.x) / 2
- const my = (a.y + b.y) / 2
- // Bow the control point away from the dial center (outward), so arcs hug
- // their wedge rather than crossing the core. Magnitude scales with chord
- // length but is capped so short links stay nearly straight.
- let nx = mx - cx
- let ny = my - cy
- const nlen = Math.hypot(nx, ny) || 1
- nx /= nlen
- ny /= nlen
- const chord = Math.hypot(b.x - a.x, b.y - a.y)
- const bow = Math.min(chord * 0.18, 60)
- const ctrlX = mx + nx * bow
- const ctrlY = my + ny * bow
- return `M ${a.x} ${a.y} Q ${ctrlX} ${ctrlY} ${b.x} ${b.y}`
-}
-
-export default function FlowMap({
+function FlowMapInner({
nodes,
edges,
- mode = 'radial',
selectedId,
- impact = null,
+ impact,
+ dim,
onSelect,
onClearSelection,
- onFocusFilter,
- core,
- centerLabel = true,
- dim,
- ringDepth = false,
ref,
-}: Readonly) {
- const containerRef = useRef(null)
- const svgRef = useRef(null)
- const [transform, setTransform] = useState(IDENTITY)
- const [focusedId, setFocusedId] = useState(null)
+}: Readonly<
+ Pick
+>) {
+ const { fitView } = useReactFlow()
+ useImperativeHandle(ref, () => ({ fit: () => void fitView(FIT_OPTIONS) }), [fitView])
- // ---- layout, memoized on the SET hash (never on metric churn) ----------
- // The set hash is a JSON-decodable key: the layout memo depends on the key
- // alone and reconstructs its inputs from it, so a metrics-only poll (new
- // array identities, same sets) provably cannot trigger a re-layout.
const edgeRefs: GraphEdgeRef[] = useMemo(
() => edges.map((e) => ({ source: e.source, target: e.target })),
[edges],
)
- const shapeKey = useMemo(
- () =>
- JSON.stringify({
- ids: [...new Set(nodes.map((n) => n.id))].sort(compareIds),
- edges: edgeRefs
- .map((e): [string, string] => [e.source, e.target])
- .sort((a, b) => compareIds(a[0] + a[1], b[0] + b[1])),
- }),
- [nodes, edgeRefs],
- )
- const layout: Layout = useMemo(() => {
- const shape = JSON.parse(shapeKey) as { ids: string[]; edges: [string, string][] }
- const refs = shape.edges.map(([source, target]) => ({ source, target }))
- return mode === 'radial' ? layoutRadial(shape.ids, refs) : layoutGraph(shape.ids, refs)
- }, [shapeKey, mode])
- // ---- fit ----------------------------------------------------------------
- const fit = useCallback(() => {
- const rect = containerRef.current?.getBoundingClientRect()
- if (!rect || rect.width <= 0 || rect.height <= 0) return
- // Legibility floor for SMALL fields: contain-fit on a narrow phone viewport
- // shrinks a few nodes to dust AND lets the fixed-size pinned core swallow the
- // inner ring. A floor keeps nodes readable and the core clear; the field then
- // overflows the narrow axis and is reached by pan/pinch. Large graphs (which
- // must zoom out to be seen whole) skip the floor.
- const minScale = layout.nodes.size > 0 && layout.nodes.size <= FIT_FLOOR_MAX_NODES ? FIT_FLOOR : 0
- setTransform(fitTransform(layout, rect, FIT_PADDING, minScale))
- }, [layout])
+ // Positions: recomputed ONLY when the node/edge SET changes (deterministic
+ // radial layout). Metric-only polls reuse these via the data memo below.
+ const shapeKey = useMemo(() => {
+ const ids = nodes.map((n) => n.id).sort(compareIds)
+ const es = edgeRefs.map((e) => `${e.source}->${e.target}`).sort((a, b) => a.localeCompare(b))
+ return JSON.stringify({ ids, es })
+ }, [nodes, edgeRefs])
+ const positions = useMemo(() => {
+ const { ids } = JSON.parse(shapeKey) as { ids: string[] }
+ return layoutRadial(ids, edgeRefs).nodes
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [shapeKey])
- useImperativeHandle(ref, () => ({ fit }), [fit])
-
- // Initial fit, re-run when the node/edge set changes shape.
- useEffect(() => {
- fit()
- }, [fit])
-
- // ---- wheel zoom (native listener — React's is passive) ------------------
- useEffect(() => {
- const svg = svgRef.current
- if (!svg) return
- const onWheel = (e: WheelEvent) => {
- e.preventDefault()
- const rect = svg.getBoundingClientRect()
- const factor = Math.exp(-e.deltaY * 0.0015)
- setTransform((t) => zoomAt(t, e.clientX - rect.left, e.clientY - rect.top, factor))
- }
- svg.addEventListener('wheel', onWheel, { passive: false })
- return () => svg.removeEventListener('wheel', onWheel)
- }, [])
-
- // ---- pointer pan + pinch -------------------------------------------------
- const pointers = useRef(new Map())
- const pinchDist = useRef(0)
-
- const onPointerDown = useCallback((e: React.PointerEvent) => {
- e.currentTarget.setPointerCapture(e.pointerId)
- pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY })
- if (pointers.current.size === 2) {
- const [a, b] = [...pointers.current.values()]
- pinchDist.current = Math.hypot(a.x - b.x, a.y - b.y)
- }
- }, [])
+ const dimField = dim ?? selectedId !== null
+ const neighbors = useMemo(
+ () => (selectedId ? neighborsOf(edgeRefs, selectedId) : null),
+ [edgeRefs, selectedId],
+ )
- const onPointerMove = useCallback((e: React.PointerEvent) => {
- const prev = pointers.current.get(e.pointerId)
- if (!prev) return
- const next = { x: e.clientX, y: e.clientY }
- pointers.current.set(e.pointerId, next)
- if (pointers.current.size === 1) {
- setTransform((t) => ({ ...t, x: t.x + next.x - prev.x, y: t.y + next.y - prev.y }))
- return
- }
- if (pointers.current.size === 2) {
- const [a, b] = [...pointers.current.values()]
- const dist = Math.hypot(a.x - b.x, a.y - b.y)
- if (pinchDist.current > 0) {
- const rect = e.currentTarget.getBoundingClientRect()
- const cx = (a.x + b.x) / 2 - rect.left
- const cy = (a.y + b.y) / 2 - rect.top
- const factor = dist / pinchDist.current
- setTransform((t) => zoomAt(t, cx, cy, factor))
+ const rfNodes: ServiceNode[] = useMemo(() => {
+ return nodes.map((n) => {
+ const p = positions.get(n.id)
+ const depth = impact?.get(n.id)
+ let emphasis: Emphasis = 'normal'
+ if (impact) emphasis = depth === undefined ? 'dim' : 'normal'
+ else if (selectedId === n.id) emphasis = 'selected'
+ else if (neighbors?.has(n.id)) emphasis = 'neighbor'
+ else if (dimField) emphasis = 'dim'
+ return {
+ id: n.id,
+ type: 'service',
+ position: { x: p?.x ?? 0, y: p?.y ?? 0 },
+ data: { node: n, emphasis, impactDepth: depth ?? null },
+ draggable: false,
+ selectable: false,
+ connectable: false,
+ focusable: true,
}
- pinchDist.current = dist
- }
- }, [])
-
- const onPointerEnd = useCallback((e: React.PointerEvent) => {
- pointers.current.delete(e.pointerId)
- pinchDist.current = 0
- }, [])
+ })
+ }, [nodes, positions, selectedId, neighbors, impact, dimField])
- // ---- ambient-motion gate: pause animations when the tab is hidden --------
- // The ONLY physics-side effect. Toggles a data attribute the CSS keys
- // `animation-play-state` off, so the failing-edge march (and any future
- // ambient motion) stops costing frames in a backgrounded tab. No rAF loop.
- useEffect(() => {
- const el = containerRef.current
- if (!el) return
- const sync = () => {
- el.dataset.visible = document.visibilityState === 'visible' ? 'true' : 'false'
- }
- sync()
- document.addEventListener('visibilitychange', sync)
- return () => document.removeEventListener('visibilitychange', sync)
- }, [])
-
- // ---- keyboard walking ----------------------------------------------------
- const onKeyDown = useCallback(
- (e: React.KeyboardEvent) => {
- const dir = KEY_DIRS[e.key]
- if (dir) {
- e.preventDefault()
- setFocusedId((cur) => {
- const from = cur ?? selectedId
- const next =
- mode === 'radial'
- ? walkRadial(layout, from, dir)
- : walkFrom(layout, edgeRefs, from, dir)
- return next ?? cur
- })
- return
- }
- if (e.key === 'Enter' && focusedId) {
- e.preventDefault()
- onSelect(focusedId)
- } else if (e.key === 'Escape') {
- setFocusedId(null)
- onClearSelection()
- } else if (e.key === 'f') {
- e.preventDefault()
- fit()
- } else if (e.key === '/') {
- e.preventDefault()
- onFocusFilter()
- }
- },
- [layout, edgeRefs, mode, selectedId, focusedId, onSelect, onClearSelection, onFocusFilter, fit],
+ const rfEdges: Edge[] = useMemo(
+ () =>
+ edges.map((e) => ({
+ id: `${e.source}->${e.target}`,
+ source: e.source,
+ target: e.target,
+ type: 'floating',
+ data: { failing: isEdgeFailing(e.status), w: edgeWidth(e.call_count) },
+ })),
+ [edges],
)
- const neighbors = selectedId ? neighborsOf(edgeRefs, selectedId) : null
- const showErrLabels = transform.k >= SEMANTIC_ZOOM
- const showNames = transform.k >= NAME_ZOOM || nodes.length <= NAME_ALWAYS_MAX
- const impactOn = impact !== null
- const isRadial = mode === 'radial'
- // Field-wide focus-distortion. `dim` defaults to "something is selected" so
- // existing callers keep their selection-dims; a caller can force it off
- // (e.g. a cinematic idle home) by passing dim={false}. Impact overlay always
- // owns its own dimming regardless.
- const dimField = dim ?? selectedId !== null
-
- // Radial backdrop geometry: concentric ring guides at each layer radius and a
- // health-core label at center. Derived from the layout (which already encodes
- // ring spacing) so it stays in lockstep.
- const cx = layout.width / 2
- const cy = layout.height / 2
- const ringRadii = useMemo(() => {
- if (!isRadial) return []
- // One faint guide circle per pseudo-ring band (node.layer), at the band's
- // mean node radius. The phyllotaxis disc gives every node a distinct radius,
- // so guiding off the bands draws ceil(sqrt(n)) rings instead of ~n.
- const acc = new Map()
- for (const n of layout.nodes.values()) {
- const r = Math.hypot(n.x + NODE_W / 2 - cx, n.y + NODE_H / 2 - cy)
- const a = acc.get(n.layer) ?? { total: 0, count: 0 }
- a.total += r
- a.count += 1
- acc.set(n.layer, a)
- }
- return [...acc.entries()]
- .sort((p, q) => p[0] - q[0])
- .map(([, a]) => Math.round(a.total / a.count))
- }, [isRadial, layout, cx, cy])
+ const onNodeClick = useCallback(
+ (_: unknown, n: Node) => onSelect(n.id),
+ [onSelect],
+ )
- // Health-core label: count of nodes still healthy vs total (numerals are the
- // source of truth; the dial is decoration).
- // Only the in- fallback core label consumes this. Skip the O(n) scan
- // entirely on the live (core-provided) canvas, and memoize so pan/zoom
- // re-renders don't re-scan up to ~720 nodes per frame.
- const healthyCount = useMemo(
- () =>
- core === undefined && centerLabel
- ? nodes.filter((n) => nodeStatus(n.status) === 'healthy').length
- : 0,
- [core, centerLabel, nodes],
+ return (
+
+
+
)
+}
+/** Mounts inside its own ReactFlowProvider so the fit() ref handle works. */
+export default function FlowMap({
+ nodes,
+ edges,
+ selectedId,
+ impact,
+ dim,
+ onSelect,
+ onClearSelection,
+ ref,
+}: Readonly) {
return (
-
-
- {isRadial && (
-
- {ringRadii.map((r, i) => (
- 1 ? i / (ringRadii.length - 1) : 0,
- } as CSSProperties
- }
- />
- ))}
- {core === undefined && centerLabel && (
- <>
-
- {`${healthyCount}/${nodes.length}`}
-
-
- HEALTHY
-
- >
- )}
-
- )}
- {edges.map((e) => {
- const from = layout.nodes.get(e.source)
- const to = layout.nodes.get(e.target)
- if (!from || !to || e.source === e.target) return null
- const failing = isEdgeFailing(e.status)
- const dimmed = impactOn
- ? !(impact.has(e.source) && impact.has(e.target))
- : dimField &&
- neighbors !== null &&
- e.source !== selectedId &&
- e.target !== selectedId
- // March failing dashes core-ward. The path runs source→target, so
- // the default offset animation flows that way; reverse it when the
- // source is the endpoint nearer the core (target is outward).
- const coreward =
- failing && isRadial
- ? Math.hypot(nodeCenter(from).x - cx, nodeCenter(from).y - cy) <
- Math.hypot(nodeCenter(to).x - cx, nodeCenter(to).y - cy)
- : false
- return (
- ${e.target}`}
- d={isRadial ? radialEdgePath(from, to, cx, cy) : edgePath(from, to)}
- data-coreward={coreward ? 'reverse' : undefined}
- className={`${styles.edge} ${failing ? styles.edgeFailing : ''} ${dimmed ? styles.dimmed : ''}`}
- style={{ '--edge-w': edgeWidth(e.call_count) } as CSSProperties}
- />
- )
- })}
- {nodes.map((n) => {
- const pos = layout.nodes.get(n.id)
- if (!pos) return null
- const status = nodeStatus(n.status)
- const selected = n.id === selectedId
- const depth = impactOn ? impact.get(n.id) : undefined
- const dimmed = impactOn
- ? depth === undefined
- : dimField && neighbors !== null && !selected && !neighbors.has(n.id)
- const focused = n.id === focusedId
- // In radial mode position carries meaning (ring = criticality),
- // so spell it out for non-visual users. Ring 0 is most critical.
- const ariaLabel = isRadial
- ? `${n.id}, ${status}, ring ${pos.layer}${pos.layer === 0 ? ', most critical' : ''}`
- : `${n.id}, ${status}`
- // Shape: a labeled chip when names show (few nodes / zoomed in) or
- // when this node is individually surfaced (selected / focused / a
- // neighbor of the selection); otherwise a small status dot, so a
- // large zoomed-out field reads as a dense even galaxy, not a smear
- // of overlapping chips. Both shapes share the node center, so edges
- // (which use layout coords) connect identically either way.
- const labelled = showNames || selected || focused || (neighbors?.has(n.id) ?? false)
- return (
- onSelect(n.id)}
- >
- {/* Anomaly beacon: failing services pulse so a glance at the map
- answers "what's on fire now". Behind the chip/dot; pure
- opacity/scale, gated off on coarse pointers + reduced motion
- + hidden tab (see CSS). Suppressed under the impact overlay,
- which owns its own emphasis. */}
- {status === 'critical' && !impactOn &&
- (labelled ? (
-
- ) : (
-
- ))}
- {labelled ? (
- <>
-
- {depth !== undefined && depth > 0 && (
-
- )}
-
-
- {n.id.length > 16 ? `${n.id.slice(0, 15)}…` : n.id}
-
- {showErrLabels && (
-
- {formatPercent(n.metrics.error_rate)}
-
- )}
- >
- ) : (
- <>
-
- {depth !== undefined && depth > 0 && (
-
- )}
- >
- )}
-
- )
- })}
-
-
- {core !== undefined && (
-
- {core}
-
- )}
+
+
+
)
}
diff --git a/ui/src/components/map/__tests__/FlowMap.test.tsx b/ui/src/components/map/__tests__/FlowMap.test.tsx
index 8cfdb87..76e3371 100644
--- a/ui/src/components/map/__tests__/FlowMap.test.tsx
+++ b/ui/src/components/map/__tests__/FlowMap.test.tsx
@@ -1,14 +1,59 @@
import { createRef } from 'react'
-import { describe, expect, it, vi } from 'vitest'
-import { fireEvent, render, screen, within } from '@testing-library/react'
-import userEvent from '@testing-library/user-event'
+import { beforeAll, describe, expect, it, vi } from 'vitest'
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import FlowMap, { type FlowMapHandle } from '../FlowMap'
import type { SystemEdge, SystemNode } from '@/types/api'
-// Canvas-level coverage for the FlowMap component in isolation (the data /
-// route / filter shell lives in ConstellationHome; this exercises the SVG
-// field, polar keyboard, pan/zoom, selection→dim, the core slot + fallback,
-// and the imperative fit()). Migrated from the retiring FlowMapView.test.
+// Integration coverage for the React Flow service map. FlowMap feeds our own
+// deterministic radial layout into @xyflow/react, which owns pan/zoom, the
+// grid, the minimap, and a11y focus. React Flow renders nodes ASYNCHRONOUSLY
+// (it measures the pane in a post-mount effect), so every node/edge assertion
+// must go through an async query — never a synchronous getBy.
+//
+// EDGE RENDERING + jsdom: React Flow only draws an edge once BOTH endpoint
+// nodes are *measured*. Its measurement pipeline is: a ResizeObserver fires →
+// reads the node wrapper's offsetWidth/Height → writes `measured` dims →
+// `nodesInitialized` flips true → edges render. The shared src/test-setup.ts
+// stubs ResizeObserver as a no-op (its callback never fires) and its
+// offsetWidth/Height getters are guarded out by an existing zero-returning
+// getter in this jsdom build, so node dims stay 0 and NO edge ever renders.
+// We close that gap locally (firing RO + non-zero offsets) so the edge tests
+// exercise real React Flow geometry. Behaviour-neutral — it only feeds React
+// Flow the measurements a real browser would. See report: test-setup's RO/
+// offset shims should be hardened so this block can move there.
+beforeAll(() => {
+ Object.defineProperties(HTMLElement.prototype, {
+ offsetWidth: {
+ configurable: true,
+ get(this: HTMLElement) {
+ return parseFloat(this.style?.width) || 168
+ },
+ },
+ offsetHeight: {
+ configurable: true,
+ get(this: HTMLElement) {
+ return parseFloat(this.style?.height) || 40
+ },
+ },
+ })
+ class FiringResizeObserver {
+ private readonly cb: ResizeObserverCallback
+ constructor(cb: ResizeObserverCallback) {
+ this.cb = cb
+ }
+ observe(target: Element) {
+ // Fire synchronously with the element's box so React Flow records a
+ // non-zero measured size (a no-op observer leaves nodes unmeasured).
+ this.cb(
+ [{ target, contentRect: target.getBoundingClientRect() } as ResizeObserverEntry],
+ this as unknown as ResizeObserver,
+ )
+ }
+ unobserve() {}
+ disconnect() {}
+ }
+ ;(globalThis as Record).ResizeObserver = FiringResizeObserver
+})
function node(id: string, status: string, errorRate = 0): SystemNode {
return {
@@ -59,7 +104,6 @@ type Overrides = Partial>
function renderMap(overrides: Overrides = {}) {
const onSelect = overrides.onSelect ?? vi.fn()
const onClearSelection = overrides.onClearSelection ?? vi.fn()
- const onFocusFilter = overrides.onFocusFilter ?? vi.fn()
const utils = render(
,
)
- return { ...utils, onSelect, onClearSelection, onFocusFilter }
+ return { ...utils, onSelect, onClearSelection }
}
function getMap() {
return screen.getByRole('application', { name: /service flow map/i })
}
-function innerG(): SVGGElement {
- const svg = screen.getByTestId('flow-map-svg')
- return svg.querySelector('g') as SVGGElement
-}
-
-describe('FlowMap — radial render', () => {
- it('renders the radial dial, a node per service, and an edge per dependency', () => {
+describe('FlowMap — React Flow render', () => {
+ it('renders a node per service', async () => {
renderMap()
- const map = getMap()
- expect(screen.getByTestId('radial-dial')).toBeInTheDocument()
- expect(within(map).getByText('checkout')).toBeInTheDocument()
- expect(within(map).getByText('payments')).toBeInTheDocument()
- expect(within(map).getByText('db')).toBeInTheDocument()
- const svg = screen.getByTestId('flow-map-svg')
- // Dial circles are ; dependency edges are .
- expect(svg.querySelectorAll('path')).toHaveLength(2)
+ // React Flow mounts nodes after measuring the pane — use findBy.
+ expect(await screen.findByText('checkout')).toBeInTheDocument()
+ expect(await screen.findByText('payments')).toBeInTheDocument()
+ expect(await screen.findByText('db')).toBeInTheDocument()
})
- it('places nodes via a CSS transform (interpolatable), not the SVG attr', () => {
- renderMap()
- const g = screen
- .getByTestId('flow-map-svg')
- .querySelector('[data-node-id="db"]') as SVGGElement
- expect(g.getAttribute('transform')).toBeNull()
- expect(g.style.transform).toMatch(/translate\(/)
- })
-
- it('marks the failing edge and leaves the healthy one alone', () => {
- renderMap()
- const svg = screen.getByTestId('flow-map-svg')
- const failing = [...svg.querySelectorAll('path')].filter((p) =>
- p.getAttribute('class')?.includes('edgeFailing'),
- )
- expect(failing).toHaveLength(1)
+ it('wraps each service node with its stable React Flow data-id', async () => {
+ const { container } = renderMap()
+ await screen.findByText('payments')
+ for (const id of ['checkout', 'payments', 'db']) {
+ expect(container.querySelector(`.react-flow__node[data-id="${id}"]`)).not.toBeNull()
+ }
})
- it('shows err% labels at default zoom (semantic zoom ≥ 0.8)', () => {
- renderMap()
- expect(screen.getByText('4.2%')).toBeInTheDocument()
+ it('renders one edge path per dependency', async () => {
+ const { container } = renderMap()
+ // Wait for nodes (edges render in the same pass once positions exist).
+ await screen.findByText('payments')
+ await waitFor(() => {
+ expect(
+ container.querySelectorAll('.react-flow__edges path').length,
+ ).toBe(EDGES.length)
+ })
})
- it('carries the ring/rank in node aria-labels (radial)', () => {
- renderMap()
- const svg = screen.getByTestId('flow-map-svg')
- const labels = [...svg.querySelectorAll('[data-node-id]')].map((g) =>
- g.getAttribute('aria-label'),
- )
- expect(labels.some((l) => l?.includes('ring 0') && l?.includes('most critical'))).toBe(true)
+ it('marks exactly the failing edge and leaves the healthy one alone', async () => {
+ const { container } = renderMap()
+ await screen.findByText('payments')
+ // React Flow joins the edge id with a NUL, not a space, so an exact
+ // [data-id="payments db"] match fails — instead count the edge s and
+ // match the failing one by the *unhashed* CSS-module token (`edgeFailing`)
+ // via [class*=…], which survives the hash suffix. EDGES has one critical
+ // (payments→db) edge, so exactly one path should be marked failing.
+ await waitFor(() => {
+ const paths = container.querySelectorAll('.react-flow__edges path')
+ expect(paths.length).toBe(EDGES.length)
+ })
+ expect(
+ container.querySelectorAll('.react-flow__edges path[class*="edgeFailing"]'),
+ ).toHaveLength(1)
})
})
-describe('FlowMap — selection & dim', () => {
+describe('FlowMap — selection', () => {
it('node click fires onSelect with the id', async () => {
- const user = userEvent.setup()
const { onSelect } = renderMap()
- await user.click(within(getMap()).getByText('payments'))
+ // fireEvent.click (not userEvent) — userEvent dispatches a full
+ // mousedown→mouseup→click sequence, and d3-zoom's mousedown handler reads
+ // event.view.document, which jsdom leaves null → an uncaught TypeError.
+ // React Flow's onNodeClick fires on the click event alone, so a bare
+ // click both avoids the crash and exercises the handler. The click on the
+ // inner chip text bubbles to the React Flow node wrapper → onNodeClick.
+ fireEvent.click(await screen.findByText('payments'))
expect(onSelect).toHaveBeenCalledWith('payments')
})
- it('dims nodes outside the selected 1-hop neighborhood by default', () => {
- renderMap({ selectedId: 'checkout' })
- const svg = screen.getByTestId('flow-map-svg')
- expect(
- svg.querySelector('[data-node-id="db"]')?.getAttribute('class'),
- ).toContain('dimmed')
- expect(
- svg.querySelector('[data-node-id="payments"]')?.getAttribute('class'),
- ).not.toContain('dimmed')
- })
-
- it('dim={false} suppresses the field-wide focus-distortion even with a selection', () => {
- renderMap({ selectedId: 'checkout', dim: false })
- const svg = screen.getByTestId('flow-map-svg')
- expect(
- svg.querySelector('[data-node-id="db"]')?.getAttribute('class'),
- ).not.toContain('dimmed')
- })
-
- it('marks ONLY the selected node group with data-selected (the lift hook)', () => {
- renderMap({ selectedId: 'payments' })
- const svg = screen.getByTestId('flow-map-svg')
- expect(
- svg.querySelector('[data-node-id="payments"]')?.hasAttribute('data-selected'),
- ).toBe(true)
- expect(
- svg.querySelector('[data-node-id="checkout"]')?.hasAttribute('data-selected'),
- ).toBe(false)
- expect(
- svg.querySelector('[data-node-id="db"]')?.hasAttribute('data-selected'),
- ).toBe(false)
- })
-
- it('carries no data-selected on any node when nothing is selected', () => {
- renderMap()
- const svg = screen.getByTestId('flow-map-svg')
- expect(svg.querySelectorAll('[data-node-id][data-selected]')).toHaveLength(0)
- })
-})
-
-describe('FlowMap — core slot', () => {
- it('renders the HTML core overlay as a sibling of the ', () => {
- renderMap({ core: core content
})
- const overlay = screen.getByTestId('flow-map-core')
- expect(overlay).toHaveTextContent('core content')
- // It is NOT inside the svg — it pins to viewport center, field pans behind.
- expect(screen.getByTestId('flow-map-svg').contains(overlay)).toBe(false)
- // With a core provided, the in-svg text fallback is gone.
- expect(screen.queryByText('HEALTHY')).not.toBeInTheDocument()
- })
-
- it('falls back to the in-svg health-core text when core is undefined', () => {
- renderMap()
- expect(screen.queryByTestId('flow-map-core')).not.toBeInTheDocument()
- expect(screen.getByText('HEALTHY')).toBeInTheDocument()
- // healthyCount/total — 1 of 3 nodes is healthy.
- expect(screen.getByText('1/3')).toBeInTheDocument()
- })
-
- it('centerLabel={false} frees the dial center (no fallback text)', () => {
- renderMap({ centerLabel: false })
- expect(screen.queryByText('HEALTHY')).not.toBeInTheDocument()
- expect(screen.queryByText('1/3')).not.toBeInTheDocument()
- // The dial graticule still renders — only the center readout is suppressed.
- expect(screen.getByTestId('radial-dial')).toBeInTheDocument()
- })
-})
-
-describe('FlowMap — anomaly prominence', () => {
- it('tags each node with its status and pulses only the critical (failing) ones', () => {
- renderMap()
- const svg = screen.getByTestId('flow-map-svg')
- expect(svg.querySelector('[data-node-id="db"]')?.getAttribute('data-status')).toBe('critical')
- expect(svg.querySelector('[data-node-id="checkout"]')?.getAttribute('data-status')).toBe(
- 'healthy',
- )
- // Only the critical node carries the pulse beacon.
- expect(screen.getByTestId('node-pulse-db')).toBeInTheDocument()
- expect(screen.queryByTestId('node-pulse-checkout')).toBeNull()
- expect(screen.queryByTestId('node-pulse-payments')).toBeNull()
- })
-
- it('suppresses the pulse under the impact overlay (which owns its emphasis)', () => {
- renderMap({ impact: new Map([['db', 0]]) })
- expect(screen.queryByTestId('node-pulse-db')).toBeNull()
- })
-})
-
-describe('FlowMap — galaxy dots at scale', () => {
- // A large field (> NAME_ALWAYS_MAX) so names are not force-shown; svc-00 is the
- // sole critical node.
- const many: readonly SystemNode[] = Array.from({ length: 30 }, (_, i) =>
- node(`svc-${String(i).padStart(2, '0')}`, i === 0 ? 'critical' : 'healthy'),
- )
-
- function renderMany(overrides: Overrides = {}) {
- return render(
- ,
- )
- }
-
- it('renders un-surfaced nodes as status dots (no chip label) when zoomed out', () => {
- renderMany()
- const svg = screen.getByTestId('flow-map-svg')
- // Zoom out below NAME_ZOOM (0.5): one wheel-out step → k ≈ 0.41.
- fireEvent.wheel(svg, { deltaY: 600, clientX: 0, clientY: 0 })
- // Galaxy markers (circles) replace the chips; no node-name labels remain.
- expect(svg.querySelectorAll('circle[class*="nodeMarker"]').length).toBeGreaterThan(0)
- expect(screen.queryByText('svc-01')).not.toBeInTheDocument()
- // The critical node stays obvious — it still pulses, now as a dot.
- expect(screen.getByTestId('node-pulse-svc-00')).toBeInTheDocument()
- })
-
- it('keeps the selected node a labeled chip even in the zoomed-out dot field', () => {
- renderMany({ selectedId: 'svc-05' })
- const svg = screen.getByTestId('flow-map-svg')
- fireEvent.wheel(svg, { deltaY: 600, clientX: 0, clientY: 0 })
- // The selection is surfaced as a chip (its label renders); the rest are dots.
- expect(screen.getByText('svc-05')).toBeInTheDocument()
- expect(svg.querySelectorAll('circle[class*="nodeMarker"]').length).toBeGreaterThan(0)
- })
-})
-
-describe('FlowMap — ringDepth', () => {
- it('opts the container into cinematic depth only when ringDepth is set', () => {
- const { rerender } = renderMap()
- expect(getMap().className).not.toMatch(/depth/)
- rerender(
- ,
- )
- expect(getMap().className).toMatch(/depth/)
- })
-})
-
-describe('FlowMap — keyboard (polar walk)', () => {
- it('arrow keys walk via aria-activedescendant; Enter inspects', async () => {
- const user = userEvent.setup()
- const { onSelect } = renderMap()
- const map = getMap()
- map.focus()
- await user.keyboard('{ArrowRight}')
- const active = map.getAttribute('aria-activedescendant')
- expect(active).toMatch(/^map-node-/)
- await user.keyboard('{Enter}')
- expect(onSelect).toHaveBeenCalledWith(active!.replace('map-node-', ''))
- })
-
- it('a ring-out step (down) lands focus on a node', async () => {
- const user = userEvent.setup()
- renderMap()
- const map = getMap()
- map.focus()
- await user.keyboard('{ArrowRight}')
- await user.keyboard('{ArrowDown}')
- expect(map.getAttribute('aria-activedescendant')).toMatch(/^map-node-/)
- })
-
- // Cold-start bootstrap: Tabbing in with nothing selected, the FIRST arrow —
- // in any direction — must seed focus (not just ArrowRight). ArrowDown is the
- // most intuitive first key in a radial field and must not dead-end.
- it.each(['ArrowDown', 'ArrowUp', 'ArrowLeft', 'ArrowRight'])(
- 'bootstraps focus from a cold start on %s',
- async (key) => {
- const user = userEvent.setup()
- renderMap()
- const map = getMap()
- map.focus()
- await user.keyboard(`{${key}}`)
- expect(map.getAttribute('aria-activedescendant')).toMatch(/^map-node-/)
- },
- )
-
- it('seeds the SAME entry node regardless of the first arrow key (deterministic)', async () => {
- const seed = async (key: string) => {
- const user = userEvent.setup()
- const { unmount } = renderMap()
- const map = getMap()
- map.focus()
- await user.keyboard(`{${key}}`)
- const active = map.getAttribute('aria-activedescendant')
- unmount()
- return active
- }
- const down = await seed('ArrowDown')
- const right = await seed('ArrowRight')
- expect(down).toMatch(/^map-node-/)
- expect(down).toBe(right)
- })
-
- it('Escape clears the selection', async () => {
- const user = userEvent.setup()
- const { onClearSelection } = renderMap({ selectedId: 'payments' })
- const map = getMap()
- map.focus()
- await user.keyboard('{Escape}')
+ it('pane click clears the selection', async () => {
+ const { container, onClearSelection } = renderMap({ selectedId: 'payments' })
+ await screen.findByText('payments')
+ const pane = container.querySelector('.react-flow__pane')
+ expect(pane).not.toBeNull()
+ fireEvent.click(pane as Element)
expect(onClearSelection).toHaveBeenCalled()
})
- it('"/" focuses the filter and "f" fits without crashing', async () => {
- const user = userEvent.setup()
- const { onFocusFilter } = renderMap()
- const map = getMap()
- map.focus()
- await user.keyboard('f')
- await user.keyboard('/')
- expect(onFocusFilter).toHaveBeenCalled()
- })
-})
-
-describe('FlowMap — pan/zoom', () => {
- it('wheel zooms the inner transform (no re-layout)', () => {
- renderMap()
- const svg = screen.getByTestId('flow-map-svg')
- const before = innerG().getAttribute('transform')
- fireEvent.wheel(svg, { deltaY: -100, clientX: 50, clientY: 50 })
- const after = innerG().getAttribute('transform')
- expect(after).not.toBe(before)
- expect(after).toMatch(/scale\(1\.1/)
- })
-
- it('zooming out below 0.8 hides the err% labels (semantic zoom)', () => {
- renderMap()
- expect(screen.getByText('4.2%')).toBeInTheDocument()
- const svg = screen.getByTestId('flow-map-svg')
- fireEvent.wheel(svg, { deltaY: 300, clientX: 0, clientY: 0 })
- expect(screen.queryByText('4.2%')).not.toBeInTheDocument()
- })
-
- it('single-pointer drag pans', () => {
- renderMap()
- const svg = screen.getByTestId('flow-map-svg')
- fireEvent.pointerDown(svg, { pointerId: 1, clientX: 10, clientY: 10 })
- fireEvent.pointerMove(svg, { pointerId: 1, clientX: 30, clientY: 25 })
- fireEvent.pointerUp(svg, { pointerId: 1 })
- expect(innerG().getAttribute('transform')).toContain('translate(20 15)')
- })
-
- it('two-pointer pinch zooms', () => {
- renderMap()
- const svg = screen.getByTestId('flow-map-svg')
- fireEvent.pointerDown(svg, { pointerId: 1, clientX: 0, clientY: 0 })
- fireEvent.pointerDown(svg, { pointerId: 2, clientX: 100, clientY: 0 })
- fireEvent.pointerMove(svg, { pointerId: 2, clientX: 200, clientY: 0 })
- fireEvent.pointerUp(svg, { pointerId: 1 })
- fireEvent.pointerUp(svg, { pointerId: 2 })
- expect(innerG().getAttribute('transform')).toMatch(/scale\(2\)/)
+ it('still renders the selected node (selection is a non-fatal data prop)', async () => {
+ // Class names are hashed, so asserting the selected styling is brittle —
+ // we only verify the selected node continues to render.
+ const { container } = renderMap({ selectedId: 'payments' })
+ await screen.findByText('payments')
+ expect(
+ container.querySelector('.react-flow__node[data-id="payments"]'),
+ ).not.toBeNull()
})
})
describe('FlowMap — imperative fit()', () => {
- it('exposes fit() on the handle and runs without throwing', () => {
+ it('exposes fit() on the handle and runs without throwing', async () => {
const ref = createRef()
render(
{
selectedId={null}
onSelect={noop}
onClearSelection={noop}
- onFocusFilter={noop}
/>,
)
+ await screen.findByText('payments')
expect(ref.current).not.toBeNull()
- expect(() => ref.current!.fit()).not.toThrow()
- })
-})
-
-describe('FlowMap — ?impact= blast-radius overlay', () => {
- it('tints the downstream cone by depth and rings the root', () => {
- const impact = new Map([
- ['checkout', 0],
- ['payments', 1],
- ['db', 2],
- ])
- renderMap({ impact })
- const payTint = screen.getByTestId('impact-tint-payments')
- const dbTint = screen.getByTestId('impact-tint-db')
- expect(Number(payTint.style.fillOpacity)).toBeGreaterThan(
- Number(dbTint.style.fillOpacity),
- )
- expect(screen.queryByTestId('impact-tint-checkout')).toBeNull()
- const rootRect = screen
- .getByTestId('flow-map-svg')
- .querySelector('[data-node-id="checkout"] rect')
- expect(rootRect?.getAttribute('class')).toContain('nodeImpactRoot')
- })
-
- it('dims services outside the cone', () => {
- const impact = new Map([
- ['payments', 0],
- ['db', 1],
- ])
- renderMap({ impact })
- const svg = screen.getByTestId('flow-map-svg')
- expect(
- svg.querySelector('[data-node-id="checkout"]')?.getAttribute('class'),
- ).toContain('dimmed')
- expect(
- svg.querySelector('[data-node-id="db"]')?.getAttribute('class'),
- ).not.toContain('dimmed')
+ expect(() => act(() => ref.current?.fit())).not.toThrow()
})
})
-describe('FlowMap — ambient-motion gate', () => {
- it('marks the container visible on mount and pauses on visibilitychange', () => {
+describe('FlowMap — smoke', () => {
+ it('mounts the React Flow application container', () => {
renderMap()
- expect(getMap().dataset.visible).toBe('true')
- const original = Object.getOwnPropertyDescriptor(
- Document.prototype,
- 'visibilityState',
- )
- Object.defineProperty(document, 'visibilityState', {
- configurable: true,
- get: () => 'hidden',
- })
- fireEvent(document, new Event('visibilitychange'))
- expect(getMap().dataset.visible).toBe('false')
- if (original) Object.defineProperty(document, 'visibilityState', original)
+ // The container itself is synchronous (only the nodes are async).
+ expect(getMap()).toBeInTheDocument()
+ expect(getMap()).toHaveAttribute('data-testid', 'flow-map')
})
})
diff --git a/ui/src/components/shell/Shell.module.css b/ui/src/components/shell/Shell.module.css
index a0bafcd..4ce5a36 100644
--- a/ui/src/components/shell/Shell.module.css
+++ b/ui/src/components/shell/Shell.module.css
@@ -17,12 +17,6 @@
font-size: var(--text-base);
}
-.body {
- display: flex;
- flex: 1;
- min-height: 0;
-}
-
.main {
flex: 1;
/* min-height:0 lets .main shrink to the viewport and contain its overflow
@@ -31,7 +25,8 @@
min-width: 0;
overflow: hidden;
padding: var(--space-3);
- /* No bottom tab bar anymore — just clear the phone home indicator. */
+ /* No nav rail / tab bar — the canvas fills the width; just clear the phone
+ * home indicator at the bottom. */
padding-bottom: calc(var(--space-3) + env(safe-area-inset-bottom));
}
@@ -40,139 +35,3 @@
padding: var(--space-4);
}
}
-
-/* ---- icon rail (md+) ---- */
-.rail {
- display: none;
-}
-
-@media (min-width: 768px) {
- .rail {
- display: flex;
- flex: none;
- flex-direction: column;
- gap: var(--space-1);
- width: 56px;
- padding: var(--space-2) var(--space-1);
- border-right: 1px solid var(--stroke-1);
- }
-}
-
-@media (min-width: 1440px) {
- .rail {
- width: 200px;
- padding: var(--space-2);
- }
-}
-
-/* ---- bottom tab bar (xs only) ---- */
-.tabbar {
- position: fixed;
- inset: auto 0 0 0;
- z-index: 40;
- display: flex;
- height: calc(56px + env(safe-area-inset-bottom));
- padding-bottom: env(safe-area-inset-bottom);
- border-top: 1px solid var(--stroke-1);
- background: var(--bg-raised);
-}
-
-@media (min-width: 768px) {
- .tabbar {
- display: none;
- }
-}
-
-/* ---- nav items (shared) ---- */
-.navItem {
- display: flex;
- align-items: center;
- justify-content: center;
- gap: var(--space-1);
- min-height: 44px; /* ≥44px touch target on every pointer type */
- border-radius: var(--radius-2);
- color: var(--text-3);
- text-decoration: none;
- font-size: var(--text-2xs);
- transition: background var(--dur-1) var(--ease), color var(--dur-1) var(--ease);
-}
-
-@media (hover: hover) {
- .navItem:hover {
- background: var(--bg-raised);
- color: var(--text-1);
- }
-}
-
-.navItemActive {
- background: var(--accent-muted);
- color: var(--accent);
-}
-
-/* Tab bar items share the row equally, icon stacked over label. */
-.tabbar .navItem {
- flex: 1;
- flex-direction: column;
- gap: 2px;
-}
-
-/* Center palette button — same anatomy as a tab, never route-active. */
-.tabPalette {
- display: flex;
- flex: 1;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- gap: 2px;
- min-height: 44px;
- padding: 0;
- border: none;
- border-radius: var(--radius-2);
- background: transparent;
- color: var(--text-3);
- font-family: inherit;
- font-size: var(--text-2xs);
- cursor: pointer;
- transition: background var(--dur-1) var(--ease), color var(--dur-1) var(--ease);
-}
-
-@media (hover: hover) {
- .tabPalette:hover {
- background: var(--bg-raised);
- color: var(--text-1);
- }
-}
-
-/* Rail: icon-only until xl, where labels appear inline. */
-.rail .navLabel {
- display: none;
-}
-
-@media (min-width: 1440px) {
- .rail .navItem {
- justify-content: flex-start;
- padding: 0 var(--space-3);
- font-size: var(--text-sm);
- }
-
- .rail .navLabel {
- display: inline;
- }
-}
-
-.tooltip {
- z-index: 50;
- padding: var(--space-1) var(--space-2);
- border: 1px solid var(--stroke-2);
- border-radius: var(--radius-1);
- background: var(--bg-overlay);
- color: var(--text-1);
- font-size: var(--text-2xs);
-}
-
-/* Labeled rail makes tooltips redundant at xl. */
-@media (min-width: 1440px) {
- .tooltip {
- display: none;
- }
-}
diff --git a/ui/src/components/shell/Shell.tsx b/ui/src/components/shell/Shell.tsx
index 4a241d7..13584fe 100644
--- a/ui/src/components/shell/Shell.tsx
+++ b/ui/src/components/shell/Shell.tsx
@@ -1,74 +1,22 @@
-import { type ComponentType, type ReactNode, useEffect } from 'react'
-import * as Tooltip from '@radix-ui/react-tooltip'
-import { Link, useRoute } from 'wouter'
-import { Orbit } from 'lucide-react'
+import { type ReactNode, useEffect } from 'react'
import { getWsManager } from '@/lib/wsManager'
import type { Theme } from '@/hooks/useTheme'
import PulseBar from './PulseBar'
import styles from './Shell.module.css'
-interface NavEntry {
- href: string
- label: string
- Icon: ComponentType<{ size?: number | string; 'aria-hidden'?: boolean }>
-}
-
-// The Constellation map is the single human destination — the flow map folded
-// into it, and logs/traces are served to AI agents via the MCP tools
-// (search_logs, trace_graph), not as dedicated human screens. The ⌘K palette
-// (pulse-bar + tab-bar buttons) carries everything else.
-const NAV_ITEMS: readonly NavEntry[] = [
- { href: '/', label: 'Service Map', Icon: Orbit },
-]
-
-function NavLink({
- entry,
- variant,
-}: Readonly<{ entry: NavEntry; variant: 'rail' | 'tab' }>) {
- const [active] = useRoute(entry.href)
- const { href, label, Icon } = entry
-
- const link = (
-
-
- {label}
-
- )
-
- // Rail icons lose their label below xl — tooltip carries it instead.
- if (variant === 'rail') {
- return (
-
- {link}
-
-
- {label}
-
-
-
- )
- }
- return link
-}
-
interface ShellProps {
theme: Theme
onToggleTheme: () => void
- /** Opens the ⌘K palette — wired to the pulse-bar and tab-bar buttons. */
+ /** Opens the ⌘K palette — wired to the pulse-bar button. */
onOpenPalette?: () => void
children: ReactNode
}
/**
- * Responsive app shell: System Pulse bar on top; navigation as a bottom
- * tab bar below 768px, a 56px icon rail from 768px, and a labeled 200px
- * rail from 1440px — all CSS-only breakpoints (see styles/tokens.css).
- * Hidden variants use display:none, so only one nav is in the a11y tree.
+ * App shell: the System Pulse bar on top, the single-page canvas below. There
+ * is no navigation rail — the Service Map IS the app (the flow map folded into
+ * the home; logs/traces are MCP-tool surfaces; everything else is the ⌘K
+ * palette), so the canvas fills the full width.
*/
export default function Shell({
theme,
@@ -87,22 +35,13 @@ export default function Shell({
// always-on slim strip (PulseBar) at the top.
return (
-
-
-
-
-
- {NAV_ITEMS.map((entry) => (
-
- ))}
-
- {children}
-
-
-
+
)
}
diff --git a/ui/src/components/shell/__tests__/Shell.test.tsx b/ui/src/components/shell/__tests__/Shell.test.tsx
index fb0252a..93796ba 100644
--- a/ui/src/components/shell/__tests__/Shell.test.tsx
+++ b/ui/src/components/shell/__tests__/Shell.test.tsx
@@ -55,31 +55,15 @@ afterEach(() => {
})
describe('Shell', () => {
- it('renders the pulse banner, navigation and main content', () => {
+ it('renders the pulse banner and main content (no nav rail)', () => {
renderShell()
expect(screen.getByRole('banner')).toBeInTheDocument()
- expect(screen.getAllByRole('navigation').length).toBeGreaterThanOrEqual(1)
expect(screen.getByRole('main')).toContainElement(
screen.getByTestId('page-content'),
)
- })
-
- it('renders the single Service Map destination in the rail', () => {
- renderShell()
- // The Service Map is the only human destination — logs/traces are MCP-tool
- // surfaces, the flow map folded into home, and the bottom tab bar was
- // removed (Search lives in the top pulse bar). One nav link, in the rail.
- expect(screen.getAllByRole('link', { name: /service map/i })).toHaveLength(1)
- expect(screen.getAllByRole('link')).toHaveLength(1)
- })
-
- it('marks the active route with aria-current', () => {
- renderShell('/')
- const active = screen
- .getAllByRole('link')
- .filter((a) => a.getAttribute('aria-current') === 'page')
- expect(active).toHaveLength(1) // the single rail nav link
- active.forEach((a) => expect(a).toHaveAttribute('href', '/'))
+ // Single-page app — the Service Map IS the app, so there is no nav rail.
+ expect(screen.queryByRole('navigation')).not.toBeInTheDocument()
+ expect(screen.queryByRole('link', { name: /service map/i })).not.toBeInTheDocument()
})
it('renders the palette buttons when onOpenPalette is wired', async () => {
diff --git a/ui/src/components/trail/TrailBar.module.css b/ui/src/components/trail/TrailBar.module.css
deleted file mode 100644
index cb04b98..0000000
--- a/ui/src/components/trail/TrailBar.module.css
+++ /dev/null
@@ -1,106 +0,0 @@
-/* Pinned bottom-left; sits above the xs bottom tab bar (56px + safe area). */
-.bar {
- position: fixed;
- left: var(--space-3);
- bottom: calc(56px + env(safe-area-inset-bottom) + var(--space-2));
- z-index: 30;
- max-width: calc(100vw - var(--space-3) * 2);
-}
-
-@media (min-width: 768px) {
- .bar {
- bottom: var(--space-3);
- max-width: min(60vw, 720px);
- }
-}
-
-.list {
- display: flex;
- align-items: center;
- gap: var(--space-1);
- margin: 0;
- padding: var(--space-1);
- list-style: none;
- overflow-x: auto;
- border: 1px solid var(--stroke-1);
- border-radius: var(--radius-2);
- background: var(--bg-overlay);
- box-shadow: var(--shadow-sheet);
- /* long trails scroll horizontally without a visible scrollbar jump */
- scrollbar-width: thin;
-}
-
-.item {
- display: flex;
- flex: none;
- align-items: center;
- gap: var(--space-1);
-}
-
-.sep {
- color: var(--text-3);
-}
-
-/* Breadcrumb chip — a tight mono id token with a hairline frame; the trail
- * reads as a row of fixed-width instrument tags, not buttons. */
-.chip {
- display: inline-flex;
- align-items: center;
- gap: var(--space-1);
- min-height: 24px;
- padding: 0 var(--space-1) 0 var(--space-2);
- border: 1px solid var(--stroke-1);
- border-radius: var(--radius-1);
- background: transparent;
- color: var(--text-2);
- font-family: var(--font-mono);
- font-size: var(--text-2xs);
- cursor: pointer;
- white-space: nowrap;
- transition: background var(--dur-1) var(--ease), color var(--dur-1) var(--ease),
- border-color var(--dur-1) var(--ease);
-}
-
-@media (hover: hover) {
- .chip:hover {
- border-color: var(--stroke-2);
- background: var(--bg-raised);
- color: var(--text-1);
- }
-}
-
-@media (pointer: coarse) {
- .chip {
- min-height: 44px;
- }
-}
-
-/* Current frame — the inspected entity. Accent fill + accent-edge hairline so
- * "you are here" reads at a glance against the muted upstream chips. */
-.chipCurrent {
- border-color: var(--accent-edge);
- background: var(--accent-muted);
- color: var(--accent);
-}
-
-@media (hover: hover) {
- .chipCurrent:hover {
- border-color: var(--accent-edge);
- background: var(--accent-muted);
- color: var(--accent);
- }
-}
-
-/* "trace" tag is an instrument legend: mono uppercase caps. */
-.kind {
- color: var(--text-3);
- font-family: var(--font-mono);
- font-size: var(--text-2xs);
- letter-spacing: 0.06em;
- text-transform: uppercase;
-}
-
-.id {
- overflow: hidden;
- text-overflow: ellipsis;
-}
diff --git a/ui/src/components/trail/TrailBar.tsx b/ui/src/components/trail/TrailBar.tsx
deleted file mode 100644
index 03d546f..0000000
--- a/ui/src/components/trail/TrailBar.tsx
+++ /dev/null
@@ -1,84 +0,0 @@
-import { useEffect } from 'react'
-import { ChevronRight } from 'lucide-react'
-import type { TrailFrame } from '@/lib/trail'
-import styles from './TrailBar.module.css'
-
-interface TrailBarProps {
- frames: readonly TrailFrame[]
- /** Chip tap — pop back to that frame. */
- onPopTo: (index: number) => void
- /** Backspace — pop the top frame. */
- onPopOne: () => void
-}
-
-/** True when the key event originates from an editable element. */
-function isEditableTarget(target: EventTarget | null): boolean {
- if (!(target instanceof HTMLElement)) return false
- return (
- target.isContentEditable ||
- target.tagName === 'INPUT' ||
- target.tagName === 'TEXTAREA' ||
- target.tagName === 'SELECT'
- )
-}
-
-/** Shorten long ids (trace ids) for chip display. */
-function chipLabel(frame: TrailFrame): string {
- if (frame.id.length <= 16) return frame.id
- return `${frame.id.slice(0, 8)}…`
-}
-
-/**
- * Investigation Trail — breadcrumb chips pinned bottom-left (above the xs
- * tab bar). Renders nothing at depth 0. Backspace pops globally unless an
- * editable element has focus.
- */
-export default function TrailBar({
- frames,
- onPopTo,
- onPopOne,
-}: Readonly) {
- const depth = frames.length
-
- useEffect(() => {
- if (depth === 0) return
- const onKeyDown = (event: KeyboardEvent) => {
- if (event.key !== 'Backspace' || isEditableTarget(event.target)) return
- event.preventDefault()
- onPopOne()
- }
- window.addEventListener('keydown', onKeyDown)
- return () => window.removeEventListener('keydown', onKeyDown)
- }, [depth, onPopOne])
-
- if (depth === 0) return null
-
- return (
-
-
- {frames.map((frame, i) => {
- const current = i === depth - 1
- return (
-
- {i > 0 && (
-
- )}
- onPopTo(i)}
- >
- {frame.kind === 'trace' && (
- trace
- )}
- {chipLabel(frame)}
-
-
- )
- })}
-
-
- )
-}
diff --git a/ui/src/components/trail/__tests__/TrailBar.test.tsx b/ui/src/components/trail/__tests__/TrailBar.test.tsx
deleted file mode 100644
index 286b848..0000000
--- a/ui/src/components/trail/__tests__/TrailBar.test.tsx
+++ /dev/null
@@ -1,70 +0,0 @@
-import { describe, expect, it, vi } from 'vitest'
-import { render, screen } from '@testing-library/react'
-import userEvent from '@testing-library/user-event'
-import TrailBar from '../TrailBar'
-import type { TrailFrame } from '@/lib/trail'
-
-const FRAMES: TrailFrame[] = [
- { kind: 'svc', id: 'checkout' },
- { kind: 'svc', id: 'payments' },
- { kind: 'trace', id: 'a1b2c3d4e5f6a7b8c9d0' },
-]
-
-function renderBar(frames: TrailFrame[] = FRAMES) {
- const onPopTo = vi.fn()
- const onPopOne = vi.fn()
- render( )
- return { onPopTo, onPopOne }
-}
-
-describe('TrailBar', () => {
- it('renders nothing at depth 0', () => {
- renderBar([])
- expect(screen.queryByRole('navigation')).not.toBeInTheDocument()
- })
-
- it('renders one chip per frame, top frame marked current', () => {
- renderBar()
- const chips = screen.getAllByRole('button')
- expect(chips).toHaveLength(3)
- expect(chips[2]).toHaveAttribute('aria-current', 'true')
- expect(chips[0]).not.toHaveAttribute('aria-current')
- })
-
- it('truncates long trace ids but keeps the full id as title', () => {
- renderBar()
- const chip = screen.getAllByRole('button')[2]
- expect(chip).toHaveAttribute('title', 'a1b2c3d4e5f6a7b8c9d0')
- expect(chip).toHaveTextContent('a1b2c3d4…')
- })
-
- it('chip tap pops to that frame index', async () => {
- const user = userEvent.setup()
- const { onPopTo } = renderBar()
- await user.click(screen.getByRole('button', { name: /checkout/ }))
- expect(onPopTo).toHaveBeenCalledWith(0)
- })
-
- it('Backspace pops the top frame', async () => {
- const user = userEvent.setup()
- const { onPopOne } = renderBar()
- await user.keyboard('{Backspace}')
- expect(onPopOne).toHaveBeenCalledTimes(1)
- })
-
- it('Backspace inside an input is left alone', async () => {
- const user = userEvent.setup()
- const onPopOne = vi.fn()
- render(
- <>
-
-
- >,
- )
- const input = screen.getByLabelText('filter')
- await user.click(input)
- await user.keyboard('{Backspace}')
- expect(onPopOne).not.toHaveBeenCalled()
- expect(input).toHaveValue('a')
- })
-})
diff --git a/ui/src/components/triage/AnomalyStrip.module.css b/ui/src/components/triage/AnomalyStrip.module.css
deleted file mode 100644
index b48f3db..0000000
--- a/ui/src/components/triage/AnomalyStrip.module.css
+++ /dev/null
@@ -1,121 +0,0 @@
-.strip {
- flex: none;
-}
-
-.header {
- display: flex;
- align-items: baseline;
- gap: var(--space-2);
- margin-bottom: var(--space-1);
-}
-
-/* The title rides the .legend utility (global.css) for the mono-caps voice. */
-.title {
- margin: 0;
-}
-
-/* Recent-window counts (15M / 30M / 1H), pushed to the right of the title. */
-.windows {
- display: flex;
- align-items: baseline;
- gap: var(--space-3);
- margin-left: auto;
-}
-
-.window {
- display: inline-flex;
- align-items: baseline;
- gap: 4px;
-}
-
-/* Count numeral inherits the window's severity color (set inline); the legend
- * cap keeps its own muted --text-3 from the .legend utility. */
-.window :global(.num) {
- color: inherit;
- font-size: var(--text-sm);
-}
-
-/* Recently-anomalous services — tappable chips, most-recent first. */
-.chips {
- display: flex;
- flex-wrap: wrap;
- align-items: center;
- gap: var(--space-1);
-}
-
-.chip {
- display: inline-flex;
- align-items: center;
- gap: var(--space-2);
- min-height: 30px;
- padding: 0 var(--space-2);
- border: 1px solid var(--stroke-1);
- /* status-tinted left edge — the one place a chip carries saturation */
- border-left: 2px solid var(--chip-color, var(--stroke-2));
- border-radius: var(--radius-1);
- background: var(--bg-raised);
- color: var(--text-1);
- font-size: var(--text-xs);
- cursor: pointer;
- transition: background var(--dur-1) var(--ease);
-}
-
-@media (pointer: coarse) {
- .chip {
- min-height: 44px;
- }
-}
-
-@media (hover: hover) {
- .chip:hover {
- background: var(--bg-overlay);
- }
-}
-
-.chipName {
- font-family: var(--font-sans);
-}
-
-.chip :global(.num) {
- color: var(--text-3);
- font-size: var(--text-2xs);
-}
-
-.more {
- color: var(--text-3);
- font-family: var(--font-mono);
- font-size: var(--text-2xs);
-}
-
-.skeleton {
- height: 30px;
- border-radius: var(--radius-2);
- background: var(--bg-raised);
-}
-
-.stateText {
- display: flex;
- align-items: center;
- gap: var(--space-2);
- height: 30px;
- margin: 0;
- color: var(--text-3);
- font-size: var(--text-sm);
-}
-
-.retry {
- min-height: 28px;
- padding: 0 var(--space-2);
- border: 1px solid var(--stroke-2);
- border-radius: var(--radius-1);
- background: transparent;
- color: var(--text-1);
- font-size: var(--text-xs);
- cursor: pointer;
-}
-
-@media (pointer: coarse) {
- .retry {
- min-height: 44px;
- }
-}
diff --git a/ui/src/components/triage/AnomalyStrip.tsx b/ui/src/components/triage/AnomalyStrip.tsx
deleted file mode 100644
index 88d03b5..0000000
--- a/ui/src/components/triage/AnomalyStrip.tsx
+++ /dev/null
@@ -1,127 +0,0 @@
-import type { CSSProperties } from 'react'
-import { useAnomalyTimeline } from '@/hooks/useAnomalyTimeline'
-import type { AnomalyNode, AnomalySeverity } from '@/types/api'
-import styles from './AnomalyStrip.module.css'
-
-// Recent-anomaly summary — counts in the last 15m / 30m / 1h (NOT a 24h
-// timeline) plus the recently-anomalous services as tappable chips that open
-// the Inspector. Answers "what's anomalous right now", not "what happened today".
-
-const WINDOWS: readonly { label: string; ms: number }[] = [
- { label: '15M', ms: 15 * 60_000 },
- { label: '30M', ms: 30 * 60_000 },
- { label: '1H', ms: 60 * 60_000 },
-]
-const RECENT_MS = 60 * 60_000
-const MAX_CHIPS = 8
-
-const SEVERITY_RANK: Record = { critical: 3, warning: 2, info: 1 }
-const SEVERITY_COLOR: Record = {
- critical: 'var(--crit)',
- warning: 'var(--warn)',
- info: 'var(--text-3)',
-}
-
-function worstSeverity(items: readonly AnomalySeverity[]): AnomalySeverity | null {
- let worst: AnomalySeverity | null = null
- for (const s of items) {
- if (worst === null || SEVERITY_RANK[s] > SEVERITY_RANK[worst]) worst = s
- }
- return worst
-}
-
-function ageLabel(ms: number): string {
- const s = Math.max(0, Math.round(ms / 1000))
- if (s < 60) return `${s}s`
- const m = Math.round(s / 60)
- if (m < 60) return `${m}m`
- return `${Math.round(m / 60)}h`
-}
-
-interface AnomalyStripProps {
- onOpenService: (id: string) => void
-}
-
-export default function AnomalyStrip({ onOpenService }: Readonly) {
- const { data, isPending, error, refetch } = useAnomalyTimeline()
- const ready = !isPending && !error && data !== undefined
-
- const now = data?.fetchedAt ?? 0
- const timed: { a: AnomalyNode; t: number }[] = ready
- ? data.anomalies
- .map((a) => ({ a, t: Date.parse(a.timestamp) }))
- .filter((x) => !Number.isNaN(x.t))
- : []
-
- const windows = WINDOWS.map((w) => {
- const inWin = timed.filter((x) => now - x.t <= w.ms)
- return { label: w.label, count: inWin.length, severity: worstSeverity(inWin.map((x) => x.a.severity)) }
- })
-
- // Distinct services anomalous in the last hour, most-recent first.
- const byService = new Map()
- for (const { a, t } of timed) {
- if (now - t > RECENT_MS) continue
- const cur = byService.get(a.service)
- if (!cur) {
- byService.set(a.service, { latest: t, severity: a.severity })
- } else {
- if (t > cur.latest) cur.latest = t
- if (SEVERITY_RANK[a.severity] > SEVERITY_RANK[cur.severity]) cur.severity = a.severity
- }
- }
- const services = [...byService.entries()].sort((a, b) => b[1].latest - a[1].latest).slice(0, MAX_CHIPS)
- const overflow = byService.size - services.length
-
- return (
-
-
-
Anomalies
- {ready && (
-
- {windows.map((w) => (
-
- {w.label}
- {w.count}
-
- ))}
-
- )}
-
-
- {isPending ? (
-
- ) : error ? (
-
- Anomalies unavailable: {error.message}{' '}
- void refetch()}>
- Retry
-
-
- ) : byService.size === 0 ? (
- No anomalies in the last hour.
- ) : (
-
- {services.map(([service, info]) => (
- onOpenService(service)}
- title={`${service} — ${info.severity}, ${ageLabel(now - info.latest)} ago`}
- >
- {service}
- {ageLabel(now - info.latest)}
-
- ))}
- {overflow > 0 && +{overflow} }
-
- )}
-
- )
-}
diff --git a/ui/src/components/triage/ConstellationHome.tsx b/ui/src/components/triage/ConstellationHome.tsx
index 22c7ec6..edc8f21 100644
--- a/ui/src/components/triage/ConstellationHome.tsx
+++ b/ui/src/components/triage/ConstellationHome.tsx
@@ -5,11 +5,11 @@ import { useLocation, useSearch } from 'wouter'
import FlowMap, { type FlowMapHandle } from '@/components/map/FlowMap'
import ConnectInline from '@/components/shell/ConnectInline'
import ServiceGroups from '@/components/common/ServiceGroups'
-import AnomalyStrip from './AnomalyStrip'
import Sparkline from './Sparkline'
import { useSystemGraph } from '@/hooks/useSystemGraph'
import { useInvestigation } from '@/hooks/useInvestigation'
import { useMediaQuery } from '@/hooks/useMediaQuery'
+import { useAnomalyTimeline } from '@/hooks/useAnomalyTimeline'
import { downstreamDepths } from '@/lib/impact'
import { buildHref, readParam } from '@/lib/urlState'
import type { TrafficPoint } from '@/types/api'
@@ -66,6 +66,14 @@ export default function ConstellationHome() {
const nodes = useMemo(() => graph?.nodes ?? [], [graph])
const edges = useMemo(() => graph?.edges ?? [], [graph])
+ // Services with an active anomaly — feeds the side panel's "Anomalies" group.
+ // Reads the shared ['anomaly-timeline'] cache (no extra fetch of its own).
+ const { data: anomalyData } = useAnomalyTimeline()
+ const anomalyServiceIds = useMemo(
+ () => new Set((anomalyData?.anomalies ?? []).map((a) => a.service)),
+ [anomalyData],
+ )
+
// ?impact= blast-radius overlay (Inspector "Show on map" / shared links):
// BFS the downstream cone client-side over the already-loaded edge set —
// re-homed here from the retired /map view, so the cone survives the fold.
@@ -105,7 +113,6 @@ export default function ConstellationHome() {
if (loading) {
return (
)
@@ -114,7 +121,6 @@ export default function ConstellationHome() {
if (error) {
return (
-
Couldn’t load the service graph: {error}
@@ -128,7 +134,6 @@ export default function ConstellationHome() {
if (nodes.length === 0) {
return (
)
@@ -136,8 +141,6 @@ export default function ConstellationHome() {
return (
-
-
{impactService && (
@@ -206,15 +209,22 @@ export default function ConstellationHome() {
Flow
-
+
)}
{/* Desktop/tablet: the slim worst-first rail rides alongside the canvas.
- Hidden on xs (the card list IS the xs default above), and hidden while
- the Inspector is docked so the MAP (the hero) keeps its width instead
- of being squeezed between rail + inspector. */}
- {!isXs && service === null && (
+ Hidden on xs (the card list IS the xs default above). It STAYS when the
+ Inspector is open — the inspector is a popup floating over the map, not
+ a docked column, so the rail no longer gets squeezed; keeping it put
+ avoids the layout jolting on every node select. The selected service is
+ reflected by the rail's focus-distortion (selectedId). */}
+ {!isXs && (
Services
@@ -225,7 +235,12 @@ export default function ConstellationHome() {
/>
)}
-
+
)}
diff --git a/ui/src/components/triage/__tests__/AnomalyStrip.test.tsx b/ui/src/components/triage/__tests__/AnomalyStrip.test.tsx
deleted file mode 100644
index f7dae71..0000000
--- a/ui/src/components/triage/__tests__/AnomalyStrip.test.tsx
+++ /dev/null
@@ -1,59 +0,0 @@
-import { render, screen } from '@testing-library/react'
-import userEvent from '@testing-library/user-event'
-import { beforeEach, describe, expect, it, vi } from 'vitest'
-import type { AnomalyNode } from '@/types/api'
-import AnomalyStrip from '../AnomalyStrip'
-
-// Hoist-safe mock of the data hook so each state (pending/error/empty/ticks)
-// is exercised deterministically without a live MCP call. These branches lived
-// in the now-deleted TriageView.test.tsx; migrated here when AnomalyStrip moved
-// under ConstellationHome.
-const { useAnomalyTimeline } = vi.hoisted(() => ({ useAnomalyTimeline: vi.fn() }))
-vi.mock('@/hooks/useAnomalyTimeline', () => ({ useAnomalyTimeline }))
-
-const NOW = 1_700_000_000_000
-const anomaly = (over: Partial = {}): AnomalyNode =>
- ({
- service: 'order-service',
- severity: 'critical',
- timestamp: new Date(NOW - 60_000).toISOString(),
- ...over,
- }) as unknown as AnomalyNode
-
-beforeEach(() => useAnomalyTimeline.mockReset())
-
-describe('AnomalyStrip', () => {
- it('renders a skeleton while pending', () => {
- useAnomalyTimeline.mockReturnValue({ isPending: true, data: undefined, error: null, refetch: vi.fn() })
- render( )
- expect(screen.getByTestId('strip-skeleton')).toBeInTheDocument()
- })
-
- it('shows an alert with the message and retries on click', async () => {
- const refetch = vi.fn()
- useAnomalyTimeline.mockReturnValue({ isPending: false, data: undefined, error: new Error('boom'), refetch })
- render( )
- expect(screen.getByRole('alert')).toHaveTextContent('boom')
- await userEvent.click(screen.getByRole('button', { name: 'Retry' }))
- expect(refetch).toHaveBeenCalledOnce()
- })
-
- it('shows the empty state when there are no anomalies in the window', () => {
- useAnomalyTimeline.mockReturnValue({ isPending: false, data: { anomalies: [], fetchedAt: NOW }, error: null, refetch: vi.fn() })
- render( )
- expect(screen.getByText(/No anomalies in the last hour/i)).toBeInTheDocument()
- })
-
- it('renders a recent-service chip and opens the inspector on click', async () => {
- const onOpenService = vi.fn()
- useAnomalyTimeline.mockReturnValue({
- isPending: false,
- data: { anomalies: [anomaly()], fetchedAt: NOW },
- error: null,
- refetch: vi.fn(),
- })
- render( )
- await userEvent.click(screen.getByRole('button', { name: /order-service/i }))
- expect(onOpenService).toHaveBeenCalledWith('order-service')
- })
-})
diff --git a/ui/src/components/triage/__tests__/ConstellationHome.test.tsx b/ui/src/components/triage/__tests__/ConstellationHome.test.tsx
index 96f8585..9ac2588 100644
--- a/ui/src/components/triage/__tests__/ConstellationHome.test.tsx
+++ b/ui/src/components/triage/__tests__/ConstellationHome.test.tsx
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import { render, screen, within } from '@testing-library/react'
+import { fireEvent, render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { Router } from 'wouter'
@@ -142,17 +142,16 @@ async function findMap() {
}
describe('ConstellationHome — canvas + core', () => {
- it('renders the radial canvas as the hero with health demoted to a corner card', async () => {
+ it('renders the canvas as the hero with health demoted to a corner card', async () => {
renderHome()
const map = await findMap()
- // The map is the hero: nodes fill the field, the center is free.
- expect(within(map).getByText('db')).toBeInTheDocument()
+ // The map is the hero: nodes fill the field, the center is free. (React Flow
+ // mounts node DOM after it measures the pane, so the node text is async.)
+ expect(await within(map).findByText('db')).toBeInTheDocument()
// No pinned center overlay and no on-page health card — vitals live in the
// header now; the map owns the whole canvas.
expect(screen.queryByTestId('flow-map-core')).toBeNull()
expect(screen.queryByRole('meter', { name: /HEALTH/ })).toBeNull()
- // ringDepth is on → the cinematic dial backdrop renders.
- expect(screen.getByTestId('radial-dial')).toBeInTheDocument()
})
it('renders the worst-first rail alongside the canvas on md+', async () => {
@@ -165,10 +164,13 @@ describe('ConstellationHome — canvas + core', () => {
})
it('selecting a node from the canvas docks the inspector (?service=)', async () => {
- const user = userEvent.setup()
const memory = renderHome()
const map = await findMap()
- await user.click(within(map).getByText('payments'))
+ // React Flow wires onNodeClick to the node wrapper's click event. We use
+ // fireEvent.click (a lone click, no mousedown sequence) so the event does
+ // not bubble into d3-zoom's pane handler, which dereferences the synthetic
+ // event's null `view` under jsdom and crashes the run.
+ fireEvent.click(await within(map).findByText('payments'))
expect(memory.history.at(-1)).toContain('service=payments')
})
@@ -177,68 +179,24 @@ describe('ConstellationHome — canvas + core', () => {
const memory = renderHome()
await findMap()
const rail = screen.getByRole('complementary', { name: /service triage feed/i })
- await user.click(within(rail).getByRole('button', { name: /db/i }))
+ // Critical is open by default, so the db row button is reachable directly.
+ const critical = within(rail).getByRole('region', { name: 'Critical' })
+ await user.click(within(critical).getByRole('button', { name: /db/i }))
expect(memory.history.at(-1)).toContain('service=db')
})
})
-describe('ConstellationHome — anomaly tape', () => {
- it('renders recent-anomaly service chips; tapping one opens the inspector', async () => {
+describe('ConstellationHome — side-panel anomalies', () => {
+ it('lists anomalous services in a collapsible Anomalies group; tapping one opens the inspector', async () => {
const user = userEvent.setup()
const memory = renderHome()
- const strip = await screen.findByRole('region', { name: /recent anomalies/i })
- await user.click(await within(strip).findByRole('button', { name: /db/i }))
+ await findMap()
+ const rail = screen.getByRole('complementary', { name: /service triage feed/i })
+ // The anomaly timeline (MCP) resolves async into the side panel's group.
+ const anomalies = await within(rail).findByRole('region', { name: 'Anomalies' })
+ await user.click(within(anomalies).getByRole('button', { name: /db/i }))
expect(memory.history.at(-1)).toContain('service=db')
})
-
- it('shows the quiet empty message when there are no anomalies', async () => {
- mcpResponder = () =>
- Promise.resolve(
- new Response(
- JSON.stringify({
- jsonrpc: '2.0',
- id: 1,
- result: { content: [{ type: 'text', text: 'null' }] },
- }),
- { status: 200 },
- ),
- )
- renderHome()
- expect(await screen.findByText(/no anomalies in the last hour/i)).toBeInTheDocument()
- })
-
- it('shows an inline error with a working Retry when the MCP tool fails', async () => {
- const user = userEvent.setup()
- mcpResponder = () =>
- Promise.resolve(
- new Response(
- JSON.stringify({
- jsonrpc: '2.0',
- id: 1,
- error: { code: -32000, message: 'graph not initialized' },
- }),
- { status: 200 },
- ),
- )
- renderHome()
- const alert = await screen.findByRole('alert')
- expect(alert).toHaveTextContent(/anomalies unavailable/i)
- // Retry refetches: once the responder recovers, the strip resolves to the
- // quiet empty message (proves the button re-runs the query, not just clears).
- mcpResponder = () =>
- Promise.resolve(
- new Response(
- JSON.stringify({
- jsonrpc: '2.0',
- id: 1,
- result: { content: [{ type: 'text', text: 'null' }] },
- }),
- { status: 200 },
- ),
- )
- await user.click(within(alert).getByRole('button', { name: /retry/i }))
- expect(await screen.findByText(/no anomalies in the last hour/i)).toBeInTheDocument()
- })
})
describe('ConstellationHome — states', () => {
@@ -268,14 +226,17 @@ describe('ConstellationHome — states', () => {
})
describe('ConstellationHome — ?impact= blast-radius overlay', () => {
- it('tints the downstream cone and announces the overlay', async () => {
+ it('renders the downstream cone and announces the overlay', async () => {
renderHome('/?impact=checkout')
- const svg = within(await findMap()).getByTestId('flow-map-svg')
- const payTint = screen.getByTestId('impact-tint-payments')
- const dbTint = screen.getByTestId('impact-tint-db')
- expect(Number(payTint.style.fillOpacity)).toBeGreaterThan(Number(dbTint.style.fillOpacity))
- const rootRect = svg.querySelector('[data-node-id="checkout"] rect')
- expect(rootRect?.getAttribute('class')).toContain('nodeImpactRoot')
+ const map = await findMap()
+ // The cone root and its two downstream services all render as map nodes.
+ // (React Flow keys each node wrapper by data-id.) The old SVG fill-opacity
+ // depth shading has no DOM equivalent in the React Flow map; the overlay
+ // semantics now live on the banner below.
+ expect(await within(map).findByText('checkout')).toBeInTheDocument()
+ expect(within(map).getByText('payments')).toBeInTheDocument()
+ expect(within(map).getByText('db')).toBeInTheDocument()
+ expect(map.querySelector('[data-id="checkout"]')).not.toBeNull()
const banner = screen.getByRole('status')
expect(banner).toHaveTextContent(/blast radius of/i)
expect(banner).toHaveTextContent(/2 downstream/i)
@@ -311,9 +272,10 @@ describe('ConstellationHome — xs canvas default', () => {
expect(await screen.findByRole('region', { name: 'Critical' })).toBeInTheDocument()
expect(screen.getByRole('region', { name: 'Degraded' })).toBeInTheDocument()
expect(screen.queryByRole('application', { name: /service flow map/i })).toBeNull()
- // healthy collapsed behind a disclosure
+ // healthy collapsed behind a disclosure (now a generic collapsible group:
+ // caps title + count, e.g. "Healthy 2")
expect(screen.queryByText('checkout')).not.toBeInTheDocument()
- await user.click(screen.getByRole('button', { name: /healthy service/i }))
+ await user.click(screen.getByRole('button', { name: /healthy/i }))
expect(screen.getByText('checkout')).toBeInTheDocument()
// Flow toggle returns to the canvas.
await user.click(screen.getByRole('button', { name: 'Flow' }))
diff --git a/ui/src/hooks/__tests__/useTheme.test.ts b/ui/src/hooks/__tests__/useTheme.test.ts
index 1ca6802..d55c440 100644
--- a/ui/src/hooks/__tests__/useTheme.test.ts
+++ b/ui/src/hooks/__tests__/useTheme.test.ts
@@ -116,7 +116,7 @@ describe('theme-color meta sync', () => {
try {
mockMatchMedia(false)
const { result } = renderHook(() => useTheme())
- expect(meta.getAttribute('content')).toBe('#0b0d10')
+ expect(meta.getAttribute('content')).toBe('#0a0c0f')
act(() => result.current.toggle())
expect(meta.getAttribute('content')).toBe('#f7f8fa')
} finally {
diff --git a/ui/src/hooks/useTheme.ts b/ui/src/hooks/useTheme.ts
index aab8da1..2cd1f57 100644
--- a/ui/src/hooks/useTheme.ts
+++ b/ui/src/hooks/useTheme.ts
@@ -48,7 +48,7 @@ export function useTheme() {
// values mirror --bg-base in tokens.css for each theme.
document
.querySelector('meta[name="theme-color"]')
- ?.setAttribute('content', theme === 'dark' ? '#0b0d10' : '#f7f8fa')
+ ?.setAttribute('content', theme === 'dark' ? '#0a0c0f' : '#f7f8fa')
}, [theme])
const toggle = useCallback(() => {
diff --git a/ui/src/lib/__tests__/sheet.test.ts b/ui/src/lib/__tests__/sheet.test.ts
deleted file mode 100644
index 84ecc58..0000000
--- a/ui/src/lib/__tests__/sheet.test.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-import { describe, expect, it } from 'vitest'
-import { nextSheetState } from '../sheet'
-
-// viewport 800px → threshold = 120px (15%)
-const VH = 800
-
-describe('nextSheetState', () => {
- it('stays put for small drags', () => {
- expect(nextSheetState(50, 40, VH)).toBe(50)
- expect(nextSheetState(92, -40, VH)).toBe(92)
- expect(nextSheetState(92, 100, VH)).toBe(92)
- })
-
- it('drag up from half snaps to full', () => {
- expect(nextSheetState(50, -200, VH)).toBe(92)
- })
-
- it('drag down from full snaps to half', () => {
- expect(nextSheetState(92, 200, VH)).toBe(50)
- })
-
- it('drag down from half dismisses', () => {
- expect(nextSheetState(50, 200, VH)).toBe('dismiss')
- })
-
- it('drag up from full stays full', () => {
- expect(nextSheetState(92, -300, VH)).toBe(92)
- })
-})
diff --git a/ui/src/lib/sheet.ts b/ui/src/lib/sheet.ts
deleted file mode 100644
index f77e73d..0000000
--- a/ui/src/lib/sheet.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-// Bottom-sheet snap resolution for the xs Service Inspector.
-// Snap points: 50% and 92% of the viewport. A drag must travel 15% of the
-// viewport height to change state; dragging down past the half snap
-// dismisses the sheet.
-
-export type SheetSnap = 50 | 92
-
-export function nextSheetState(
- current: SheetSnap,
- /** Pointer travel in px — positive = dragged down. */
- deltaY: number,
- viewportH: number,
-): SheetSnap | 'dismiss' {
- const threshold = viewportH * 0.15
- if (Math.abs(deltaY) < threshold) return current
- if (deltaY < 0) return 92 // dragged up
- return current === 92 ? 50 : 'dismiss'
-}
diff --git a/ui/src/styles/global.css b/ui/src/styles/global.css
index 5b27002..f13b48f 100644
--- a/ui/src/styles/global.css
+++ b/ui/src/styles/global.css
@@ -125,11 +125,14 @@ select:focus-visible {
font-feature-settings: 'tnum' 1;
}
-/* Instrument legend: uppercase mono caps for units/axis/section labels. */
+/* Instrument legend: uppercase mono caps for units/axis/section labels.
+ * Medium weight + wider tracking give the caps a deliberate spec-sheet voice
+ * (the premium-label tell); the colour stays muted for restraint. */
.legend {
font-family: var(--font-mono);
font-size: var(--text-2xs);
- letter-spacing: 0.06em;
+ font-weight: 500;
+ letter-spacing: 0.09em;
text-transform: uppercase;
color: var(--text-3);
}
diff --git a/ui/src/styles/tokens.css b/ui/src/styles/tokens.css
index f3967bf..e0fe094 100644
--- a/ui/src/styles/tokens.css
+++ b/ui/src/styles/tokens.css
@@ -12,38 +12,42 @@
* xl 1440+ labeled rail 200px
*/
-/* ---- vendored fonts (OFL, ui/public/fonts, air-gap safe) ---- */
+/* ---- vendored fonts (Martian Grotesk + Martian Mono, Evil Martians, OFL-1.1,
+ * ui/public/fonts, air-gap safe). Purpose-built for developer tools — a
+ * characterful, engineered grotesk paired with its sibling monospace. Sans is
+ * the wght-axis variable cut, width pinned to normal + latin-subset (36 KB);
+ * mono is the wght-axis variable latin subset (24 KB). ---- */
@font-face {
- font-family: 'InterVariable';
+ font-family: 'Martian Grotesk';
font-style: normal;
font-weight: 100 900;
font-display: swap;
- src: url('/fonts/inter-latin-wght-normal.woff2') format('woff2-variations');
+ src: url('/fonts/martian-grotesk-latin-wght-normal.woff2') format('woff2-variations');
}
@font-face {
- font-family: 'JetBrains Mono';
+ font-family: 'Martian Mono';
font-style: normal;
- font-weight: 400;
+ font-weight: 100 900;
font-display: swap;
- src: url('/fonts/jetbrains-mono-latin-400-normal.woff2') format('woff2');
+ src: url('/fonts/martian-mono-latin-wght-normal.woff2') format('woff2-variations');
}
:root {
- /* dark (default) — near-black blue-cast surfaces, hairlines not shadows */
- --bg-base: #0b0d10;
- --bg-raised: #12151b;
- --bg-overlay: #171b23;
- --bg-inset: #07090c;
- --stroke-1: #1e232d;
- --stroke-2: #2a3140;
- --text-1: #e8ecf2; /* 13.9:1 on base */
- --text-2: #9aa3b2; /* 7.1:1 on base */
- --text-3: #646d7c; /* 4.6:1 on base */
- --accent: #5ca8ff; /* the ONLY interactive hue */
- --accent-muted: #5ca8ff26;
- --accent-edge: #5ca8ff26; /* accent ~15% — focus ring + active-route hairline ONLY */
- --bg-graticule: #5ca8ff0a; /* dotted instrument grid, ~4% accent */
+ /* dark (default) — deep near-black blue-cast surfaces, hairlines not shadows */
+ --bg-base: #0a0c0f;
+ --bg-raised: #12151c;
+ --bg-overlay: #181c25;
+ --bg-inset: #06080b;
+ --stroke-1: #20252f;
+ --stroke-2: #2c3342;
+ --text-1: #eceef3; /* 14.6:1 on base */
+ --text-2: #9aa3b3; /* 7.2:1 on base */
+ --text-3: #646d7d; /* 4.6:1 on base */
+ --accent: #818cf8; /* refined indigo — the ONLY interactive hue */
+ --accent-muted: #818cf826;
+ --accent-edge: #818cf82e; /* accent ~18% — focus ring + active-route hairline ONLY */
+ --bg-graticule: #818cf80c; /* dotted instrument grid, ~5% accent */
--gauge-track: var(--stroke-2); /* gauge/dial hairline track — inherits prefers-contrast */
--ok: #34d399;
--warn: #fbbf24;
@@ -54,8 +58,8 @@
--crit-bg: #f8717114;
--unknown-bg: #6b728014; /* neutral status wash — 8% --unknown, parity with ok/warn/crit-bg */
--scrim: rgb(0 0 0 / 0.5); /* neutral black dialog scrim — shared by palette + inspector overlays */
- --font-sans: 'InterVariable', system-ui, sans-serif;
- --font-mono: 'JetBrains Mono', ui-monospace, monospace;
+ --font-sans: 'Martian Grotesk', system-ui, sans-serif;
+ --font-mono: 'Martian Mono', ui-monospace, monospace;
--text-2xs: 11px;
--text-xs: 12px;
--text-sm: 12.5px;
@@ -80,10 +84,9 @@
/* two elevation levels — popovers/menus, and sheets/overlays */
--shadow-pop: 0 4px 16px rgb(0 0 0 / 0.35);
--shadow-sheet: 0 8px 32px rgb(0 0 0 / 0.45);
- /* App atmosphere: a barely-perceptible luminance ramp from the top of the
- * viewport (~3% lighter) into --bg-base. Texture, not decoration — it
- * keeps the large dark field from reading flat. Painted by body only. */
- --bg-app: radial-gradient(1100px 700px at 50% -12%, #11151d 0%, var(--bg-base) 62%);
+ /* Flat premium surface — no gradient. The graticule dots + the map's ring
+ * guides carry depth; the large dark field stays a clean instrument panel. */
+ --bg-app: var(--bg-base);
color-scheme: dark;
}
@@ -97,10 +100,10 @@
--text-1: #171b23;
--text-2: #49515e;
--text-3: #687284;
- --accent: #1d6fe0; /* darkened for ≥4.5:1 on white */
- --accent-muted: #1d6fe01f;
- --accent-edge: #1d6fe029;
- --bg-graticule: #1d6fe00d;
+ --accent: #4f46e5; /* indigo-600 — ≥4.5:1 on white */
+ --accent-muted: #4f46e51f;
+ --accent-edge: #4f46e529;
+ --bg-graticule: #4f46e50d;
--gauge-track: var(--stroke-2);
--ok: #0e9f6e; /* status hues darkened for ≥4.5:1 */
--warn: #b45309;
@@ -113,7 +116,7 @@
--scrim: rgb(0 0 0 / 0.5); /* neutral black dialog scrim — shared by palette + inspector overlays */
--shadow-pop: 0 4px 16px rgb(23 27 35 / 0.1);
--shadow-sheet: 0 8px 32px rgb(23 27 35 / 0.16);
- --bg-app: radial-gradient(1100px 700px at 50% -12%, #fdfdfe 0%, var(--bg-base) 62%);
+ --bg-app: var(--bg-base);
color-scheme: light;
}
diff --git a/ui/src/test-setup.ts b/ui/src/test-setup.ts
index 8f04aad..d65fb5b 100644
--- a/ui/src/test-setup.ts
+++ b/ui/src/test-setup.ts
@@ -37,3 +37,40 @@ if (!('ResizeObserver' in globalThis)) {
}
(globalThis as Record).ResizeObserver = ResizeObserverStub;
}
+
+// --- React Flow (@xyflow/react) jsdom shims ---
+// React Flow measures its pane + nodes through offset sizes and parses the
+// viewport transform via DOMMatrixReadOnly; jsdom provides neither, so nodes
+// won't render without these. Canonical "mockReactFlow" setup.
+if (!('DOMMatrixReadOnly' in globalThis)) {
+ class DOMMatrixReadOnlyStub {
+ m22: number;
+ constructor(transform?: string) {
+ const scale = transform?.match(/scale\(([^)]+)\)/);
+ this.m22 = scale ? parseFloat(scale[1]) : 1;
+ }
+ }
+ (globalThis as Record).DOMMatrixReadOnly = DOMMatrixReadOnlyStub;
+}
+if (!Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetWidth')?.get) {
+ Object.defineProperties(HTMLElement.prototype, {
+ offsetWidth: {
+ get(this: HTMLElement) {
+ return parseFloat(this.style?.width) || 800;
+ },
+ },
+ offsetHeight: {
+ get(this: HTMLElement) {
+ return parseFloat(this.style?.height) || 600;
+ },
+ },
+ });
+}
+{
+ const svgProto = typeof SVGElement !== 'undefined'
+ ? (SVGElement.prototype as unknown as { getBBox?: () => DOMRect })
+ : null;
+ if (svgProto && !svgProto.getBBox) {
+ svgProto.getBBox = () => ({ x: 0, y: 0, width: 0, height: 0 }) as DOMRect;
+ }
+}