23 KiB
Info Popover Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Replace the two broken metric-help affordances (the off-viewport MetricTooltip bubble and the native-title compare ? help) with one @floating-ui/react-backed InfoPopover that stays on-screen in any viewport and shows a custom (non-native) tooltip on desktop.
Architecture: One shared engine (InfoPopover) owns all Floating UI positioning and interactions. Two existing components become thin adapters over it: MetricTooltip keeps its public API (so ~24 detail-page call sites are untouched), and RowLabel in sectionShared.tsx swaps its native-title span for InfoPopover (so ~14 compare tip= call sites are untouched).
Tech Stack: Next.js 16, React 19, TypeScript, @floating-ui/react, CSS Modules, Jest + jsdom + Testing Library, Playwright (e2e).
Global Constraints
- All work happens in
nextjs-app/unless a path says otherwise. Commands below assume the working directory isnextjs-app/. - Path alias:
@/→nextjs-app/root (jestmoduleNameMapperand tsconfig). - Jest test locations: files under
__tests__/**or named*.test.tsx. Env isjest-environment-jsdom. - Trigger glyph is the circled ? everywhere (replaces ⓘ on detail pages).
- Trigger is a real
<button>with a min 24px tap target (WCAG 2.5.8) and an accessible name. InfoPopoverrendersnullwhen it has noplaincontent (preserves currentMetricTooltipbehaviour).- Positioning/interaction is delegated to Floating UI — do not hand-roll positioning, outside-click, or Escape handling.
- Run
npx tsc --noEmitandnpx jestgreen before every commit that changes code. - Never push to
main; this plan's branch isfeat/info-popover-tooltip.
Task 1: Add the @floating-ui/react dependency
Files:
- Modify:
nextjs-app/package.json(dependencies) - Modify:
nextjs-app/package-lock.json(generated)
Interfaces:
-
Consumes: nothing.
-
Produces:
@floating-ui/reactimportable — the hooksuseFloating,autoUpdate,offset,flip,shift,arrow,useHover,useFocus,useClick,useDismiss,useRole,useInteractions, and the componentFloatingPortal,FloatingArrow. -
Step 1: Install the package
Run:
npm install @floating-ui/react@^0.27.0
Expected: package.json gains "@floating-ui/react": "^0.27.0" under dependencies; package-lock.json updates; exit 0.
- Step 2: Verify it resolves
Run:
node -e "const f=require('@floating-ui/react'); console.log(typeof f.useFloating, typeof f.FloatingPortal, typeof f.useInteractions)"
Expected: function function function
- Step 3: Typecheck still passes
Run:
npx tsc --noEmit
Expected: no output (exit 0).
- Step 4: Commit
git add package.json package-lock.json
git commit -m "build(deps): add @floating-ui/react for the info popover"
Task 2: Build InfoPopover (the shared engine) with unit tests
Files:
- Create:
nextjs-app/components/InfoPopover.tsx - Create:
nextjs-app/components/InfoPopover.module.css - Test:
nextjs-app/__tests__/components/InfoPopover.test.tsx
Interfaces:
-
Consumes:
@floating-ui/react(Task 1). -
Produces:
export interface InfoPopoverProps { label?: string; // bold heading plain?: string; // body text (primary explanation) detail?: string; // muted supplementary line ariaLabel?: string; // accessible name for the trigger button } export function InfoPopover(props: InfoPopoverProps): JSX.Element | null;Behaviour: renders
nullifplainis falsy. Otherwise renders a<button>(the circled?) witharia-label={ariaLabel ?? 'More information'}andaria-expanded. When open, a portalledrole="tooltip"container showslabel(if any),plain, anddetail(if any). Opens on hover (100ms open / 0ms close) and focus on desktop, on click for touch; closes on outside-press and Escape. -
Step 1: Write the failing test
Create nextjs-app/__tests__/components/InfoPopover.test.tsx:
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { InfoPopover } from '@/components/InfoPopover';
describe('InfoPopover', () => {
it('renders nothing when there is no plain content', () => {
const { container } = render(<InfoPopover label="X" />);
expect(container).toBeEmptyDOMElement();
});
it('renders a labelled, collapsed trigger button', () => {
render(<InfoPopover plain="what it means" ariaLabel="Reading score" />);
const btn = screen.getByRole('button', { name: 'Reading score' });
expect(btn).toHaveAttribute('aria-expanded', 'false');
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
});
it('opens on click and shows label, plain and detail', async () => {
const user = userEvent.setup();
render(
<InfoPopover
label="Reading, Writing & Maths"
plain="% reaching the expected standard"
detail="National average ~60%"
ariaLabel="RWM"
/>,
);
await user.click(screen.getByRole('button', { name: 'RWM' }));
const tip = await screen.findByRole('tooltip');
expect(tip).toHaveTextContent('Reading, Writing & Maths');
expect(tip).toHaveTextContent('% reaching the expected standard');
expect(tip).toHaveTextContent('National average ~60%');
expect(screen.getByRole('button', { name: 'RWM' })).toHaveAttribute(
'aria-expanded',
'true',
);
});
it('closes again on a second click', async () => {
const user = userEvent.setup();
render(<InfoPopover plain="body" ariaLabel="Info" />);
const btn = screen.getByRole('button', { name: 'Info' });
await user.click(btn);
expect(await screen.findByRole('tooltip')).toBeInTheDocument();
await user.click(btn);
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
});
it('closes on Escape', async () => {
const user = userEvent.setup();
render(<InfoPopover plain="body" ariaLabel="Info" />);
await user.click(screen.getByRole('button', { name: 'Info' }));
expect(await screen.findByRole('tooltip')).toBeInTheDocument();
await user.keyboard('{Escape}');
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
});
it('defaults the accessible name when no ariaLabel is given', () => {
render(<InfoPopover plain="body" />);
expect(
screen.getByRole('button', { name: 'More information' }),
).toBeInTheDocument();
});
});
- Step 2: Run the test to verify it fails
Run:
npx jest __tests__/components/InfoPopover.test.tsx
Expected: FAIL — Cannot find module '@/components/InfoPopover'.
- Step 3: Write the component
Create nextjs-app/components/InfoPopover.tsx:
'use client';
import { useRef, useState } from 'react';
import {
useFloating,
autoUpdate,
offset,
flip,
shift,
arrow,
useHover,
useFocus,
useClick,
useDismiss,
useRole,
useInteractions,
FloatingPortal,
FloatingArrow,
} from '@floating-ui/react';
import styles from './InfoPopover.module.css';
export interface InfoPopoverProps {
label?: string;
plain?: string;
detail?: string;
ariaLabel?: string;
}
export function InfoPopover({ label, plain, detail, ariaLabel }: InfoPopoverProps) {
const [open, setOpen] = useState(false);
const arrowRef = useRef<SVGSVGElement>(null);
const { refs, floatingStyles, context } = useFloating({
open,
onOpenChange: setOpen,
placement: 'top',
whileElementsMounted: autoUpdate,
middleware: [
offset(8),
flip({ fallbackAxisSideDirection: 'start' }),
shift({ padding: 8 }),
arrow({ element: arrowRef, padding: 8 }),
],
});
// Hover (desktop) with a short open delay, keyboard focus, tap (touch),
// outside-press + Escape to dismiss. Floating UI disables hover on touch,
// so tap and hover never double-fire.
const hover = useHover(context, { delay: { open: 100, close: 0 } });
const focus = useFocus(context);
const click = useClick(context);
const dismiss = useDismiss(context);
const role = useRole(context, { role: 'tooltip' });
const { getReferenceProps, getFloatingProps } = useInteractions([
hover,
focus,
click,
dismiss,
role,
]);
if (!plain) return null;
return (
<>
<button
type="button"
ref={refs.setReference}
className={styles.icon}
aria-label={ariaLabel ?? 'More information'}
aria-expanded={open}
{...getReferenceProps()}
>
?
</button>
{open && (
<FloatingPortal>
<div
ref={refs.setFloating}
className={styles.tooltip}
style={floatingStyles}
{...getFloatingProps()}
>
<FloatingArrow ref={arrowRef} context={context} className={styles.arrow} />
{label && <span className={styles.label}>{label}</span>}
<span className={styles.plain}>{plain}</span>
{detail && <span className={styles.detail}>{detail}</span>}
</div>
</FloatingPortal>
)}
</>
);
}
- Step 4: Write the styles
Create nextjs-app/components/InfoPopover.module.css:
.icon {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 24px;
min-height: 24px;
margin: -6px 0;
padding: 0;
border: none;
background: none;
/* font-size:0 hides the button's own "?" text node; the ::before glyph
below carries the visible circled "?" at its own explicit size. */
font-size: 0;
color: var(--text-muted, #8a7a72);
cursor: help;
line-height: 1;
user-select: none;
transition: color 0.15s ease;
}
/* The visible affordance: a small circled "?" centred in the 24px target. */
.icon::before {
content: '?';
display: inline-flex;
align-items: center;
justify-content: center;
width: 15px;
height: 15px;
border-radius: 50%;
border: 1px solid currentColor;
font-size: 0.65rem;
}
.icon:hover,
.icon[aria-expanded='true'],
.icon:focus-visible {
color: var(--accent-coral-dark, #b04a2e);
}
.tooltip {
z-index: 9999;
width: max-content;
max-width: min(260px, calc(100vw - 24px));
background: var(--bg-primary, #faf7f2);
border: 1px solid var(--border-color, #e8ddd4);
border-radius: 10px;
box-shadow: 0 4px 16px rgba(44, 36, 32, 0.15);
padding: 0.6rem 0.75rem;
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.arrow {
fill: var(--bg-primary, #faf7f2);
stroke: var(--border-color, #e8ddd4);
stroke-width: 1px;
}
.label {
font-weight: 600;
font-size: 0.75rem;
color: var(--text-primary, #2c2420);
}
.plain {
font-size: 0.75rem;
color: var(--text-secondary, #5a4a44);
line-height: 1.4;
}
.detail {
font-size: 0.7rem;
color: var(--text-muted, #8a7a72);
line-height: 1.4;
margin-top: 0.1rem;
}
- Step 5: Run the tests to verify they pass
Run:
npx jest __tests__/components/InfoPopover.test.tsx
Expected: PASS (6 tests).
- Step 6: Typecheck
Run:
npx tsc --noEmit
Expected: no output (exit 0).
- Step 7: Commit
git add components/InfoPopover.tsx components/InfoPopover.module.css __tests__/components/InfoPopover.test.tsx
git commit -m "feat(ui): add InfoPopover — viewport-aware metric help via Floating UI"
Task 3: Reduce MetricTooltip to a thin adapter over InfoPopover
Files:
- Modify:
nextjs-app/components/MetricTooltip.tsx(full rewrite of body) - Delete:
nextjs-app/components/MetricTooltip.module.css - Test:
nextjs-app/__tests__/components/MetricTooltip.test.tsx(create)
Interfaces:
-
Consumes:
InfoPopover(Task 2),METRIC_EXPLANATIONSfrom@/lib/metrics. -
Produces:
MetricTooltipwith unchanged public props{ metricKey?: string; label?: string; plain?: string; detail?: string }. ResolvesmetricKey→METRIC_EXPLANATIONS[metricKey], with explicitlabel/plain/detailprops overriding the looked-up values. Passes the resolved label asInfoPopover'sariaLabel. -
Step 1: Write the failing test
Create nextjs-app/__tests__/components/MetricTooltip.test.tsx:
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MetricTooltip } from '@/components/MetricTooltip';
import { METRIC_EXPLANATIONS } from '@/lib/metrics';
describe('MetricTooltip', () => {
it('resolves content from a metricKey', async () => {
const key = Object.keys(METRIC_EXPLANATIONS)[0];
const exp = METRIC_EXPLANATIONS[key];
const user = userEvent.setup();
render(<MetricTooltip metricKey={key} />);
await user.click(screen.getByRole('button', { name: exp.label }));
const tip = await screen.findByRole('tooltip');
expect(tip).toHaveTextContent(exp.plain);
});
it('renders nothing for an unknown metricKey with no explicit content', () => {
const { container } = render(<MetricTooltip metricKey="__nope__" />);
expect(container).toBeEmptyDOMElement();
});
it('lets explicit props override the looked-up explanation', async () => {
const key = Object.keys(METRIC_EXPLANATIONS)[0];
const user = userEvent.setup();
render(<MetricTooltip metricKey={key} plain="custom text" />);
await user.click(screen.getByRole('button'));
expect(await screen.findByRole('tooltip')).toHaveTextContent('custom text');
});
});
- Step 2: Run the test to verify it fails
Run:
npx jest __tests__/components/MetricTooltip.test.tsx
Expected: FAIL — the old MetricTooltip renders a .tooltip span even when
collapsed (no role="tooltip" gating on open) / different structure, so
findByRole('tooltip') after click and the empty-render assertion diverge from
the new contract. (If any assertion happens to pass against the old component,
it still must fail overall before Step 3.)
- Step 3: Rewrite the component
Replace the entire contents of nextjs-app/components/MetricTooltip.tsx with:
'use client';
import { METRIC_EXPLANATIONS } from '@/lib/metrics';
import { InfoPopover } from './InfoPopover';
interface MetricTooltipProps {
metricKey?: string;
label?: string;
plain?: string;
detail?: string;
}
export function MetricTooltip({ metricKey, label, plain, detail }: MetricTooltipProps) {
const explanation = metricKey ? METRIC_EXPLANATIONS[metricKey] : undefined;
const resolvedLabel = label ?? explanation?.label;
return (
<InfoPopover
label={resolvedLabel}
plain={plain ?? explanation?.plain}
detail={detail ?? explanation?.detail}
ariaLabel={resolvedLabel ? `What does ${resolvedLabel} mean?` : undefined}
/>
);
}
- Step 4: Delete the now-unused stylesheet
Run:
git rm components/MetricTooltip.module.css
Expected: file staged for deletion. (The old component was its only importer;
InfoPopover.module.css supersedes it.)
- Step 5: Verify nothing else imports the deleted CSS
Run:
grep -rn "MetricTooltip.module.css" components app lib || echo "no importers"
Expected: no importers.
- Step 6: Run the tests to verify they pass
Run:
npx jest __tests__/components/MetricTooltip.test.tsx
Expected: PASS (3 tests).
- Step 7: Typecheck
Run:
npx tsc --noEmit
Expected: no output (exit 0).
- Step 8: Commit
git add components/MetricTooltip.tsx __tests__/components/MetricTooltip.test.tsx
git commit -m "refactor(ui): MetricTooltip delegates to InfoPopover (circled ? glyph)"
Task 4: Swap the compare ? help (RowLabel) to InfoPopover
Files:
- Modify:
nextjs-app/components/compare/sectionShared.tsx(RowLabel) - Modify:
nextjs-app/components/compare/compareSections.module.css(remove.help) - Test:
nextjs-app/__tests__/components/sectionShared.test.tsx(create)
Interfaces:
-
Consumes:
InfoPopover(Task 2). -
Produces:
RowLabel({ children, tip })renders the label text plus, whentipis set, anInfoPopoverwithplain={tip}.ariaLabelis omitted, so the trigger usesInfoPopover's default accessible name"More information"(the row label is arbitraryReactNode, so there is no clean string to derive a per-row name from).Measureand all comparetip=call sites are unchanged. -
Step 1: Write the failing test
Create nextjs-app/__tests__/components/sectionShared.test.tsx:
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { RowLabel } from '@/components/compare/sectionShared';
describe('RowLabel', () => {
it('renders its label text', () => {
render(<RowLabel>Attainment 8</RowLabel>);
expect(screen.getByText('Attainment 8')).toBeInTheDocument();
});
it('shows no help affordance when no tip is given', () => {
render(<RowLabel>Attainment 8</RowLabel>);
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
it('opens the tip in a popover on click', async () => {
const user = userEvent.setup();
render(<RowLabel tip="Average GCSE score across 8 subjects">Attainment 8</RowLabel>);
await user.click(screen.getByRole('button'));
expect(await screen.findByRole('tooltip')).toHaveTextContent(
'Average GCSE score across 8 subjects',
);
});
});
- Step 2: Run the test to verify it fails
Run:
npx jest __tests__/components/sectionShared.test.tsx
Expected: FAIL — the current RowLabel renders a <span title=...> (not a
button/role="tooltip"), so the click test fails.
- Step 3: Update
RowLabel
In nextjs-app/components/compare/sectionShared.tsx, add the import near the
other imports:
import { InfoPopover } from '@/components/InfoPopover';
Then replace the RowLabel function:
export function RowLabel({ children, tip }: { children: ReactNode; tip?: string }) {
return (
<div className={styles.rowLabel}>
{children}
{tip && <InfoPopover plain={tip} />}
</div>
);
}
- Step 4: Remove the dead
.helpstyle
In nextjs-app/components/compare/compareSections.module.css, delete the entire
.help { … } rule (the display: inline-flex; width: 15px; … flex: none;
block — the circled-? styling now lives in InfoPopover.module.css).
- Step 5: Confirm
.helpis unreferenced
Run:
grep -rn "styles.help\|\.help\b" components/compare || echo "no references"
Expected: no references.
- Step 6: Run the tests to verify they pass
Run:
npx jest __tests__/components/sectionShared.test.tsx
Expected: PASS (3 tests).
- Step 7: Full unit suite + typecheck
Run:
npx tsc --noEmit && npx jest
Expected: typecheck clean; all suites pass (existing + the 3 new suites).
- Step 8: Commit
git add components/compare/sectionShared.tsx components/compare/compareSections.module.css __tests__/components/sectionShared.test.tsx
git commit -m "refactor(compare): row-label help uses InfoPopover, not native title"
Task 5: E2E regression guard — popover stays within the mobile viewport
Files:
- Modify:
nextjs-app/../e2e/tests/journeys.spec.ts(add one test)
Interfaces:
-
Consumes: the running app (staging/local baseURL), the
twoPrimaryUrnshelper already defined injourneys.spec.ts. -
Produces: a Playwright test asserting an opened compare help popover's bounding box is fully within the viewport on a narrow screen.
-
Step 1: Add the failing-guard test
Append to nextjs-app/../e2e/tests/journeys.spec.ts (i.e. e2e/tests/journeys.spec.ts):
test('compare metric-help popover stays within the mobile viewport', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
const [urn0, urn1] = await twoPrimaryUrns(page);
await page.goto(`/compare?urns=${urn0},${urn1}`);
await expect(
page.getByRole('heading', { name: 'At a glance' }),
).toBeVisible({ timeout: 15_000 });
// The metric-help triggers are the circled-"?" buttons in the row labels.
// "More information" is InfoPopover's default accessible name.
const help = page.getByRole('button', { name: 'More information' }).first();
await expect(help).toBeVisible({ timeout: 15_000 });
await help.click();
const tip = page.getByRole('tooltip');
await expect(tip).toBeVisible();
// The whole bubble must sit inside the viewport — the original bug pushed it
// off the right edge with no way to scroll to it.
const box = await tip.boundingBox();
const width = page.viewportSize()!.width;
expect(box).not.toBeNull();
expect(box!.x).toBeGreaterThanOrEqual(0);
expect(box!.x + box!.width).toBeLessThanOrEqual(width);
// And the page must not have gained a horizontal scrollbar from the bubble.
const bodyOverflowsX = await page
.locator('body')
.evaluate((el) => el.scrollWidth > el.clientWidth + 1);
expect(bodyOverflowsX).toBe(false);
});
- Step 2: Lint/typecheck the e2e file
Run (from e2e/):
cd ../e2e && npx tsc --noEmit -p . 2>/dev/null || npx tsc --noEmit journeys 2>/dev/null; cd ../nextjs-app
Expected: no type errors reported for journeys.spec.ts. (If the e2e package
has no standalone tsconfig, this is a no-op; the CI Playwright run type-checks
on execution.)
- Step 3: Note on running e2e
The e2e journeys run against a deployed environment (staging) in CI, per
CLAUDE.md; they are not run locally here (no local server). This test will
execute in the staging gate after merge. Do not attempt to start a local server.
- Step 4: Commit
git add ../e2e/tests/journeys.spec.ts
git commit -m "test(e2e): compare help popover stays within the mobile viewport"
Task 6: Final verification and PR
Files: none (verification + PR).
- Step 1: Full typecheck + unit suite
Run (from nextjs-app/):
npx tsc --noEmit && npx jest 2>&1 | tail -8
Expected: typecheck clean; all suites pass.
- Step 2: Production build sanity (catches client/server boundary issues)
Run:
npx next build 2>&1 | tail -20
Expected: build completes without errors. (InfoPopover is a client
component — 'use client' — so this confirms the portal usage compiles.)
- Step 3: Confirm no stragglers reference removed APIs
Run:
grep -rn "MetricTooltip.module.css\|styles.help\|title={tip}" components app || echo "clean"
Expected: clean.
- Step 4: Push and open the PR
git push -u origin feat/info-popover-tooltip
Then open a Gitea PR (base main, head feat/info-popover-tooltip) via the
credential-helper + API pattern used in this repo, summarising: the two bugs
fixed (off-viewport mobile bubble; slow native-title desktop hover), the
unified InfoPopover approach, the circled-? standardisation, the new
@floating-ui/react dependency, and the mobile-viewport e2e guard.