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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ The agent writes `.stackmap/<name>/diagram.json`, validates and repairs it, and
</picture>
</p>

- **Explore:** select a card for its details, connections and source evidence. **Trace** keeps a node's upstream and downstream; **Route** lights every path between two nodes.
- **Explore:** select a card to light its connections and dim the rest, with its details and source evidence in the inspector. **Trace** keeps a node's upstream and downstream; **Route** lights every path between two nodes.
- **Find:** search titles, ids and types; the **lens** dims the node types you don't need.
- **Guided views:** tabs the agent defines, like "Checkout path", that focus one part of the diagram. **Present** steps through them full screen.
- **Flow:** **Play** sends pulses along whatever you're looking at: the whole diagram, a view, a trace or a route, hop by hop.
Expand Down
12 changes: 9 additions & 3 deletions packages/viewer/e2e/focus.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,19 @@ test('the toolbar button enters focus, and the URL never keeps it', async ({ pag
await expect(header(page)).toBeVisible();
});

test('in focus, H hides the toolbar and shows the way back; leaving focus restores it', async ({ page }) => {
test('H hides the toolbar outside focus too', async ({ page }) => {
await page.goto('/?page=release-delivery');
await expect(page.locator('.sm-card').first()).toBeVisible();
// Outside focus H does nothing.
await page.keyboard.press('h');
await expect(tool(page, 'Focus on the canvas (Z)')).toHaveCount(0);
await expect(header(page)).toBeVisible();
await page.keyboard.press('h');
await expect(tool(page, 'Focus on the canvas (Z)')).toBeVisible();
});

test('in focus, H hides the toolbar and shows the way back; leaving focus restores it', async ({ page }) => {
await page.goto('/?page=release-delivery');
await expect(page.locator('.sm-card').first()).toBeVisible();
await page.keyboard.press('z');
await page.keyboard.press('h');
await expect(tool(page, 'Leave focus (Z)')).toHaveCount(0);
Expand All @@ -56,7 +63,6 @@ test('in focus, H hides the toolbar and shows the way back; leaving focus restor
await page.keyboard.press('z');
await expect(header(page)).toBeVisible();
await expect(tool(page, /^Play the flow/)).toBeVisible();
await expect(tool(page, 'Hide the toolbar (H)')).toHaveCount(0);
});

test('Z and H are ignored while typing in search', async ({ page }) => {
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified packages/viewer/e2e/visual.spec.ts-snapshots/sequence-light-linux.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 3 additions & 2 deletions packages/viewer/src/canvas/DiagramCanvas.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ describe('DiagramCanvas', () => {
);
nodeCardRenders.mockClear();
fireEvent.click(container.querySelector('.sm-card[data-card-id="orders"]')!);
// orders gains focus + the tab stop; edge (the default tab stop) loses it. The other four stay put.
expect([...new Set(nodeCardRenders.mock.calls.map(([id]) => id))].sort()).toEqual(['edge', 'orders']);
// orders gains focus + the tab stop; edge (the default tab stop) loses it; sessions, not a neighbour, dims.
// The other three stay put.
expect([...new Set(nodeCardRenders.mock.calls.map(([id]) => id))].sort()).toEqual(['edge', 'orders', 'sessions']);
});
});
21 changes: 0 additions & 21 deletions packages/viewer/src/chrome/IdentityCard.test.tsx

This file was deleted.

28 changes: 0 additions & 28 deletions packages/viewer/src/chrome/IdentityCard.tsx

This file was deleted.

14 changes: 11 additions & 3 deletions packages/viewer/src/chrome/Inspector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,20 @@ const LINE_STYLES: { key: string; label: string; test: (e: DiagramEdge) => boole
];

function Legend({ draft }: { draft: DiagramDraft }) {
const { dispatch } = useExplore();
const lines = LINE_STYLES.filter((l) => draft.edges.some(l.test));
// A row that unmounts under the pointer never gets its mouseleave.
useEffect(() => () => dispatch({ type: 'peek', nodeType: null }), [dispatch]);
return (
<>
<ul aria-label="Legend" className="space-y-2.5">
<ul aria-label="Legend" className="space-y-0.5">
{countByType(draft.nodes).map(([type, count]) => (
<li key={type} className="flex items-center justify-between text-[13px] text-fg">
<li
key={type}
onMouseEnter={() => dispatch({ type: 'peek', nodeType: type })}
onMouseLeave={() => dispatch({ type: 'peek', nodeType: null })}
className="-mx-2 flex items-center justify-between rounded-lg px-2 py-1 text-[13px] text-fg transition-colors duration-150 hover:bg-page"
>
<span className="flex items-center gap-2.5">
<span aria-hidden="true" className="size-2.5 rounded-full" style={{ background: `var(--sm-${type}-accent)` }} />
{TYPE_LABELS[type]}
Expand Down Expand Up @@ -375,7 +383,7 @@ export function Inspector({
const narrow = placement === 'auto';
if (collapsed && toggle) {
return (
<aside aria-label="Inspector" className={`shrink-0 rounded-[20px] bg-panel p-1.5 ${narrow ? NARROW_COLLAPSED : ''}`} style={PANEL_STYLE}>
<aside aria-label="Inspector" className={`shrink-0 self-start rounded-2xl bg-panel p-1.5 ${narrow ? NARROW_COLLAPSED : ''}`} style={PANEL_STYLE}>
{toggle}
</aside>
);
Expand Down
23 changes: 12 additions & 11 deletions packages/viewer/src/chrome/Toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,10 @@ export function Toolbar({
theme: ThemeChoice;
onToggleTheme: () => void;
onPresent?: () => void;
/** focus mode (Z): the canvas alone, and in it a button that hides the toolbar (H) */
/** focus mode (Z): the canvas alone */
focused?: boolean;
onFocus?: () => void;
/** a button that hides the toolbar (H) */
onHide?: () => void;
/** the export menu; a page that embeds the viewer has no file of its own to export */
exports?: boolean;
Expand Down Expand Up @@ -156,19 +157,19 @@ export function Toolbar({
<Presentation size={17} strokeWidth={1.75} />
</IconButton>
)}
{/* A phone's toolbar has no room for it; Z still works there with a keyboard. */}
{onFocus && (
<span className="contents @max-md:hidden">
{/* A phone's toolbar has no room for these; Z and H still work there with a keyboard. */}
<span className="contents @max-md:hidden">
{onFocus && (
<IconButton label={focused ? 'Leave focus (Z)' : 'Focus on the canvas (Z)'} pressed={focused} onClick={onFocus}>
<Focus size={17} strokeWidth={1.75} />
</IconButton>
</span>
)}
{focused && onHide && (
<IconButton label="Hide the toolbar (H)" onClick={onHide}>
<EyeOff size={17} strokeWidth={1.75} />
</IconButton>
)}
)}
{onHide && (
<IconButton label="Hide the toolbar (H)" onClick={onHide}>
<EyeOff size={17} strokeWidth={1.75} />
</IconButton>
)}
</span>
<IconButton label={`Switch to ${next} theme`} onClick={onToggleTheme}>
{theme === 'dark' ? <Sun size={17} strokeWidth={1.75} /> : <Moon size={17} strokeWidth={1.75} />}
</IconButton>
Expand Down
30 changes: 23 additions & 7 deletions packages/viewer/src/chrome/ViewerShell.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,14 +59,30 @@ describe('ViewerShell', () => {
expect(screen.getByText('100%')).toBeInTheDocument(); // zero-size stage in jsdom → identity transform
});

it('the identity card’s details button opens the inspector on the diagram, leaving any selection', () => {
innerWidth = 900;
it('names the diagram once, in the header: no title card on the canvas', () => {
render(<ViewerShell diagram={commerceApiLayout} theme="light" onToggleTheme={() => {}} />);
fireEvent.click(screen.getByRole('button', { name: 'Orders, Database' }));
fireEvent.click(screen.getByRole('button', { name: 'Diagram details' }));
const inspector = screen.getByRole('complementary', { name: 'Inspector' });
expect(within(inspector).getByRole('heading', { level: 2, name: 'Commerce API' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Orders, Database' })).toHaveAttribute('aria-pressed', 'false');
expect(screen.queryByRole('button', { name: 'Diagram details' })).toBeNull();
expect(screen.getAllByText('Commerce API')).toHaveLength(2); // the header and the inspector
});

it('hovering a legend row dims every other type until the pointer leaves', () => {
const { container } = render(<ViewerShell diagram={commerceApiLayout} theme="light" onToggleTheme={() => {}} />);
const row = within(screen.getByRole('list', { name: 'Legend' })).getByText('Service').closest('li')!;
const lit = () => [...container.querySelectorAll('.sm-card')].filter((c) => c.getAttribute('data-emphasis') !== 'dim').length;
expect(lit()).toBe(6);
fireEvent.mouseEnter(row);
expect(lit()).toBe(3);
fireEvent.mouseLeave(row);
expect(lit()).toBe(6);
});

it('H hides the toolbar outside focus too, and the button beside it does the same', () => {
render(<ViewerShell diagram={commerceApiLayout} theme="light" onToggleTheme={() => {}} />);
fireEvent.keyDown(window, { key: 'h' });
expect(screen.queryByRole('button', { name: 'Search nodes (/)' })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: 'Show the toolbar (H)' }));
fireEvent.click(screen.getByRole('button', { name: 'Hide the toolbar (H)' }));
expect(screen.queryByRole('button', { name: 'Search nodes (/)' })).toBeNull();
});

it('has no editor controls', () => {
Expand Down
52 changes: 15 additions & 37 deletions packages/viewer/src/chrome/ViewerShell.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Eye } from 'lucide-react';
import { Eye, Workflow } from 'lucide-react';
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { KIND_LABELS, type LaidOutDiagram } from '@stackmap/core';
import { CanvasPanel } from '../canvas/CanvasPanel';
Expand All @@ -7,7 +7,6 @@ import { ExploreProvider, useExplore } from '../explore/ExploreContext';
import { readLiveConfig, readShown } from '../live';
import { revealChrome } from '../motion/motion';
import type { ThemeChoice } from '../theme/theme';
import { IdentityCard } from './IdentityCard';
import { Inspector } from './Inspector';
import { PresentBar } from './Presentation';
import { inMenu } from './Toolbar';
Expand All @@ -20,20 +19,6 @@ const INSPECTOR_BREAKPOINT = 1100;

const DIAGRAM_ID = 'sm-diagram';

/** The identity card's details button shows the diagram itself in the inspector, whatever was selected. */
function DiagramIdentity({ onDetails }: { onDetails: () => void }) {
const { draft, dispatch } = useExplore();
return (
<IdentityCard
draft={draft}
onDetails={() => {
dispatch({ type: 'clear' });
onDetails();
}}
/>
);
}

function ViewCaption() {
const { draft, state } = useExplore();
const caption = draft.views?.find((v) => v.id === state.view)?.caption;
Expand Down Expand Up @@ -66,7 +51,7 @@ export function ViewerShell({
if (on) void shell.current?.requestFullscreen?.().catch(() => {});
else if (document.fullscreenElement) void document.exitFullscreen().catch(() => {});
}, []);
// Focus (Z): the canvas alone in the window, everything on it still working; H hides the toolbar too.
// Focus (Z): the canvas alone in the window, everything on it still working. H hides the toolbar, in focus or not.
const [focused, setFocused] = useState(false);
const [toolbarHidden, setToolbarHidden] = useState(false);
const focus = useCallback((on: boolean) => {
Expand All @@ -80,7 +65,6 @@ export function ViewerShell({
const key = e.key.toLowerCase();
if ((key !== 'f' && key !== 'z' && key !== 'h') || e.metaKey || e.ctrlKey || e.altKey || e.repeat || inMenu(t) || (t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName)))) return;
if (key !== 'f' && presenting) return;
if (key === 'h' && !focused) return;
e.preventDefault();
if (key === 'f') present(!presenting);
else if (key === 'z') focus(!focused);
Expand Down Expand Up @@ -114,6 +98,9 @@ export function ViewerShell({
{!presenting && !focused && (
<header className="mx-8 mt-4 flex gap-8 border-b border-divider @max-2xl:mx-4 @max-2xl:mt-2 @max-md:flex-wrap @max-md:gap-x-4 @max-md:gap-y-0">
<div className="flex max-w-[45%] min-w-0 items-center gap-3 py-3 @max-md:max-w-full @max-md:basis-full @max-md:pb-1">
<span aria-hidden="true" className="grid size-8 shrink-0 place-items-center rounded-[10px] bg-panel text-fg-muted" style={{ boxShadow: 'inset 0 0 0 1px var(--sm-panel-border)' }}>
<Workflow size={16} strokeWidth={1.75} />
</span>
<Title className="truncate text-[20px] leading-7 font-semibold tracking-tight">{draft.title}</Title>
<span
className="shrink-0 rounded-lg bg-panel px-2 py-0.5 text-[13px] text-fg-muted"
Expand All @@ -129,7 +116,7 @@ export function ViewerShell({
</header>
)}
{!presenting && !focused && <ViewCaption />}
<Main className={`relative flex min-h-0 flex-1 gap-4 @max-2xl:gap-2 ${presenting ? '' : focused ? 'p-3' : 'px-8 pt-4 pb-6 @max-2xl:px-3 @max-2xl:pt-3 @max-2xl:pb-3'}`}>
<Main className={`relative flex min-h-0 flex-1 gap-3 @max-2xl:gap-2 ${presenting ? '' : focused ? 'p-3' : 'px-8 pt-4 pb-6 @max-2xl:px-3 @max-2xl:pt-3 @max-2xl:pb-3'}`}>
<section
id={DIAGRAM_ID}
role="tabpanel"
Expand All @@ -138,31 +125,22 @@ export function ViewerShell({
style={presenting ? undefined : { boxShadow: 'inset 0 0 0 1px var(--sm-panel-border)' }}
>
<DiagramCanvas diagram={diagram} chrome={!presenting} stage={focused ? 'focus' : undefined}>
{/* Never wider than the stage: the identity card gives way (truncating, then hidden) before the toolbar. */}
<CanvasPanel position="top-left" className="flex max-w-[calc(100%-30px)] gap-2">
<CanvasPanel position="top-left" className="flex max-w-[calc(100%-30px)]">
{toolbarHidden ? (
<div className={`${PANEL_CLASS} p-1.5`} style={PANEL_STYLE}>
<IconButton label="Show the toolbar (H)" onClick={() => setToolbarHidden(false)}>
<Eye size={17} strokeWidth={1.75} />
</IconButton>
</div>
) : (
<>
{/* Narrow, the header already names the diagram: the toolbar gets the room. Focus shows the canvas alone. */}
{!focused && (
<div className="flex min-w-0 @max-[640px]/stage:hidden">
<DiagramIdentity onDetails={() => setInspectorCollapsed(false)} />
</div>
)}
<Toolbar
theme={theme}
onToggleTheme={onToggleTheme}
onPresent={() => present(true)}
focused={focused}
onFocus={() => focus(!focused)}
onHide={() => setToolbarHidden(true)}
/>
</>
<Toolbar
theme={theme}
onToggleTheme={onToggleTheme}
onPresent={() => present(true)}
focused={focused}
onFocus={() => focus(!focused)}
onHide={() => setToolbarHidden(true)}
/>
)}
</CanvasPanel>
</DiagramCanvas>
Expand Down
6 changes: 0 additions & 6 deletions packages/viewer/src/embed.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { useRef, type ReactNode } from 'react';
import { afterEach, describe, expect, it } from 'vitest';
import { commerceApiLayout } from './samples/commerce-api.layout';
import { DiagramCanvas } from './canvas/DiagramCanvas';
import { IdentityCard } from './chrome/IdentityCard';
import { Inspector } from './chrome/Inspector';
import { Toolbar } from './chrome/Toolbar';
import { ExploreProvider, useExplore } from './explore/ExploreContext';
Expand Down Expand Up @@ -139,11 +138,6 @@ describe('embedding the viewer', () => {
expect(screen.getByRole('button', { name: 'Route between two nodes (R)' })).toBeInTheDocument();
});

it('draws the identity card without a details button when there is nothing to open', () => {
render(<IdentityCard draft={draft} />);
expect(screen.queryByRole('button', { name: 'Diagram details' })).toBeNull();
});

it('gives the inspector no toggle when it cannot collapse, and lets the page add to it', () => {
render(
<ExploreProvider draft={draft} syncHash={false}>
Expand Down
15 changes: 12 additions & 3 deletions packages/viewer/src/explore/emphasis.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,21 @@ describe('emphasis', () => {
expect([...e.edges.values()].every((x) => !x.dim && x.tint === null)).toBe(true);
});

it('selection focuses the node and tints its direct edges with the source type', () => {
it('selection focuses the node and its neighbours, tints its direct edges and dims the rest', () => {
const e = run({ selected: 'db' });
expect(e.nodes.get('db')).toBe('focus');
expect(e.nodes.get('web')).toBe('normal');
expect(e.nodes.get('api')).toBe('normal');
expect(e.nodes.get('web')).toBe('dim');
expect(e.nodes.get('mail')).toBe('dim');
expect(e.edges.get('e2')).toEqual({ dim: false, tint: 'service' });
expect(e.edges.get('e1')).toEqual({ dim: false, tint: null });
expect(e.edges.get('e1')).toEqual({ dim: true, tint: null });
});

it('peeking at a type dims every other type and every edge leaving it, whatever else is on', () => {
const e = run({ peek: 'service', selected: 'db', query: 'web' });
expect([...e.nodes.entries()].filter(([, v]) => v !== 'dim').map(([k]) => k)).toEqual(['api']);
expect([...e.edges.values()].every((x) => x.dim && x.tint === null)).toBe(true);
expect(e.active).toBe(true);
});

it('trace dims everything outside the selection’s upstream and downstream', () => {
Expand Down
Loading
Loading