Skip to content
Open
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
4 changes: 3 additions & 1 deletion calm-hub-ui/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,9 @@ restyled desktop while targeting mobile β€” don't.
menu) portal into the navbar `#navbar-actions` slot instead of floating in the
render pane. See `components/navbar/Navbar.tsx` and `diagram-section/DiagramSection.tsx`.
- **iOS-style drill-down explorer** on mobile (`tree-navigation/MobileNavMenu.tsx`):
one flat list per level, not a tree.
one flat list per level for `types`, `resources`, `domains` and `controls`. The
`namespaces` level is the exception β€” it renders as a nested tree, because the
hierarchy is already latent in the dotted names.
- **Full-bleed render pane** with the minimap/zoom controls hidden (pinch is the
native gesture) β€” see `visualizer/components/reactflow/`.
- **Tabbed detail views** where desktop stacks panels (e.g.
Expand Down
17 changes: 4 additions & 13 deletions calm-hub-ui/src/hub/Hub.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useLocation, useMatch, useNavigate } from 'react-router-dom';
import { IoChevronForwardOutline, IoCompassOutline } from 'react-icons/io5';
import { IoCompassOutline } from 'react-icons/io5';
import { ExploreRail } from './components/explore-rail/ExploreRail.js';
import { CollapsedRail } from './components/explore-rail/CollapsedRail.js';
import { MobileNavMenu } from './components/tree-navigation/MobileNavMenu.js';
import { NamespacePage } from './components/namespace-page/NamespacePage.js';
import { DomainPage } from './components/domain-page/DomainPage.js';
Expand Down Expand Up @@ -421,7 +422,7 @@ export default function Hub() {
<div className="relative flex flex-row flex-1 overflow-hidden bg-base-300">
{/* Desktop: inline, collapsible browse rail. */}
{!isMobile && (
<div className={`h-full shrink-0 ${isSidebarOpen ? '' : 'w-12 p-4 pr-2'} transition-all duration-300`}>
<div className={`h-full shrink-0 ${isSidebarOpen ? '' : 'w-12'} transition-all duration-300`}>
{isSidebarOpen ? (
<ExploreRail
namespaceCounts={namespaceCounts}
Expand All @@ -433,17 +434,7 @@ export default function Hub() {
onCollapse={() => setIsSidebarOpen(false)}
/>
) : (
<div className="h-full bg-base-100 rounded-box overflow-hidden shadow-xl flex flex-col">
<div className="flex items-center justify-center pt-3">
<button
aria-label="Expand sidebar"
className="btn btn-ghost btn-xs btn-circle"
onClick={() => setIsSidebarOpen(true)}
>
<IoChevronForwardOutline />
</button>
</div>
</div>
<CollapsedRail namespaceCounts={namespaceCounts} onExpand={() => setIsSidebarOpen(true)} />
)}
</div>
)}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { describe, expect, it, vi } from 'vitest';
import { CollapsedRail } from './CollapsedRail.js';
import { colors } from '../../../theme/colors.js';
import { redesignTokens } from '../../../theme/redesign-tokens.js';
import type { NamespaceCounts } from '../../../model/counts.js';

function nc(namespace: string, total: number): NamespaceCounts {
return { namespace, architectures: 0, patterns: 0, flows: 0, standards: 0, adrs: 0, interfaces: 0, total };
}

const namespaceCounts: NamespaceCounts[] = [nc('finos.calm', 5), nc('barclays.payments', 3), nc('acme', 2)];

const renderRail = (path = '/') => {
const onExpand = vi.fn();
const rail = <CollapsedRail namespaceCounts={namespaceCounts} onExpand={onExpand} />;
const utils = render(
<MemoryRouter initialEntries={[path]}>
<Routes>
{['/', '/namespace/:ns', '/:namespace/:type/:id/:version'].map((p) => (
<Route key={p} path={p} element={rail} />
))}
</Routes>
</MemoryRouter>
);
return { ...utils, onExpand };
};

describe('CollapsedRail', () => {
it('shows one initial per root namespace, in tree order', () => {
renderRail();
const initials = screen.getAllByRole('button', { name: /^(acme|barclays|finos)$/ });
expect(initials.map((b) => b.textContent)).toEqual(['A', 'B', 'F']);
});

it('calls onExpand from the expand-sidebar button', () => {
const { onExpand } = renderRail();
fireEvent.click(screen.getByLabelText('Expand sidebar'));
expect(onExpand).toHaveBeenCalled();
});

it('accents the root initial whose subtree contains the active namespace', () => {
renderRail('/namespace/finos.calm');
const finosInitial = screen.getByRole('button', { name: 'finos' });
expect(finosInitial).toHaveStyle({ backgroundColor: colors.redesign.tintBg, boxShadow: redesignTokens.shadow.railAccent });

const acmeInitial = screen.getByRole('button', { name: 'acme' });
expect(acmeInitial).not.toHaveStyle({ backgroundColor: colors.redesign.tintBg });
});

it('opens the fly-out for a root on hover', () => {
renderRail();
expect(screen.queryByText('calm')).not.toBeInTheDocument();

fireEvent.mouseEnter(screen.getByRole('button', { name: 'finos' }).parentElement!);
expect(screen.getByText('calm')).toBeInTheDocument();

fireEvent.mouseLeave(screen.getByRole('button', { name: 'finos' }).parentElement!);
expect(screen.queryByText('calm')).not.toBeInTheDocument();
});

it('opens the fly-out for a root on focus', () => {
renderRail();
const finosInitial = screen.getByRole('button', { name: 'finos' });
expect(screen.queryByText('calm')).not.toBeInTheDocument();

fireEvent.focus(finosInitial);
expect(screen.getByText('calm')).toBeInTheDocument();
});

it('closes the fly-out on Escape', () => {
renderRail();
const finosInitial = screen.getByRole('button', { name: 'finos' });
fireEvent.focus(finosInitial);
expect(screen.getByText('calm')).toBeInTheDocument();

fireEvent.keyDown(finosInitial, { key: 'Escape' });
expect(screen.queryByText('calm')).not.toBeInTheDocument();
});

it('renders a group-only root row inside the fly-out without a link', () => {
renderRail();
fireEvent.focus(screen.getByRole('button', { name: 'barclays' }));

// 'barclays' itself has no direct count (only barclays.payments does) β€”
// its own row inside the fly-out is text, not a link.
expect(screen.getByText('barclays', { selector: 'span' })).toBeInTheDocument();
expect(screen.queryByRole('link', { name: /^barclays$/ })).not.toBeInTheDocument();
expect(screen.getByRole('link', { name: /payments/ })).toHaveAttribute('href', '/namespace/barclays.payments');
});
});
153 changes: 153 additions & 0 deletions calm-hub-ui/src/hub/components/explore-rail/CollapsedRail.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { useMemo, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { IoChevronForwardOutline } from 'react-icons/io5';
import { NamespaceCounts } from '../../../model/counts.js';
import { colors } from '../../../theme/colors.js';
import { redesignTokens } from '../../../theme/redesign-tokens.js';
import { CountBadge } from './CountBadge.js';
import { buildNamespaceTree, flattenNamespaceTree, type NamespaceTreeNode } from './namespace-tree.js';

interface CollapsedRailProps {
namespaceCounts: NamespaceCounts[];
onExpand: () => void;
}

function isWithin(container: HTMLElement, target: EventTarget | null): boolean {
return target instanceof Node && container.contains(target);
}

function FlyoutRow({ node, depth, active }: { node: NamespaceTreeNode; depth: number; active: boolean }) {
const isNamespace = node.total !== null;
const style = active ? { color: colors.redesign.activeText } : { color: colors.redesign.bodyAlt };

const content = (
<>
<span className="min-w-0 flex-1 truncate">{node.segment}</span>
{node.total !== null && <CountBadge count={node.total} active={active} />}
</>
);

return (
<div
className="flex items-center gap-1 px-2 py-1 rounded-[7px] text-[13px]"
style={{
paddingLeft: 8 + depth * 14,
backgroundColor: active ? colors.redesign.tintBg : undefined,
boxShadow: active ? redesignTokens.shadow.railAccent : undefined,
}}
>
{isNamespace ? (
<Link to={`/namespace/${encodeURIComponent(node.path)}`} className="flex items-center gap-1 min-w-0 flex-1 no-underline" style={style}>
{content}
</Link>
) : (
<span className="flex items-center gap-1 min-w-0 flex-1 italic" style={{ color: colors.redesign.muted }}>
{content}
</span>
)}
</div>
);
}

interface RootInitialProps {
root: NamespaceTreeNode;
isActive: boolean;
isOpen: boolean;
activeNamespace?: string;
onOpen: () => void;
onClose: () => void;
}

function RootInitial({ root, isActive, isOpen, activeNamespace, onOpen, onClose }: RootInitialProps) {
const rows = useMemo(() => flattenNamespaceTree([root], { collapsed: new Set(), filtering: false, visible: new Set() }), [root]);

return (
<div
className="relative"
onMouseEnter={onOpen}
onMouseLeave={onClose}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

onMouseLeave closes the fly-out unconditionally, unlike onBlur just below it, which checks isWithin before closing. A hybrid mouse+keyboard user who opens the fly-out via Tab (onFocus) and then nudges the mouse across/off the container will have it closed even though focus is still logically inside. Might be worth mirroring the isWithin check here, or dropping onMouseLeave in favor of onBlur.

onFocus={onOpen}
onBlur={(e) => {
if (!isWithin(e.currentTarget, e.relatedTarget)) onClose();
}}
onKeyDown={(e) => {
if (e.key === 'Escape') onClose();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Escape closes the fly-out unconditionally, which unmounts the {isOpen && (...)} block. If a keyboard user has tabbed onto one of the fly-out's Link rows, that element disappears and the browser drops focus to <body> β€” nothing here returns focus to the trigger button. Worth keeping a ref to the trigger and calling .focus() on it after closing (the same pattern onBlur already uses for detecting focus movement).

}}
>
<button
type="button"
aria-label={root.path}
aria-haspopup="true"
aria-expanded={isOpen}
className="flex items-center justify-center font-semibold text-[13px] rounded-[7px] border-0 cursor-pointer focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-interaction)]"
style={{
width: 32,
height: 24,
backgroundColor: isActive ? colors.redesign.tintBg : 'transparent',
boxShadow: isActive ? redesignTokens.shadow.railAccent : undefined,
color: isActive ? colors.redesign.activeText : colors.redesign.bodyAlt,
transition: redesignTokens.transition,
}}
>
{root.segment.charAt(0).toUpperCase()}
</button>

{isOpen && (
<div
className="absolute top-0 left-full ml-1 flex flex-col gap-0.5 p-1.5 rounded-[12px] z-50"
style={{
width: 220,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This panel has a fixed width: 220 and no maxHeight/overflowY, and rows is built with collapsed: new Set() β€” so it always renders the full subtree regardless of what the user collapsed on the main rail. For a root with a large or deep subtree this can overflow the viewport with no way to scroll. A capped maxHeight plus overflowY: 'auto' would be a cheap safety net.

backgroundColor: colors.redesign.surface,
border: `1px solid ${colors.redesign.border}`,
boxShadow: redesignTokens.shadow.floating,
}}
>
{rows.map((row) => (
<FlyoutRow key={row.node.path} node={row.node} depth={row.depth} active={row.node.path === activeNamespace} />
))}
</div>
)}
</div>
);
}

/**
* The rail collapsed to a 24px strip of per-root initials. Each initial opens a
* fully-flattened fly-out of its subtree on hover or focus β€” there is no collapse
* state in the fly-out, it is transient.
*/
export function CollapsedRail({ namespaceCounts, onExpand }: CollapsedRailProps) {
// Matches ExploreRail: on the detail route the param is `namespace`, so the accent
// survives a detail session just as the expanded rail's highlight does.
const { ns, namespace } = useParams<{ ns?: string; namespace?: string }>();
const activeNamespace = ns ?? namespace;
const [openPath, setOpenPath] = useState<string | null>(null);
const tree = useMemo(() => buildNamespaceTree(namespaceCounts), [namespaceCounts]);

return (
<div
className="h-full w-full flex flex-col items-center"
style={{ backgroundColor: colors.redesign.surfaceAlt, borderRight: `1px solid ${colors.redesign.border}` }}
>
<div className="flex items-center justify-center pt-3 pb-2">
<button aria-label="Expand sidebar" className="btn btn-ghost btn-xs btn-circle" onClick={onExpand}>
<IoChevronForwardOutline />
</button>
</div>

<div className="flex flex-col items-center gap-1.5">
{tree.map((root) => (
<RootInitial
key={root.path}
root={root}
isActive={activeNamespace === root.path || activeNamespace?.startsWith(`${root.path}.`) === true}
isOpen={openPath === root.path}
activeNamespace={activeNamespace}
onOpen={() => setOpenPath(root.path)}
onClose={() => setOpenPath((prev) => (prev === root.path ? null : prev))}
/>
))}
</div>
</div>
);
}
Loading
Loading