fix(frontend): quiet shared summary scope chrome

This commit is contained in:
rcourtman
2026-04-02 18:09:41 +01:00
parent e279c2a5f9
commit bb6ec99e62
5 changed files with 128 additions and 35 deletions
@@ -153,7 +153,7 @@ work extends shared components instead of creating new local variants.
8. Keep summary chart interaction identity on one shared helper. Summary surfaces that expose row-hover, group-hover, chart-hover, or route-focus-driven chart emphasis must derive page/group/entity scope through `frontend-modern/src/components/shared/summaryCardInteraction.ts` and pass that same resolved scope into card-state, sparkline, and density-map primitives, rather than letting cards read `hovered || focused` while charts listen to a different page-local ID source. Hovering one summary chart must promote that series into the shared active entity so sibling cards highlight the same object instead of keeping chart-local hover islands, and hovering or pinning a workload group header, infrastructure cluster header, or storage pool-group header must scope the matching summary cards through that same shared contract instead of forking a page-local summary filter path. Sibling cards should surface that synchronized hover as one compact header readout through the shared summary-card contract, while the chart under the pointer keeps the only floating tooltip. `frontend-modern/src/components/Recovery/RecoverySummary.tsx` is explicitly outside this interaction dialect: recovery posture cards may share summary framing, but they must not silently grow row/group/chart hover behavior without a separate governed product decision.
9. Keep page summaries page-scoped when table rows enter contextual focus. Route-backed row selection may add a focused label and shared series emphasis, but infrastructure, workloads, and storage summary cards must continue to render the page-level series set instead of collapsing the summary down to the selected row or replacing the global trend view with row-local empty states.
10. Keep contextual row focus on the shared summary primitive. Summary surfaces and same-route table drill-ins must reuse `frontend-modern/src/components/shared/contextualFocus.ts` for interactive-series filtering, focused-name lookup, active-series derivation, local scroll preservation, and deliberate inline-detail reveal instead of rebuilding page-local `Set` filters, focused-label scans, drawer-aware scroll math, or ad hoc scroll restoration in each surface.
11. Keep summary-to-table coordination deliberate, explicit, and reversible. Shared summary hover may highlight the matching table row when it is already visible, but transient chart hover must not auto-filter tables, auto-scroll the page, or reshuffle table ordering. When a page/group/entity scope is active on workloads, infrastructure, or storage, the page shell must surface that state through the shared `frontend-modern/src/components/shared/SummaryScopeBar.tsx` plus `frontend-modern/src/components/shared/summaryScopePresentation.ts` contract instead of inventing page-local chips, breadcrumbs, or hidden route-only focus. That shared scope bar must distinguish transient preview from pinned focus, keep the current scope visible even when the sticky summary is collapsed or off-screen, and expose a clear reset path for pinned scope so touch-only operators do not depend on hover or “click the same row again” discovery. When the active row is off-screen, page owners must still route through `frontend-modern/src/components/shared/summaryTableFocus.ts` and surface a lightweight `Jump to row` affordance that reveals and scrolls only on explicit user action. Deliberate row focus may reveal inline detail automatically, but that reveal must be drawer-aware: preserve already-good positions, avoid hard centering, and scroll only enough to keep the row header plus the top of the inline detail visible.
11. Keep summary-to-table coordination deliberate, explicit, and reversible. Shared summary hover may highlight the matching table row when it is already visible, but transient chart hover must not auto-filter tables, auto-scroll the page, or reshuffle table ordering. When a page/group/entity scope is active on workloads, infrastructure, or storage, the page shell must surface that state through the shared `frontend-modern/src/components/shared/SummaryScopeBar.tsx` plus `frontend-modern/src/components/shared/summaryScopePresentation.ts` contract instead of inventing page-local chips, breadcrumbs, or hidden route-only focus. That shared scope presenter must read like native page context, not a pill/badge widget: it should distinguish transient preview from pinned focus with quiet inline language, keep the current scope visible even when the sticky summary is collapsed or off-screen, and expose a clear reset path for pinned scope so touch-only operators do not depend on hover or “click the same row again” discovery. When the active row is off-screen, page owners must still route through `frontend-modern/src/components/shared/summaryTableFocus.ts` and surface a lightweight `Jump to row` affordance that reveals and scrolls only on explicit user action. Deliberate row focus may reveal inline detail automatically, but that reveal must be drawer-aware: preserve already-good positions, avoid hard centering, and scroll only enough to keep the row header plus the top of the inline detail visible.
Shared summary-linked rows and group headers must also route their preview
semantics through
`frontend-modern/src/components/shared/summaryInteractionA11y.ts` and
@@ -166,7 +166,7 @@ regression protection.
24. Extend dashboard shell rendering through `frontend-modern/src/components/Dashboard/DashboardStateCards.tsx`, `frontend-modern/src/components/Dashboard/DashboardWorkloadTable.tsx`, and `frontend-modern/src/components/Dashboard/DashboardStatsStrip.tsx` rather than accreting loading cards, workload table markup, or stats-strip presentation back into `frontend-modern/src/components/Dashboard/Dashboard.tsx`
25. Extend dashboard workload table shell ownership through `frontend-modern/src/components/Dashboard/WorkloadTableHeader.tsx` and `frontend-modern/src/components/Dashboard/WorkloadPanel.tsx` rather than rebuilding sortable header markup, grouped node rows, row expansion, or guest-drawer rendering inside `frontend-modern/src/components/Dashboard/DashboardWorkloadTable.tsx`
26. Keep long-range workload chart capping time-proportional across `frontend-modern/src/components/Workloads/WorkloadsSummary.tsx`, `frontend-modern/src/api/charts.ts`, and `internal/api/router.go`: when the workload hot path caps mixed-cadence history for top cards, it must bucket by time window rather than raw point index so 7-day and 30-day workload cards stay visually even without relaxing the protected payload budget.
27. Keep summary hover/focus and sticky-card behavior on shared hot paths: infrastructure, workloads, and storage summary shells must reuse one page/group/entity scope model plus `frontend-modern/src/components/shared/StickySummarySection.tsx` inside the app scroll shell instead of per-page scroll listeners or per-card hover derivations, so row scrubbing highlights all cards, workload group headers, infrastructure cluster headers, and storage pool-group headers scope the summary coherently, pinned group focus remains route-backed and reversible, and the hot path does not multiply render or scroll work. The explicit scope affordance for that hot path must also stay shared: `frontend-modern/src/components/shared/SummaryScopeBar.tsx` and `frontend-modern/src/components/shared/summaryScopePresentation.ts` own the visible page/group/entity scope label, preview-vs-pinned distinction, and reset affordance so touch-safe reversibility does not fork per-page banner logic. That same hot path must keep chart-backed summary-card geometry explicit and stable so hover rerenders, synchronized readouts, idle header metadata, or the scope bar itself cannot feed layout loops that grow or shrink the top cards over time. Recoverys summary rail is not part of this interactive hot path; it may share summary-card framing, but it must remain non-interactive until a separately governed model says otherwise.
27. Keep summary hover/focus and sticky-card behavior on shared hot paths: infrastructure, workloads, and storage summary shells must reuse one page/group/entity scope model plus `frontend-modern/src/components/shared/StickySummarySection.tsx` inside the app scroll shell instead of per-page scroll listeners or per-card hover derivations, so row scrubbing highlights all cards, workload group headers, infrastructure cluster headers, and storage pool-group headers scope the summary coherently, pinned group focus remains route-backed and reversible, and the hot path does not multiply render or scroll work. The explicit scope affordance for that hot path must also stay shared: `frontend-modern/src/components/shared/SummaryScopeBar.tsx` and `frontend-modern/src/components/shared/summaryScopePresentation.ts` own the visible page/group/entity scope label, preview-vs-pinned distinction, and reset affordance so touch-safe reversibility does not fork per-page banner logic. That affordance must stay visually quiet and inline with the summary shell rather than reintroducing chip, badge, or banner chrome that competes with the charts themselves. That same hot path must keep chart-backed summary-card geometry explicit and stable so hover rerenders, synchronized readouts, idle header metadata, or the scope bar itself cannot feed layout loops that grow or shrink the top cards over time. Recoverys summary rail is not part of this interactive hot path; it may share summary-card framing, but it must remain non-interactive until a separately governed model says otherwise.
The input path for that hot summary contract must stay shared too:
`frontend-modern/src/components/shared/summaryInteractionA11y.ts` owns
fine-pointer preview and focus-preview continuity, while
@@ -342,8 +342,12 @@ describe('shared primitive guardrails', () => {
expect(summaryScopePresentationSource).toContain('buildSummaryScopePresentation');
expect(summaryScopePresentationSource).toContain("mode: 'all'");
expect(summaryScopeBarSource).toContain('SummaryScopeBar');
expect(summaryScopeBarSource).toContain('Previewing');
expect(summaryScopeBarSource).toContain('Showing');
expect(summaryScopeBarSource).toContain('Pinned to');
expect(summaryScopeBarSource).toContain('Reset pinned scope');
expect(summaryScopeBarSource).not.toContain('rounded-full');
expect(summaryScopeBarSource).not.toContain('bg-surface-alt/60');
expect(summaryScopeBarSource).not.toContain('useLocation(');
expect(summaryScopeBarSource).not.toContain('useNavigate(');
});
@@ -11,25 +11,25 @@ interface SummaryScopeBarProps {
onReset?: () => void;
}
const badgeClassForMode = (mode: SummaryScopePresentation['mode']) => {
const leadClassForMode = (mode: SummaryScopePresentation['mode']) => {
switch (mode) {
case 'preview':
return 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900/60 dark:bg-amber-950/40 dark:text-amber-300';
return 'text-amber-700 dark:text-amber-300';
case 'pinned':
return 'border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-900/60 dark:bg-sky-950/40 dark:text-sky-300';
return 'text-sky-700 dark:text-sky-300';
default:
return 'border-border bg-surface text-muted';
return 'text-muted';
}
};
const labelForMode = (mode: SummaryScopePresentation['mode']) => {
const leadLabelForMode = (mode: SummaryScopePresentation['mode']) => {
switch (mode) {
case 'preview':
return 'Preview';
return 'Previewing';
case 'pinned':
return 'Pinned';
return 'Pinned to';
default:
return 'All';
return 'Showing';
}
};
@@ -50,33 +50,39 @@ export const SummaryScopeBar: Component<SummaryScopeBarProps> = (props) => {
return (
<div
data-testid={props.testId}
class={`rounded-md border border-border-subtle bg-surface-alt/60 px-3 py-2 ${props.class ?? ''}`.trim()}
data-summary-scope-mode={props.active.mode}
class={`flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 px-1 py-1 ${props.class ?? ''}`.trim()}
>
<div class="flex flex-wrap items-center gap-x-2 gap-y-1">
<span class="text-[10px] font-semibold uppercase tracking-[0.16em] text-muted">
Scope
</span>
<span
class={`inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.12em] ${badgeClassForMode(props.active.mode)}`.trim()}
<span
class={`shrink-0 text-[11px] font-semibold tracking-[0.01em] ${leadClassForMode(props.active.mode)}`.trim()}
>
{leadLabelForMode(props.active.mode)}
</span>
<span class="min-w-0 truncate text-sm font-medium text-base-content" title={props.active.label}>
{props.active.label}
</span>
<Show when={helperText()}>
{(text) => (
<>
<span aria-hidden="true" class="shrink-0 text-xs text-muted/70">
</span>
<span class="min-w-0 truncate text-xs text-muted" title={text()}>
{text()}
</span>
</>
)}
</Show>
<Show when={props.onReset}>
<button
type="button"
aria-label={props.resetLabel ?? 'Reset pinned scope'}
class="ml-auto shrink-0 text-xs font-medium text-muted transition-colors hover:text-base-content focus-visible:text-base-content"
onClick={() => props.onReset?.()}
>
{labelForMode(props.active.mode)}
</span>
<span class="min-w-0 truncate text-sm font-medium text-base-content">
{props.active.label}
</span>
<Show when={helperText()}>
{(text) => <span class="min-w-0 truncate text-xs text-muted">{text()}</span>}
</Show>
<Show when={props.onReset}>
<button
type="button"
class="ml-auto inline-flex items-center rounded-md border border-border px-2 py-1 text-xs font-medium text-base-content transition-colors hover:bg-surface"
onClick={() => props.onReset?.()}
>
{props.resetLabel ?? 'Reset pinned scope'}
</button>
</Show>
</div>
Reset
</button>
</Show>
</div>
);
};
@@ -0,0 +1,83 @@
import { fireEvent, render, screen } from '@solidjs/testing-library';
import { describe, expect, it, vi } from 'vitest';
import { SummaryScopeBar } from '@/components/shared/SummaryScopeBar';
import type { SummaryScopePresentation } from '@/components/shared/summaryScopePresentation';
const renderScopeBar = (active: SummaryScopePresentation, options?: {
idleHint?: string;
onReset?: () => void;
pinned?: SummaryScopePresentation | null;
}) =>
render(() => (
<SummaryScopeBar
testId="summary-scope"
active={active}
idleHint={options?.idleHint}
onReset={options?.onReset}
pinned={options?.pinned ?? null}
/>
));
describe('SummaryScopeBar', () => {
it('renders all scope as a quiet context line without a scope badge', () => {
renderScopeBar({
kind: 'page',
label: 'All workloads',
contextLabel: null,
mode: 'all',
}, {
idleHint: 'Tap a group or row to pin scope.',
});
const scope = screen.getByTestId('summary-scope');
expect(scope).toHaveTextContent('Showing');
expect(scope).toHaveTextContent('All workloads');
expect(scope).toHaveTextContent('Tap a group or row to pin scope.');
expect(screen.queryByText('Scope')).not.toBeInTheDocument();
expect(scope.querySelector('.rounded-full')).toBeNull();
});
it('distinguishes preview from a pinned fallback without reintroducing chrome', () => {
renderScopeBar(
{
kind: 'entity',
label: 'finance-jump-01',
contextLabel: 'Production cluster',
mode: 'preview',
},
{
pinned: {
kind: 'group',
label: 'Production cluster',
contextLabel: null,
mode: 'pinned',
},
},
);
const scope = screen.getByTestId('summary-scope');
expect(scope).toHaveTextContent('Previewing');
expect(scope).toHaveTextContent('finance-jump-01');
expect(scope).toHaveTextContent('Pinned to Production cluster');
expect(scope.querySelector('.rounded-full')).toBeNull();
});
it('keeps the reset affordance explicit but visually quiet', () => {
const onReset = vi.fn();
renderScopeBar(
{
kind: 'group',
label: 'tower',
contextLabel: null,
mode: 'pinned',
},
{ onReset },
);
const button = screen.getByRole('button', { name: 'Reset pinned scope' });
expect(button).toHaveTextContent('Reset');
fireEvent.click(button);
expect(onReset).toHaveBeenCalledTimes(1);
});
});