From 765b0bfaf0d551cac348b9d43cd6235269da8f11 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Wed, 26 Aug 2026 20:59:49 +0200 Subject: [PATCH 01/10] Spin while the parameter index loads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The build page announced "Loading the parameter index…" in static grey text for the seconds it takes to fetch and index the US tree, which reads more like a dead page than work in progress. Both waits on the page — the search index and the policy tree — now carry a spinner beside the same wording. Co-Authored-By: Claude Opus 5 --- .../flagship/ParameterTreeBrowser.tsx | 11 ++++++---- app/src/pages/flagship/Build.page.tsx | 21 ++++++++++++------- .../tests/unit/pages/flagship/Build.test.tsx | 10 +++++++++ changelog_entry.yaml | 4 +--- 4 files changed, 32 insertions(+), 14 deletions(-) diff --git a/app/src/components/flagship/ParameterTreeBrowser.tsx b/app/src/components/flagship/ParameterTreeBrowser.tsx index c4df0c27d..71d8eabf4 100644 --- a/app/src/components/flagship/ParameterTreeBrowser.tsx +++ b/app/src/components/flagship/ParameterTreeBrowser.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from 'react'; import { IconChevronDown, IconChevronRight, IconPlus } from '@tabler/icons-react'; -import { Text } from '@/components/ui'; +import { Spinner, Text } from '@/components/ui'; import { colors, spacing, typography } from '@/designTokens'; import { ParameterTreeNode } from '@/types/metadata'; @@ -57,9 +57,12 @@ export default function ParameterTreeBrowser({ if (!tree) { return ( - - Loading the policy tree… - +
+ + + Loading the policy tree… + +
); } diff --git a/app/src/pages/flagship/Build.page.tsx b/app/src/pages/flagship/Build.page.tsx index 9445121d3..a11fd1cc0 100644 --- a/app/src/pages/flagship/Build.page.tsx +++ b/app/src/pages/flagship/Build.page.tsx @@ -4,7 +4,7 @@ import { useSelector } from 'react-redux'; import ParameterSearchBox from '@/components/flagship/ParameterSearchBox'; import ParameterTreeBrowser from '@/components/flagship/ParameterTreeBrowser'; import WorkspaceLayout from '@/components/flagship/WorkspaceLayout'; -import { Button, Stack, Text, Title } from '@/components/ui'; +import { Button, Spinner, Stack, Text, Title } from '@/components/ui'; import { colors, spacing, typography } from '@/designTokens'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; import { addDraftProvision, provisionFromSearchEntry, useDraftReform } from '@/libs/draftReform'; @@ -96,15 +96,22 @@ export default function BuildPage() { }} /> ) : ( - - Loading the parameter index… - + + + Loading the parameter index… + + )}
diff --git a/app/src/tests/unit/pages/flagship/Build.test.tsx b/app/src/tests/unit/pages/flagship/Build.test.tsx index a0af54d52..7c7b5bbd9 100644 --- a/app/src/tests/unit/pages/flagship/Build.test.tsx +++ b/app/src/tests/unit/pages/flagship/Build.test.tsx @@ -35,4 +35,14 @@ describe('BuildPage', () => { expect(screen.queryByText(/loading the policy tree/i)).not.toBeInTheDocument(); }); + + test('given the parameter index has not loaded then a spinner stands in for the search box', () => { + // Given / When — the store starts empty, as it does on a cold load + render(); + + // Then + expect(screen.getByText(/loading the parameter index/i)).toBeInTheDocument(); + expect(screen.getAllByRole('status').length).toBeGreaterThan(0); + expect(screen.queryByRole('combobox', { name: /search parameters/i })).not.toBeInTheDocument(); + }); }); diff --git a/changelog_entry.yaml b/changelog_entry.yaml index 7b103d1eb..126c65c37 100644 --- a/changelog_entry.yaml +++ b/changelog_entry.yaml @@ -1,3 +1 @@ -- Name states in the parameter search filter, explain the contributed filter, and stop long parameter values from squeezing result labels -- Lead the build page with search, fold the policy tree behind a toggle, and open a search result's folder in the tree -- Let the draft reform panel fold away +- Show a spinner while the parameter index and policy tree load on the build page From a0acfa7295e001a9fc9998c15e551a763702fe74 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Thu, 27 Aug 2026 01:10:16 +0200 Subject: [PATCH 02/10] Dock the companions as one full-height right plane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The draft reform and a report's adjust rail are the same shape — a column of provisions beside the content — but they were built twice and sat differently: teal card versus grey card, chevron versus X, a floating pill versus a header toggle, 380px versus 340px, sticky at 0 versus at 24px. The adjust panel in particular read as a pop-up rather than part of the page. SidePanel now owns that chrome once: a column running the full height of the shell beside the content, scrolling its own body, folding to a spine that keeps its place in the layout rather than vanishing. Teal marks a panel holding unsaved work, so the colour means something instead of recording which component was written first. The draft opens by default and the report's adjust rail starts folded — the report is what you came for. 340px, not 380: at 380 the report row's 640px content column and the panel no longer shared a flex line at 1280px, and the panel wrapped underneath the report. Co-Authored-By: Claude Opus 5 --- .../components/flagship/ReformPreviewCard.tsx | 416 ++++++++---------- .../components/flagship/ReportAdjustPanel.tsx | 96 +--- app/src/components/flagship/SidePanel.tsx | 159 +++++++ .../components/flagship/WorkspaceLayout.tsx | 12 +- app/src/pages/flagship/BillReport.page.tsx | 2 +- app/src/pages/flagship/Report.page.tsx | 2 +- .../flagship/ReformPreviewCard.test.tsx | 21 +- .../flagship/ReportAdjustPanel.test.tsx | 6 +- .../components/flagship/SidePanel.test.tsx | 58 +++ changelog_entry.yaml | 2 +- 10 files changed, 419 insertions(+), 355 deletions(-) create mode 100644 app/src/components/flagship/SidePanel.tsx create mode 100644 app/src/tests/unit/components/flagship/SidePanel.test.tsx diff --git a/app/src/components/flagship/ReformPreviewCard.tsx b/app/src/components/flagship/ReformPreviewCard.tsx index 4b08abd65..54133933a 100644 --- a/app/src/components/flagship/ReformPreviewCard.tsx +++ b/app/src/components/flagship/ReformPreviewCard.tsx @@ -1,11 +1,4 @@ -import { useState } from 'react'; -import { - IconArrowRight, - IconChartBar, - IconChevronDown, - IconTrash, - IconX, -} from '@tabler/icons-react'; +import { IconArrowRight, IconChartBar, IconTrash, IconX } from '@tabler/icons-react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { getReformStore } from '@/api/reformStore'; import { Button, Stack, Text } from '@/components/ui'; @@ -24,6 +17,7 @@ import { } from '@/libs/draftReform'; import { formatCompactBreadcrumb } from '@/utils/parameterLabels'; import { formatValue } from '@/utils/parameterValues'; +import SidePanel from './SidePanel'; import ValueInput from './ValueInput'; const SOURCE_NOTES: Record = { @@ -108,256 +102,198 @@ export default function ReformPreviewCard({ draft }: { draft: DraftReform }) { }); const hasEditedValue = draft.provisions.some((p) => p.value !== p.baselineValue); - // The draft follows you across every page, so it has to be foldable - // when you want the room back — open by default, since an unseen draft - // is the thing this panel exists to prevent. - const [open, setOpen] = useState(true); const provisionCount = `${draft.provisions.length} provision${ draft.provisions.length === 1 ? '' : 's' }`; return ( - - - {open && ( -
- - - {draft.provisions.map((provision) => ( - + - - - {formatCompactBreadcrumb(provision.breadcrumb || provision.path)} - - - - - - {formatValue(provision.baselineValue, provision.unit)} → - - - - - ))} - - - - setDraftLabel(event.target.value)} - placeholder="Name this reform, e.g. CTC expansion 2026" - aria-label="Reform name" + {formatCompactBreadcrumb(provision.breadcrumb || provision.path)} + + + + - {!hasEditedValue && draft.provisions.length > 0 && ( - - Values match current law so far — edit a value above to make this a reform. + > + + {formatValue(provision.baselineValue, provision.unit)} → - )} + + + ))} + - - - {( - [ - { scope: 'national', label: 'Nationwide', enabled: true }, - { scope: 'household', label: 'A household', enabled: false }, - ] as const - ).map(({ scope, label, enabled }) => { - const active = draft.population.scope === scope; - return ( - - ); - })} - + + setDraftLabel(event.target.value)} + placeholder="Name this reform, e.g. CTC expansion 2026" + aria-label="Reform name" + style={{ + padding: `${spacing.sm} ${spacing.md}`, + border: `1px solid ${colors.border.light}`, + borderRadius: 8, + fontSize: typography.fontSize.sm, + fontFamily: typography.fontFamily.primary, + }} + /> + {!hasEditedValue && draft.provisions.length > 0 && ( + + Values match current law so far — edit a value above to make this a reform. + + )} + - - - - Baseline: current law · {new Date().getFullYear()} - - {runReport.error && ( - - {runReport.error} - - )} - + + + {( + [ + { scope: 'national', label: 'Nationwide', enabled: true }, + { scope: 'household', label: 'A household', enabled: false }, + ] as const + ).map(({ scope, label, enabled }) => { + const active = draft.population.scope === scope; + return ( + + ); + })} + - - {saveMutation.isError && ( - - Could not save the reform. Try again. - - )} - - - - - - -
- )} -
+ + + + Baseline: current law · {new Date().getFullYear()} + + {runReport.error && ( + + {runReport.error} + + )} + + + + {saveMutation.isError && ( + + Could not save the reform. Try again. + + )} + + + + + + + ); } diff --git a/app/src/components/flagship/ReportAdjustPanel.tsx b/app/src/components/flagship/ReportAdjustPanel.tsx index 9e6fe6f3a..e40ba4f3e 100644 --- a/app/src/components/flagship/ReportAdjustPanel.tsx +++ b/app/src/components/flagship/ReportAdjustPanel.tsx @@ -1,10 +1,5 @@ import { useState } from 'react'; -import { - IconAdjustments, - IconChartBar, - IconLayoutSidebarRightCollapse, - IconX, -} from '@tabler/icons-react'; +import { IconChartBar, IconX } from '@tabler/icons-react'; import { useQueryClient } from '@tanstack/react-query'; import { getReformStore } from '@/api/reformStore'; import { Button, Stack, Text } from '@/components/ui'; @@ -16,6 +11,7 @@ import { RunReportProvision } from '@/libs/flagship/runReport'; import { Reform } from '@/types/ingredients/Reform'; import { formatCompactBreadcrumb } from '@/utils/parameterLabels'; import { formatValue } from '@/utils/parameterValues'; +import SidePanel from './SidePanel'; import ValueInput from './ValueInput'; interface ReportAdjustPanelProps { @@ -54,9 +50,6 @@ export default function ReportAdjustPanel({ const runReport = useRunFlagshipReport(); const countryId = useCurrentCountry(); const queryClient = useQueryClient(); - // Collapsed by default: the report is the main event; adjusting is - // one click away on the edge tab. - const [collapsed, setCollapsed] = useState(true); const [removed, setRemoved] = useState>(new Set()); const [reconcileError, setReconcileError] = useState(null); const [isReconciling, setIsReconciling] = useState(false); @@ -124,87 +117,12 @@ export default function ReportAdjustPanel({ } }; - if (collapsed) { - return ( - - ); - } - return ( - - - - Adjust parameters - - - - {active.map((provision) => ( - + ); } diff --git a/app/src/components/flagship/SidePanel.tsx b/app/src/components/flagship/SidePanel.tsx new file mode 100644 index 000000000..94e5cf87b --- /dev/null +++ b/app/src/components/flagship/SidePanel.tsx @@ -0,0 +1,159 @@ +import { useState } from 'react'; +import { IconChevronDown, IconChevronsLeft } from '@tabler/icons-react'; +import { Text } from '@/components/ui'; +import { colors, spacing, typography } from '@/designTokens'; + +/** + * The flagship shell's right-hand plane. + * + * Both companions — the draft reform and a report's adjust rail — are + * the same shape: a column that runs the height of the page beside the + * content, scrolling its own body, folding to a slim tab that keeps its + * place in the layout. Panels differ in what they hold, not in how they + * sit, so the chrome lives here rather than twice. + * + * The shell's
is the scroll container, so full height means the + * viewport minus that element's padding. + */ +/** + * Narrow enough that a report's content column (640px basis) and this + * panel still share one flex line at 1280px — at 380 the row wrapped and + * the panel fell under the report. + */ +const PANEL_WIDTH = 340; +const TAB_WIDTH = 40; +/** StandardLayout pads
by 24px top and bottom. */ +const PANEL_HEIGHT = 'calc(100vh - 48px)'; + +interface SidePanelProps { + /** Header text, and the label on the folded tab. */ + title: string; + /** Right-hand note in the header: source, count, whatever is short. */ + meta?: string; + /** Teal chrome, for a panel holding unsaved work. */ + accent?: boolean; + /** Report companions start folded; the draft starts open. */ + defaultOpen?: boolean; + children: React.ReactNode; +} + +export default function SidePanel({ + title, + meta, + accent = false, + defaultOpen = true, + children, +}: SidePanelProps) { + const [open, setOpen] = useState(defaultOpen); + const headerBackground = accent ? colors.primary[50] : colors.gray[50]; + const titleColor = accent ? colors.primary[700] : colors.text.primary; + + if (!open) { + return ( + + ); + } + + return ( +
+ + {/* The body scrolls, not the page: the panel keeps its own height. */} +
{children}
+
+ ); +} diff --git a/app/src/components/flagship/WorkspaceLayout.tsx b/app/src/components/flagship/WorkspaceLayout.tsx index f79534ad1..5c13d6afd 100644 --- a/app/src/components/flagship/WorkspaceLayout.tsx +++ b/app/src/components/flagship/WorkspaceLayout.tsx @@ -40,15 +40,9 @@ export default function WorkspaceLayout({ children, wide = false }: WorkspaceLay
{children}
-
+ {/* The panel owns its width, height and folding; the layout + only decides that it sits here. */} +
diff --git a/app/src/pages/flagship/BillReport.page.tsx b/app/src/pages/flagship/BillReport.page.tsx index a0082cf10..fb12d452d 100644 --- a/app/src/pages/flagship/BillReport.page.tsx +++ b/app/src/pages/flagship/BillReport.page.tsx @@ -932,7 +932,7 @@ export default function BillReportPage({ billId: propId }: BillReportPageProps)
-
+
-
+
{ renderCard(); // When - await user.click(screen.getByRole('button', { name: /here's your draft reform/i })); + await user.click(screen.getByRole('button', { name: /collapse here's your draft reform/i })); - // Then — the count survives the fold; the editing controls do not - expect(screen.getByText('1 provision')).toBeInTheDocument(); + // Then — the folded spine keeps the panel's name; the controls go + expect( + screen.getByRole('button', { name: /open here's your draft reform/i }) + ).toBeInTheDocument(); expect(screen.queryByRole('button', { name: /run report/i })).not.toBeInTheDocument(); expect(screen.queryByLabelText('Reform name')).not.toBeInTheDocument(); }); @@ -163,11 +165,9 @@ describe('ReformPreviewCard', () => { const user = userEvent.setup(); seedDraft(); renderCard(); - const header = screen.getByRole('button', { name: /here's your draft reform/i }); - // When - await user.click(header); - await user.click(header); + await user.click(screen.getByRole('button', { name: /collapse here's your draft reform/i })); + await user.click(screen.getByRole('button', { name: /open here's your draft reform/i })); // Then expect(screen.getByRole('button', { name: /run report/i })).toBeInTheDocument(); @@ -180,9 +180,8 @@ describe('ReformPreviewCard', () => { renderCard(); // Then — an unseen draft is what this panel exists to prevent - expect(screen.getByRole('button', { name: /here's your draft reform/i })).toHaveAttribute( - 'aria-expanded', - 'true' - ); + expect( + screen.getByRole('button', { name: /collapse here's your draft reform/i }) + ).toHaveAttribute('aria-expanded', 'true'); }); }); diff --git a/app/src/tests/unit/components/flagship/ReportAdjustPanel.test.tsx b/app/src/tests/unit/components/flagship/ReportAdjustPanel.test.tsx index d8f10bc6a..8c883b03c 100644 --- a/app/src/tests/unit/components/flagship/ReportAdjustPanel.test.tsx +++ b/app/src/tests/unit/components/flagship/ReportAdjustPanel.test.tsx @@ -56,7 +56,7 @@ async function renderPanel() { ); // Collapsed by default — expand via the edge tab before interacting. - await userEvent.setup().click(screen.getByRole('button', { name: /adjust parameters/i })); + await userEvent.setup().click(screen.getByRole('button', { name: /open adjust parameters/i })); return result; } @@ -160,10 +160,10 @@ describe('ReportAdjustPanel', () => { const user = userEvent.setup(); await renderPanel(); - await user.click(screen.getByRole('button', { name: /collapse the adjust panel/i })); + await user.click(screen.getByRole('button', { name: /collapse adjust parameters/i })); expect(screen.queryByRole('button', { name: /recompute/i })).not.toBeInTheDocument(); - await user.click(screen.getByRole('button', { name: /adjust parameters/i })); + await user.click(screen.getByRole('button', { name: /open adjust parameters/i })); expect(screen.getByRole('button', { name: /recompute/i })).toBeInTheDocument(); }); }); diff --git a/app/src/tests/unit/components/flagship/SidePanel.test.tsx b/app/src/tests/unit/components/flagship/SidePanel.test.tsx new file mode 100644 index 000000000..c56ab7953 --- /dev/null +++ b/app/src/tests/unit/components/flagship/SidePanel.test.tsx @@ -0,0 +1,58 @@ +import { render, screen, userEvent } from '@test-utils'; +import { describe, expect, test } from 'vitest'; +import SidePanel from '@/components/flagship/SidePanel'; + +describe('SidePanel', () => { + test('given an open panel then its body and header meta show', () => { + render( + +

panel body

+
+ ); + + expect(screen.getByText('panel body')).toBeInTheDocument(); + expect(screen.getByText('2 provisions')).toBeInTheDocument(); + }); + + test('given the header is clicked then the panel folds to a titled spine', async () => { + const user = userEvent.setup(); + render( + +

panel body

+
+ ); + + await user.click(screen.getByRole('button', { name: /collapse adjust parameters/i })); + + // The spine keeps the panel's name and its place in the layout. + expect(screen.getByRole('button', { name: /open adjust parameters/i })).toBeInTheDocument(); + expect(screen.queryByText('panel body')).not.toBeInTheDocument(); + }); + + test('given defaultOpen false then the panel starts folded', () => { + render( + +

panel body

+
+ ); + + expect(screen.getByRole('button', { name: /open adjust parameters/i })).toHaveAttribute( + 'aria-expanded', + 'false' + ); + expect(screen.queryByText('panel body')).not.toBeInTheDocument(); + }); + + test('given a folded panel then reopening restores the body', async () => { + const user = userEvent.setup(); + render( + +

panel body

+
+ ); + + await user.click(screen.getByRole('button', { name: /open draft reform/i })); + + expect(screen.getByText('panel body')).toBeInTheDocument(); + }); +}); diff --git a/changelog_entry.yaml b/changelog_entry.yaml index 126c65c37..96851b370 100644 --- a/changelog_entry.yaml +++ b/changelog_entry.yaml @@ -1 +1 @@ -- Show a spinner while the parameter index and policy tree load on the build page +- Dock the draft reform and report adjust panels as a full-height right-hand plane that folds to a spine From e16fb8d9a8317f1333f8e799685418621f2243c2 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Thu, 27 Aug 2026 01:13:36 +0200 Subject: [PATCH 03/10] Name the draft panel plainly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Here's your draft reform" narrates at the reader every time they open a page, and "Hand-built" beside it labelled the panel with trivia about how the draft started. The header now says what the panel is — Draft reform, or Editing reform — and nothing else. The source note stays in the model, where the report carries it as provenance. Co-Authored-By: Claude Opus 5 --- .../components/flagship/ReformPreviewCard.tsx | 10 +++++----- .../flagship/ReformPreviewCard.test.tsx | 17 ++++++++--------- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/app/src/components/flagship/ReformPreviewCard.tsx b/app/src/components/flagship/ReformPreviewCard.tsx index 54133933a..22c0d177d 100644 --- a/app/src/components/flagship/ReformPreviewCard.tsx +++ b/app/src/components/flagship/ReformPreviewCard.tsx @@ -20,6 +20,10 @@ import { formatValue } from '@/utils/parameterValues'; import SidePanel from './SidePanel'; import ValueInput from './ValueInput'; +/** + * How the draft came about, for the report's own record. This is + * provenance the report carries, not a label the panel needs to wear. + */ const SOURCE_NOTES: Record = { manual: 'Hand-built', chat: 'Drafted from your question', @@ -107,11 +111,7 @@ export default function ReformPreviewCard({ draft }: { draft: DraftReform }) { }`; return ( - + {draft.provisions.map((provision) => ( diff --git a/app/src/tests/unit/components/flagship/ReformPreviewCard.test.tsx b/app/src/tests/unit/components/flagship/ReformPreviewCard.test.tsx index a351d1c6c..522d07854 100644 --- a/app/src/tests/unit/components/flagship/ReformPreviewCard.test.tsx +++ b/app/src/tests/unit/components/flagship/ReformPreviewCard.test.tsx @@ -150,12 +150,10 @@ describe('ReformPreviewCard', () => { renderCard(); // When - await user.click(screen.getByRole('button', { name: /collapse here's your draft reform/i })); + await user.click(screen.getByRole('button', { name: /collapse draft reform/i })); // Then — the folded spine keeps the panel's name; the controls go - expect( - screen.getByRole('button', { name: /open here's your draft reform/i }) - ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /open draft reform/i })).toBeInTheDocument(); expect(screen.queryByRole('button', { name: /run report/i })).not.toBeInTheDocument(); expect(screen.queryByLabelText('Reform name')).not.toBeInTheDocument(); }); @@ -166,8 +164,8 @@ describe('ReformPreviewCard', () => { seedDraft(); renderCard(); // When - await user.click(screen.getByRole('button', { name: /collapse here's your draft reform/i })); - await user.click(screen.getByRole('button', { name: /open here's your draft reform/i })); + await user.click(screen.getByRole('button', { name: /collapse draft reform/i })); + await user.click(screen.getByRole('button', { name: /open draft reform/i })); // Then expect(screen.getByRole('button', { name: /run report/i })).toBeInTheDocument(); @@ -180,8 +178,9 @@ describe('ReformPreviewCard', () => { renderCard(); // Then — an unseen draft is what this panel exists to prevent - expect( - screen.getByRole('button', { name: /collapse here's your draft reform/i }) - ).toHaveAttribute('aria-expanded', 'true'); + expect(screen.getByRole('button', { name: /collapse draft reform/i })).toHaveAttribute( + 'aria-expanded', + 'true' + ); }); }); From db072299a866dda50246e86a3d24471cc5647312 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Thu, 27 Aug 2026 01:25:08 +0200 Subject: [PATCH 04/10] Make folder headers look clickable, and stop covering what they open Three faults in the reveal, all visible the first time you use it. The folder header gave no sign it was a control: same grey as the rows above it, no hover state. It now tints, underlines and slides its arrow on hover and focus, with an "open in tree" hint appearing beside it. The results list floated over the page, so opening a folder scrolled the tree underneath the very list that opened it. With the tree open the results now sit in the page flow and the tree renders below them. And the reveal quietly did nothing for bracketed parameters: a folder path of `...eitc.max[0]` names a node the tree has no row for, since brackets render inside their parent. The trailing index is dropped, and the scroll now waits for a render where the row exists rather than an animation frame that fires before it. Co-Authored-By: Claude Opus 5 --- .../flagship/ParameterSearchBox.tsx | 66 ++++++++++++++++--- .../flagship/ParameterTreeBrowser.tsx | 26 ++++++-- app/src/pages/flagship/Build.page.tsx | 3 + .../flagship/ParameterSearchBox.test.tsx | 37 +++++++++++ 4 files changed, 115 insertions(+), 17 deletions(-) diff --git a/app/src/components/flagship/ParameterSearchBox.tsx b/app/src/components/flagship/ParameterSearchBox.tsx index 6cef8358c..4c2f21b68 100644 --- a/app/src/components/flagship/ParameterSearchBox.tsx +++ b/app/src/components/flagship/ParameterSearchBox.tsx @@ -31,12 +31,29 @@ interface ParameterSearchBoxProps { * to the siblings that did not. */ onOpenFolder?: (folderPath: string) => void; + /** + * Render results in the page flow rather than floating over it. Set + * when something below needs to stay visible — a floating list would + * sit on top of the very folder it just opened. + */ + resultsInFlow?: boolean; } -/** The parent path of a leaf: gov.irs.credits.ctc.amount → gov.irs.credits.ctc. */ +/** + * The folder a leaf sits in: gov.irs.credits.ctc.amount → + * gov.irs.credits.ctc. + * + * Bracket indices are not nodes in the policy tree — it stops at + * `...eitc.max` and renders the brackets inside it — so a trailing + * `[n]` is dropped. Pointing at `...max[0]` names a folder the tree + * cannot reveal, and the reveal silently does nothing. + */ function parentPath(path: string): string | null { const lastDot = path.lastIndexOf('.'); - return lastDot > 0 ? path.slice(0, lastDot) : null; + if (lastDot <= 0) { + return null; + } + return path.slice(0, lastDot).replace(/\[\d+\]$/, ''); } const CONTRIB_EXPLANATION = @@ -110,10 +127,12 @@ export default function ParameterSearchBox({ clusters = [], stateLabels = {}, onOpenFolder, + resultsInFlow = false, index: providedIndex, }: ParameterSearchBoxProps) { const [query, setQuery] = useState(''); const [highlighted, setHighlighted] = useState(0); + const [hoveredFolder, setHoveredFolder] = useState(null); const [filters, setFilters] = useState(DEFAULT_SEARCH_FILTERS); const index = useMemo( @@ -285,16 +304,14 @@ export default function ParameterSearchBox({ id="parameter-search-results" role="listbox" style={{ - position: 'absolute', - top: '100%', - left: 0, - right: 0, - zIndex: 20, + ...(resultsInFlow + ? { position: 'relative' } + : { position: 'absolute', top: '100%', left: 0, right: 0, zIndex: 20 }), marginTop: spacing.xs, border: `1px solid ${colors.border.light}`, borderRadius: 10, background: colors.background.primary, - boxShadow: '0 8px 24px rgba(20, 32, 31, 0.12)', + boxShadow: resultsInFlow ? 'none' : '0 8px 24px rgba(20, 32, 31, 0.12)', maxHeight: 420, overflowY: 'auto', }} @@ -328,17 +345,28 @@ export default function ParameterSearchBox({
); } + const isHovered = hoveredFolder === folderPath; return ( ); })()} diff --git a/app/src/components/flagship/ParameterTreeBrowser.tsx b/app/src/components/flagship/ParameterTreeBrowser.tsx index 71d8eabf4..cbfcc3e20 100644 --- a/app/src/components/flagship/ParameterTreeBrowser.tsx +++ b/app/src/components/flagship/ParameterTreeBrowser.tsx @@ -40,21 +40,33 @@ export default function ParameterTreeBrowser({ }: ParameterTreeBrowserProps) { const [expanded, setExpanded] = useState>(new Set()); const containerRef = useRef(null); + const pendingScroll = useRef(null); useEffect(() => { if (!expandTo) { return; } + pendingScroll.current = expandTo; setExpanded((prev) => new Set([...prev, ...pathWithAncestors(expandTo)])); - // The rows for those ancestors only exist after the expansion renders. - const frame = requestAnimationFrame(() => { - const rows = containerRef.current?.querySelectorAll('[data-path]') ?? []; - const target = [...rows].find((row) => row.getAttribute('data-path') === expandTo); - target?.scrollIntoView({ block: 'center' }); - }); - return () => cancelAnimationFrame(frame); }, [expandTo]); + // The row only exists once the expansion has rendered, so the scroll + // waits for a render in which it is actually there — an animation + // frame scheduled alongside the state update fires too early and + // finds nothing. + useEffect(() => { + const target = pendingScroll.current; + if (!target) { + return; + } + const rows = containerRef.current?.querySelectorAll('[data-path]') ?? []; + const row = [...rows].find((candidate) => candidate.getAttribute('data-path') === target); + if (row) { + row.scrollIntoView({ block: 'center' }); + pendingScroll.current = null; + } + }, [expanded]); + if (!tree) { return (
diff --git a/app/src/pages/flagship/Build.page.tsx b/app/src/pages/flagship/Build.page.tsx index a11fd1cc0..ee1a7b2dd 100644 --- a/app/src/pages/flagship/Build.page.tsx +++ b/app/src/pages/flagship/Build.page.tsx @@ -84,6 +84,9 @@ export default function BuildPage() { stateLabels={stateLabels} index={searchIndex} onSelect={addEntry} + // With the tree open, a floating result list would cover + // the folder the reader just asked to see. + resultsInFlow={showTree} onOpenFolder={(folderPath) => { // Results stay up: the near miss is worth comparing // against whatever the folder turns out to hold. diff --git a/app/src/tests/unit/components/flagship/ParameterSearchBox.test.tsx b/app/src/tests/unit/components/flagship/ParameterSearchBox.test.tsx index 352063d31..39190cbbd 100644 --- a/app/src/tests/unit/components/flagship/ParameterSearchBox.test.tsx +++ b/app/src/tests/unit/components/flagship/ParameterSearchBox.test.tsx @@ -270,4 +270,41 @@ describe('ParameterSearchBox', () => { screen.queryByRole('button', { name: /open irs → credits → eitc in the policy tree/i }) ).not.toBeInTheDocument(); }); + + test('given a bracketed parameter then the folder path drops the bracket index', async () => { + // Given — the policy tree has no node for a bracket index, so a + // folder path pointing at one names something it cannot reveal + const user = userEvent.setup(); + const onOpenFolder = vi.fn(); + const bracketed: ParameterSearchEntry[] = [ + { + path: 'gov.irs.credits.eitc.max[0].threshold', + label: 'threshold', + breadcrumb: 'IRS → Credits → EITC → Maximum → Bracket 1 → Threshold', + unit: 'currency-USD', + description: null, + isContrib: false, + stateCode: null, + }, + { + path: 'gov.irs.credits.eitc.max[0].amount', + label: 'amount', + breadcrumb: 'IRS → Credits → EITC → Maximum → Bracket 1 → Amount', + unit: 'currency-USD', + description: null, + isContrib: false, + stateCode: null, + }, + ]; + render( + + ); + + // When + await user.type(screen.getByRole('combobox', { name: /search parameters/i }), 'bracket'); + await user.click(screen.getByRole('button', { name: /open .* in the policy tree/i })); + + // Then + expect(onOpenFolder).toHaveBeenCalledWith('gov.irs.credits.eitc.max'); + }); }); From 21478d53aaf7595b77dcaa1077b90db8f4bfe072 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Fri, 28 Aug 2026 13:16:25 +0200 Subject: [PATCH 05/10] Make the right plane a real shell column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The panel claimed to be a docked plane but was a floating viewport-tall box inside the scrolling content, and the review caught the ways that lie surfaced: its sticky had no travel (the wrapper shrink-wrapped to the panel's own height), so it scrolled away with the page; between ~710px and ~1010px of content width the folded spine fit inline but the opened panel wrapped below the entire report; and the hardcoded calc(100vh - 48px) overflowed the scrollport by WorkspaceLayout's 16px margin. The plane is now structural. StandardLayout's flagship shell renders a third column — left sidebar, scrolling main, right plane — and SidePanel portals into it, so full height and immunity to the content's scroll and wrapping follow from the layout rather than from tuned constants. WorkspaceLayout and the report pages stop hand-rolling rail geometry entirely. Where the slot does not exist (tests, the legacy shell) the panel renders in place. The rest of the review lands with it: - The draft's fold persists across navigations (sessionStorage, opt-in per storageKey) instead of springing back open on every page change. - The open header's chevron points down again; the extraction had hardcoded the folded rotation. - A reveal that cannot find its row clears immediately instead of arming a stale scroll that could yank the viewport minutes later and taxing every tree toggle with a full DOM scan. - Re-clicking a folder header reveals again (a sequence number rides along with the path, so same-path clicks are no longer state no-ops). - Folder hover is keyed by the rendered group, not the bracket-stripped path, so Bracket 1 and Bracket 2 headers no longer light up together. - Build keeps results in flow always rather than flipping anchoring mid-interaction under the pointer. - The "Open in tree" hint renders only on hover — invisible, it still reserved width and truncated folder names — and is sentence case. Co-Authored-By: Claude Fable 5 --- app/src/components/StandardLayout.tsx | 7 + .../flagship/ParameterSearchBox.tsx | 34 ++-- .../flagship/ParameterTreeBrowser.tsx | 21 ++- .../components/flagship/ReformPreviewCard.tsx | 6 +- app/src/components/flagship/SidePanel.tsx | 178 ++++++++++-------- .../components/flagship/WorkspaceLayout.tsx | 36 +--- app/src/pages/flagship/BillReport.page.tsx | 12 +- app/src/pages/flagship/Build.page.tsx | 17 +- app/src/pages/flagship/Report.page.tsx | 12 +- .../flagship/ReformPreviewCard.test.tsx | 3 + .../components/flagship/SidePanel.test.tsx | 44 ++++- 11 files changed, 222 insertions(+), 148 deletions(-) diff --git a/app/src/components/StandardLayout.tsx b/app/src/components/StandardLayout.tsx index 0e86b61fe..b21b31a0a 100644 --- a/app/src/components/StandardLayout.tsx +++ b/app/src/components/StandardLayout.tsx @@ -15,6 +15,7 @@ import { useDisclosure } from '@/hooks/useDisclosure'; import { cn } from '@/lib/utils'; import { isFlagshipShellEnabled } from '@/libs/featureFlags'; import FlagshipSidebar from './flagship/FlagshipSidebar'; +import { SIDE_PANEL_SLOT_ID } from './flagship/SidePanel'; import GiveCalcBanner from './shared/GiveCalcBanner'; import HeaderNavigation from './shared/HomeHeader'; import Sidebar from './Sidebar'; @@ -50,6 +51,12 @@ export default function StandardLayout({ children }: StandardLayoutProps) {
{children}
+ {/* The right plane: SidePanel portals its content here, so the + panel is a real column of the shell — outside the scrolling + content, full height by construction — rather than a + floating box inside it. Empty (zero width) on pages + without a companion. */} +
); diff --git a/app/src/components/flagship/ParameterSearchBox.tsx b/app/src/components/flagship/ParameterSearchBox.tsx index 4c2f21b68..0e3b80e69 100644 --- a/app/src/components/flagship/ParameterSearchBox.tsx +++ b/app/src/components/flagship/ParameterSearchBox.tsx @@ -345,14 +345,17 @@ export default function ParameterSearchBox({
); } - const isHovered = hoveredFolder === folderPath; + // Keyed by the group's breadcrumb, not the stripped + // folder path — bracket siblings share the path and + // would hover in lockstep. + const isHovered = hoveredFolder === group.folder; return ( ); })()} diff --git a/app/src/components/flagship/ParameterTreeBrowser.tsx b/app/src/components/flagship/ParameterTreeBrowser.tsx index cbfcc3e20..af8ae0479 100644 --- a/app/src/components/flagship/ParameterTreeBrowser.tsx +++ b/app/src/components/flagship/ParameterTreeBrowser.tsx @@ -18,6 +18,8 @@ interface ParameterTreeBrowserProps { * a near miss leads to the parameters around it. */ expandTo?: string | null; + /** Bump to repeat a reveal of the same path (state equality would swallow it). */ + expandSeq?: number; } /** Every ancestor path of a dotted parameter path, plus the path itself. */ @@ -37,6 +39,7 @@ export default function ParameterTreeBrowser({ addablePaths, draftPaths, expandTo = null, + expandSeq = 0, }: ParameterTreeBrowserProps) { const [expanded, setExpanded] = useState>(new Set()); const containerRef = useRef(null); @@ -47,8 +50,10 @@ export default function ParameterTreeBrowser({ return; } pendingScroll.current = expandTo; + // A new Set even when the contents are unchanged, so the scroll + // effect below re-fires for a repeat reveal of the same path. setExpanded((prev) => new Set([...prev, ...pathWithAncestors(expandTo)])); - }, [expandTo]); + }, [expandTo, expandSeq]); // The row only exists once the expansion has rendered, so the scroll // waits for a render in which it is actually there — an animation @@ -59,12 +64,14 @@ export default function ParameterTreeBrowser({ if (!target) { return; } - const rows = containerRef.current?.querySelectorAll('[data-path]') ?? []; - const row = [...rows].find((candidate) => candidate.getAttribute('data-path') === target); - if (row) { - row.scrollIntoView({ block: 'center' }); - pendingScroll.current = null; - } + // One attempt, then clear regardless: by this render the expansion + // has committed, so the row either exists now or never will — a + // armed leftover would rescan the tree on every later toggle and + // could yank the viewport to a stale target minutes on. + pendingScroll.current = null; + containerRef.current + ?.querySelector(`[data-path="${CSS.escape(target)}"]`) + ?.scrollIntoView({ block: 'center' }); }, [expanded]); if (!tree) { diff --git a/app/src/components/flagship/ReformPreviewCard.tsx b/app/src/components/flagship/ReformPreviewCard.tsx index 22c0d177d..f31b06e42 100644 --- a/app/src/components/flagship/ReformPreviewCard.tsx +++ b/app/src/components/flagship/ReformPreviewCard.tsx @@ -111,7 +111,11 @@ export default function ReformPreviewCard({ draft }: { draft: DraftReform }) { }`; return ( - + {draft.provisions.map((provision) => ( diff --git a/app/src/components/flagship/SidePanel.tsx b/app/src/components/flagship/SidePanel.tsx index 94e5cf87b..7ce001e38 100644 --- a/app/src/components/flagship/SidePanel.tsx +++ b/app/src/components/flagship/SidePanel.tsx @@ -1,32 +1,31 @@ -import { useState } from 'react'; -import { IconChevronDown, IconChevronsLeft } from '@tabler/icons-react'; +import { useEffect, useLayoutEffect, useState } from 'react'; +import { IconChevronDown } from '@tabler/icons-react'; +import { createPortal } from 'react-dom'; import { Text } from '@/components/ui'; import { colors, spacing, typography } from '@/designTokens'; /** - * The flagship shell's right-hand plane. + * The flagship shell's right plane. * * Both companions — the draft reform and a report's adjust rail — are - * the same shape: a column that runs the height of the page beside the - * content, scrolling its own body, folding to a slim tab that keeps its - * place in the layout. Panels differ in what they hold, not in how they - * sit, so the chrome lives here rather than twice. - * - * The shell's
is the scroll container, so full height means the - * viewport minus that element's padding. - */ -/** - * Narrow enough that a report's content column (640px basis) and this - * panel still share one flex line at 1280px — at 380 the row wrapped and - * the panel fell under the report. + * the same shape: a column beside the content, folding to a slim spine. + * The chrome lives here once, and the column is real: the panel portals + * into a slot that is a flex sibling of the shell's scrolling
+ * (see StandardLayout), so it runs the full height of the page by + * construction, never scrolls away with the content, and never wraps + * beneath it. Where the slot does not exist (tests, the legacy shell) + * the panel renders in place. */ +export const SIDE_PANEL_SLOT_ID = 'flagship-side-panel-slot'; + const PANEL_WIDTH = 340; -const TAB_WIDTH = 40; -/** StandardLayout pads
by 24px top and bottom. */ -const PANEL_HEIGHT = 'calc(100vh - 48px)'; +const SPINE_WIDTH = 40; + +/** Effects that must run before paint, without warning during SSR. */ +const useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect; interface SidePanelProps { - /** Header text, and the label on the folded tab. */ + /** Header text, and the label on the folded spine. */ title: string; /** Right-hand note in the header: source, count, whatever is short. */ meta?: string; @@ -34,79 +33,60 @@ interface SidePanelProps { accent?: boolean; /** Report companions start folded; the draft starts open. */ defaultOpen?: boolean; + /** + * Remember the fold across navigations under this key. The draft + * panel follows the reader between pages; without persistence every + * navigation would spring a deliberately folded panel back open. + */ + storageKey?: string; children: React.ReactNode; } +function readStoredOpen(storageKey: string | undefined, fallback: boolean): boolean { + if (!storageKey || typeof sessionStorage === 'undefined') { + return fallback; + } + const stored = sessionStorage.getItem(`side-panel-open:${storageKey}`); + return stored === null ? fallback : stored === 'true'; +} + export default function SidePanel({ title, meta, accent = false, defaultOpen = true, + storageKey, children, }: SidePanelProps) { - const [open, setOpen] = useState(defaultOpen); + const [open, setOpenState] = useState(() => readStoredOpen(storageKey, defaultOpen)); + const [slot, setSlot] = useState(null); + + useIsomorphicLayoutEffect(() => { + setSlot(document.getElementById(SIDE_PANEL_SLOT_ID)); + }, []); + + const setOpen = (next: boolean) => { + setOpenState(next); + if (storageKey && typeof sessionStorage !== 'undefined') { + sessionStorage.setItem(`side-panel-open:${storageKey}`, String(next)); + } + }; + + const borderColor = accent ? colors.primary[500] : colors.border.light; const headerBackground = accent ? colors.primary[50] : colors.gray[50]; const titleColor = accent ? colors.primary[700] : colors.text.primary; + const bodyId = `side-panel-body-${(storageKey ?? title).replace(/\W+/g, '-').toLowerCase()}`; - if (!open) { - return ( - - ); - } - - return ( + const content = open ? (
setOpen(false)} aria-expanded + aria-controls={bodyId} aria-label={`Collapse ${title}`} style={{ all: 'unset', @@ -134,7 +115,7 @@ export default function SidePanel({ )} - {/* The body scrolls, not the page: the panel keeps its own height. */} -
{children}
+ {/* The body scrolls, not the page: the plane keeps its own height. */} +
+ {children} +
+ ) : ( + ); + + // Into the shell's right-plane slot when it exists; in place otherwise. + return slot ? createPortal(content, slot) : content; } diff --git a/app/src/components/flagship/WorkspaceLayout.tsx b/app/src/components/flagship/WorkspaceLayout.tsx index 5c13d6afd..13dfdaf61 100644 --- a/app/src/components/flagship/WorkspaceLayout.tsx +++ b/app/src/components/flagship/WorkspaceLayout.tsx @@ -10,11 +10,10 @@ interface WorkspaceLayoutProps { } /** - * Shared layout for the working sections (Ask, Build, Reforms). With - * no draft the content sits alone in a centered column; the moment a - * draft exists it slides in as a sticky right panel — the panel only - * exists when there is something in it. Panes wrap to a single column - * on narrow screens. + * Shared layout for the working sections (Ask, Build, Reforms). The + * content sits in a centered column; the moment a draft exists its + * panel appears — rendered here, docked by SidePanel into the shell's + * right plane, so the layout never manages rail geometry itself. */ export default function WorkspaceLayout({ children, wide = false }: WorkspaceLayoutProps) { const draft = useDraftReform(); @@ -22,31 +21,10 @@ export default function WorkspaceLayout({ children, wide = false }: WorkspaceLay const hasDraft = Boolean(draft && draft.countryId === countryId && draft.provisions.length > 0); const contentWidth = wide ? 1400 : 760; - if (!hasDraft) { - return
{children}
; - } - return ( -
-
-
-
{children}
-
- {/* The panel owns its width, height and folding; the layout - only decides that it sits here. */} -
- - -
-
+
+ {children} + {hasDraft && }
); } diff --git a/app/src/pages/flagship/BillReport.page.tsx b/app/src/pages/flagship/BillReport.page.tsx index fb12d452d..e2ea04035 100644 --- a/app/src/pages/flagship/BillReport.page.tsx +++ b/app/src/pages/flagship/BillReport.page.tsx @@ -932,13 +932,11 @@ export default function BillReportPage({ billId: propId }: BillReportPageProps)
-
- -
+
); diff --git a/app/src/pages/flagship/Build.page.tsx b/app/src/pages/flagship/Build.page.tsx index ee1a7b2dd..5c5f9d4a0 100644 --- a/app/src/pages/flagship/Build.page.tsx +++ b/app/src/pages/flagship/Build.page.tsx @@ -41,7 +41,10 @@ export default function BuildPage() { // know what the thing is called, so it stays out of the way until asked for. const [showTree, setShowTree] = useState(false); // The folder a search result pointed at, revealed in the tree below. - const [treeFocus, setTreeFocus] = useState(null); + // The folder a search result pointed at; the counter makes every + // click a fresh reveal — the same path twice would otherwise be a + // state no-op and the tree would sit unmoved. + const [treeFocus, setTreeFocus] = useState<{ path: string; seq: number } | null>(null); // Store-memoized like the index: built once per metadata load, not // per navigation or render. @@ -84,14 +87,15 @@ export default function BuildPage() { stateLabels={stateLabels} index={searchIndex} onSelect={addEntry} - // With the tree open, a floating result list would cover - // the folder the reader just asked to see. - resultsInFlow={showTree} + // Always in flow on this page: a floating list would + // cover the tree, and flipping modes mid-interaction + // would shift rows under the pointer. + resultsInFlow onOpenFolder={(folderPath) => { // Results stay up: the near miss is worth comparing // against whatever the folder turns out to hold. setShowTree(true); - setTreeFocus(folderPath); + setTreeFocus((prev) => ({ path: folderPath, seq: (prev?.seq ?? 0) + 1 })); }} currentValueFor={(entry) => { const value = getCurrentValue(parameters?.[entry.path]?.values); @@ -159,7 +163,8 @@ export default function BuildPage() { tree={parameterTree} addablePaths={addablePaths} draftPaths={draftPaths} - expandTo={treeFocus} + expandTo={treeFocus?.path ?? null} + expandSeq={treeFocus?.seq ?? 0} onSelectLeaf={(path) => { const entry = entriesByPath.get(path); if (entry) { diff --git a/app/src/pages/flagship/Report.page.tsx b/app/src/pages/flagship/Report.page.tsx index 7a916b59a..0f4a3a0a7 100644 --- a/app/src/pages/flagship/Report.page.tsx +++ b/app/src/pages/flagship/Report.page.tsx @@ -320,13 +320,11 @@ export default function FlagshipReportPage({ userReportId: propId }: FlagshipRep
-
- -
+ ); diff --git a/app/src/tests/unit/components/flagship/ReformPreviewCard.test.tsx b/app/src/tests/unit/components/flagship/ReformPreviewCard.test.tsx index 522d07854..20585cb53 100644 --- a/app/src/tests/unit/components/flagship/ReformPreviewCard.test.tsx +++ b/app/src/tests/unit/components/flagship/ReformPreviewCard.test.tsx @@ -58,6 +58,9 @@ function renderCard() { describe('ReformPreviewCard', () => { beforeEach(() => { vi.clearAllMocks(); + // The panel remembers its fold in sessionStorage; a fold test must + // not leak a closed panel into the next test. + sessionStorage.clear(); clearDraftReform(); seedDraft(); }); diff --git a/app/src/tests/unit/components/flagship/SidePanel.test.tsx b/app/src/tests/unit/components/flagship/SidePanel.test.tsx index c56ab7953..c231681c5 100644 --- a/app/src/tests/unit/components/flagship/SidePanel.test.tsx +++ b/app/src/tests/unit/components/flagship/SidePanel.test.tsx @@ -1,8 +1,50 @@ import { render, screen, userEvent } from '@test-utils'; -import { describe, expect, test } from 'vitest'; +import { beforeEach, describe, expect, test } from 'vitest'; import SidePanel from '@/components/flagship/SidePanel'; describe('SidePanel', () => { + beforeEach(() => { + sessionStorage.clear(); + }); + + test('given a storageKey then the fold survives a remount', async () => { + const user = userEvent.setup(); + const { unmount } = render( + +

panel body

+
+ ); + await user.click(screen.getByRole('button', { name: /collapse draft reform/i })); + unmount(); + + // Remount, as a page navigation does — the fold must hold. + render( + +

panel body

+
+ ); + + expect(screen.getByRole('button', { name: /open draft reform/i })).toBeInTheDocument(); + expect(screen.queryByText('panel body')).not.toBeInTheDocument(); + }); + + test('given the shell slot exists then the panel renders into it', () => { + const slot = document.createElement('div'); + slot.id = 'flagship-side-panel-slot'; + document.body.appendChild(slot); + try { + render( + +

panel body

+
+ ); + + expect(slot.textContent).toContain('panel body'); + } finally { + slot.remove(); + } + }); + test('given an open panel then its body and header meta show', () => { render( From 0390fb16d2a39d9e905de5c7840a2604b45ed954 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Fri, 28 Aug 2026 15:12:35 +0200 Subject: [PATCH 06/10] Name the panel after the reform, and dress it like the sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The panel header said "Draft reform" — a category label — while the reform's actual name sat in an input a few rows down. The name is the identity, the way a document title is: the header (and the folded spine) now carry the reform's own name, live as it is typed, with "New reform" before one exists. A small uppercase kicker — Draft, or Editing — holds the state the old title carried. Visually the plane now mirrors the left sidebar instead of shouting next to it: the same flat gray surface, the same quiet border, the kicker set like the sidebar's section labels, and teal reserved for text the way the sidebar reserves it for the active item — no colored bands. Co-Authored-By: Claude Fable 5 --- .../components/flagship/ReformPreviewCard.tsx | 5 +- app/src/components/flagship/SidePanel.tsx | 68 +++++++++++++------ .../flagship/ReformPreviewCard.test.tsx | 26 +++++-- 3 files changed, 72 insertions(+), 27 deletions(-) diff --git a/app/src/components/flagship/ReformPreviewCard.tsx b/app/src/components/flagship/ReformPreviewCard.tsx index f31b06e42..b2ca62fe8 100644 --- a/app/src/components/flagship/ReformPreviewCard.tsx +++ b/app/src/components/flagship/ReformPreviewCard.tsx @@ -112,7 +112,10 @@ export default function ReformPreviewCard({ draft }: { draft: DraftReform }) { return ( diff --git a/app/src/components/flagship/SidePanel.tsx b/app/src/components/flagship/SidePanel.tsx index 7ce001e38..a9867e568 100644 --- a/app/src/components/flagship/SidePanel.tsx +++ b/app/src/components/flagship/SidePanel.tsx @@ -27,9 +27,16 @@ const useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : us interface SidePanelProps { /** Header text, and the label on the folded spine. */ title: string; + /** + * Small uppercase label above the title — the panel's kind ("Draft"), + * styled like the sidebar's section labels, so the title itself can be + * the thing's own name. + */ + kicker?: string; /** Right-hand note in the header: source, count, whatever is short. */ meta?: string; - /** Teal chrome, for a panel holding unsaved work. */ + /** Teal title, for a panel holding unsaved work — the same accent the + * sidebar gives its active item, not a colored band. */ accent?: boolean; /** Report companions start folded; the draft starts open. */ defaultOpen?: boolean; @@ -52,6 +59,7 @@ function readStoredOpen(storageKey: string | undefined, fallback: boolean): bool export default function SidePanel({ title, + kicker, meta, accent = false, defaultOpen = true, @@ -72,8 +80,8 @@ export default function SidePanel({ } }; - const borderColor = accent ? colors.primary[500] : colors.border.light; - const headerBackground = accent ? colors.primary[50] : colors.gray[50]; + // The plane mirrors the left sidebar: the same flat surface and quiet + // edge, with teal reserved for text — no colored bands. const titleColor = accent ? colors.primary[700] : colors.text.primary; const bodyId = `side-panel-body-${(storageKey ?? title).replace(/\W+/g, '-').toLowerCase()}`; @@ -86,9 +94,10 @@ export default function SidePanel({ minHeight: 0, display: 'flex', flexDirection: 'column', - borderLeft: `1px solid ${borderColor}`, - background: colors.background.primary, + borderLeft: `1px solid ${colors.border.light}`, + background: colors.gray[50], overflow: 'hidden', + fontFamily: typography.fontFamily.primary, }} > + {/* The primary verb gets the full row; the two secondary verbs + share the one below it. */} + + - diff --git a/app/src/components/flagship/SidePanel.tsx b/app/src/components/flagship/SidePanel.tsx index a9867e568..5ceb14872 100644 --- a/app/src/components/flagship/SidePanel.tsx +++ b/app/src/components/flagship/SidePanel.tsx @@ -20,6 +20,9 @@ export const SIDE_PANEL_SLOT_ID = 'flagship-side-panel-slot'; const PANEL_WIDTH = 340; const SPINE_WIDTH = 40; +/** Width eases over this; the two faces crossfade inside it. */ +const WIDTH_MS = 240; +const FADE_MS = 160; /** Effects that must run before paint, without warning during SSR. */ const useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect; @@ -85,125 +88,159 @@ export default function SidePanel({ const titleColor = accent ? colors.primary[700] : colors.text.primary; const bodyId = `side-panel-body-${(storageKey ?? title).replace(/\W+/g, '-').toLowerCase()}`; - const content = open ? ( + const reduceMotion = + typeof window !== 'undefined' && + window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; + + /** + * Both faces stay mounted so the fold can animate: the container's + * width eases between panel and spine while the faces crossfade. + * visibility (not just opacity) removes the hidden face from the + * accessibility tree and tab order, delayed so the fade-out is seen. + */ + const face = (visible: boolean): React.CSSProperties => ({ + opacity: visible ? 1 : 0, + visibility: visible ? 'visible' : 'hidden', + transition: reduceMotion + ? undefined + : `opacity ${FADE_MS}ms ease, visibility 0s linear ${visible ? 0 : FADE_MS}ms`, + }); + + const content = (
- - {/* The body scrolls, not the page: the plane keeps its own height. */} -
- {children} + {meta && ( + + {meta} + + )} + + {/* The body scrolls, not the page: the plane keeps its own height. */} +
+ {children} +
-
- ) : ( - + + + {title} + + + ); // Into the shell's right-plane slot when it exists; in place otherwise. diff --git a/app/src/tests/unit/components/flagship/ReformPreviewCard.test.tsx b/app/src/tests/unit/components/flagship/ReformPreviewCard.test.tsx index b41388ab7..8e1639265 100644 --- a/app/src/tests/unit/components/flagship/ReformPreviewCard.test.tsx +++ b/app/src/tests/unit/components/flagship/ReformPreviewCard.test.tsx @@ -155,10 +155,11 @@ describe('ReformPreviewCard', () => { // When await user.click(screen.getByRole('button', { name: /collapse new reform/i })); - // Then — the folded spine keeps the panel's name; the controls go + // Then — the folded spine keeps the panel's name; the controls fade + // out but stay mounted so the fold can animate expect(screen.getByRole('button', { name: /open new reform/i })).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: /run report/i })).not.toBeInTheDocument(); - expect(screen.queryByLabelText('Reform name')).not.toBeInTheDocument(); + expect(screen.getByText('Run report')).not.toBeVisible(); + expect(screen.getByLabelText('Reform name')).not.toBeVisible(); }); test('given a folded draft then clicking again restores the controls', async () => { @@ -171,8 +172,8 @@ describe('ReformPreviewCard', () => { await user.click(screen.getByRole('button', { name: /open new reform/i })); // Then - expect(screen.getByRole('button', { name: /run report/i })).toBeInTheDocument(); - expect(screen.getByLabelText('Reform name')).toBeInTheDocument(); + expect(screen.getByText('Run report')).toBeVisible(); + expect(screen.getByLabelText('Reform name')).toBeVisible(); }); test('given the default view then the draft is open', () => { diff --git a/app/src/tests/unit/components/flagship/SidePanel.test.tsx b/app/src/tests/unit/components/flagship/SidePanel.test.tsx index c231681c5..14b815807 100644 --- a/app/src/tests/unit/components/flagship/SidePanel.test.tsx +++ b/app/src/tests/unit/components/flagship/SidePanel.test.tsx @@ -25,7 +25,7 @@ describe('SidePanel', () => { ); expect(screen.getByRole('button', { name: /open draft reform/i })).toBeInTheDocument(); - expect(screen.queryByText('panel body')).not.toBeInTheDocument(); + expect(screen.getByText('panel body')).not.toBeVisible(); }); test('given the shell slot exists then the panel renders into it', () => { @@ -66,9 +66,10 @@ describe('SidePanel', () => { await user.click(screen.getByRole('button', { name: /collapse adjust parameters/i })); - // The spine keeps the panel's name and its place in the layout. + // The spine keeps the panel's name; the body fades out but stays + // mounted so the fold can animate — hidden, not removed. expect(screen.getByRole('button', { name: /open adjust parameters/i })).toBeInTheDocument(); - expect(screen.queryByText('panel body')).not.toBeInTheDocument(); + expect(screen.getByText('panel body')).not.toBeVisible(); }); test('given defaultOpen false then the panel starts folded', () => { @@ -82,7 +83,7 @@ describe('SidePanel', () => { 'aria-expanded', 'false' ); - expect(screen.queryByText('panel body')).not.toBeInTheDocument(); + expect(screen.getByText('panel body')).not.toBeVisible(); }); test('given a folded panel then reopening restores the body', async () => { @@ -95,6 +96,6 @@ describe('SidePanel', () => { await user.click(screen.getByRole('button', { name: /open draft reform/i })); - expect(screen.getByText('panel body')).toBeInTheDocument(); + expect(screen.getByText('panel body')).toBeVisible(); }); }); From cc19730a86f359f6142ced1cebecbd5f93100254 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Sun, 30 Aug 2026 11:16:28 +0200 Subject: [PATCH 08/10] Browse a folder inside the results, not in the tree below MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening a search result's folder revealed it in the policy tree — below the dropdown, off-screen from where the reader was looking, so the click appeared to do nothing. And the affordance was a hover-appearing hint that shifted the row. A folder header now flips the dropdown itself to the folder's contents: a back row, the folder's name with its parameter count, and every parameter it holds — the siblings the query missed are the point — each addable in place. Editing the query or pressing Escape steps back to the matches. The affordance is a constant chevron: folders open, rows add. The folder's display name is the longest breadcrumb prefix its contents share, since the clicked header can sit deeper than the folder itself (bracket indices fold into their parent). The tree-reveal machinery this replaces — expandTo, the reveal sequence, the pending scroll, the data-path hooks — is removed; ParameterTreeBrowser is a plain expand/collapse browser again. Co-Authored-By: Claude Fable 5 --- .../flagship/ParameterSearchBox.tsx | 528 ++++++++++++------ .../flagship/ParameterTreeBrowser.tsx | 52 +- app/src/pages/flagship/Build.page.tsx | 24 +- .../flagship/ParameterSearchBox.test.tsx | 102 ++-- .../flagship/ParameterTreeBrowser.test.tsx | 31 - 5 files changed, 419 insertions(+), 318 deletions(-) diff --git a/app/src/components/flagship/ParameterSearchBox.tsx b/app/src/components/flagship/ParameterSearchBox.tsx index 0e3b80e69..cd61cac5a 100644 --- a/app/src/components/flagship/ParameterSearchBox.tsx +++ b/app/src/components/flagship/ParameterSearchBox.tsx @@ -1,5 +1,11 @@ import { useMemo, useState } from 'react'; -import { IconArrowRight, IconFolder, IconInfoCircle, IconSearch } from '@tabler/icons-react'; +import { + IconArrowLeft, + IconChevronRight, + IconFolder, + IconInfoCircle, + IconSearch, +} from '@tabler/icons-react'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui'; import { colors, spacing, typography } from '@/designTokens'; import { @@ -25,12 +31,6 @@ interface ParameterSearchBoxProps { clusters?: string[][]; /** State code → name, so the scope filter reads "California", not "CA only" */ stateLabels?: Record; - /** - * Called with a folder's parameter path when its header is clicked. - * Search shows only the leaves that matched; this is how a reader gets - * to the siblings that did not. - */ - onOpenFolder?: (folderPath: string) => void; /** * Render results in the page flow rather than floating over it. Set * when something below needs to stay visible — a floating list would @@ -126,13 +126,19 @@ export default function ParameterSearchBox({ currentValueFor, clusters = [], stateLabels = {}, - onOpenFolder, resultsInFlow = false, index: providedIndex, }: ParameterSearchBoxProps) { const [query, setQuery] = useState(''); const [highlighted, setHighlighted] = useState(0); const [hoveredFolder, setHoveredFolder] = useState(null); + /** + * A folder opened from the results: the dropdown shows everything the + * folder holds, in place — search only surfaced the leaves that + * matched, and the siblings that did not are usually what a near miss + * needs. `folder` is the breadcrumb, kept to label rows relative to it. + */ + const [browsing, setBrowsing] = useState<{ path: string; folder: string } | null>(null); const [filters, setFilters] = useState(DEFAULT_SEARCH_FILTERS); const index = useMemo( @@ -152,11 +158,49 @@ export default function ParameterSearchBox({ () => groupSearchResults(searchParameters(index, query, RESULT_LIMIT, filters)), [index, query, filters] ); - const flatEntries = useMemo(() => groups.flatMap((group) => group.entries), [groups]); + const folderEntries = useMemo(() => { + if (!browsing) { + return []; + } + return entries + .filter( + (entry) => + entry.path.startsWith(`${browsing.path}.`) || entry.path.startsWith(`${browsing.path}[`) + ) + .sort((a, b) => a.path.localeCompare(b.path)); + }, [entries, browsing]); + + /** + * The folder's display name, as the longest breadcrumb prefix its + * contents share. The clicked header's label can be deeper than the + * folder itself — "… → Bracket 1" for a folder that also holds + * Bracket 2 — because bracket indices fold into their parent. + */ + const folderLabel = useMemo(() => { + if (folderEntries.length === 0) { + return browsing?.folder ?? ''; + } + let prefix = folderEntries[0].breadcrumb.split(' → ').slice(0, -1); + for (const entry of folderEntries.slice(1)) { + const segments = entry.breadcrumb.split(' → '); + let shared = 0; + while (shared < prefix.length && prefix[shared] === segments[shared]) { + shared += 1; + } + prefix = prefix.slice(0, shared); + } + return prefix.join(' → '); + }, [folderEntries, browsing]); + + const flatEntries = useMemo( + () => (browsing ? folderEntries : groups.flatMap((group) => group.entries)), + [browsing, folderEntries, groups] + ); const select = (entry: ParameterSearchEntry) => { onSelect(entry); setQuery(''); + setBrowsing(null); setHighlighted(0); }; @@ -174,7 +218,13 @@ export default function ParameterSearchBox({ event.preventDefault(); select(flatEntries[highlighted]); } else if (event.key === 'Escape') { - setQuery(''); + // Step out of the folder first; a second Escape clears the search. + if (browsing) { + setBrowsing(null); + setHighlighted(0); + } else { + setQuery(''); + } } }; @@ -280,6 +330,7 @@ export default function ParameterSearchBox({ value={query} onChange={(event) => { setQuery(event.target.value); + setBrowsing(null); setHighlighted(0); }} onKeyDown={handleKeyDown} @@ -299,7 +350,7 @@ export default function ParameterSearchBox({ /> - {flatEntries.length > 0 && ( + {(browsing || flatEntries.length > 0) && (
- {groups.map((group) => { - const isFolder = group.entries.length > 1 && group.folder; - return ( -
- {isFolder && - (() => { - const folderPath = parentPath(group.entries[0].path); - const headerStyle: React.CSSProperties = { + {browsing && ( + <> + +
+ + + + {folderLabel} + + + + {folderEntries.length} parameter{folderEntries.length === 1 ? '' : 's'} + +
+ {folderEntries.map((entry, i) => ( +
+
+ {entry.path} +
+ + ))} + + )} + {!browsing && + groups.map((group) => { + const isFolder = group.entries.length > 1 && group.folder; + return ( +
+ {isFolder && + (() => { + const folderPath = parentPath(group.entries[0].path); + const headerStyle: React.CSSProperties = { + display: 'flex', + alignItems: 'center', + gap: spacing.xs, + width: '100%', + padding: `${spacing.sm} ${spacing.lg} ${spacing.xs}`, + fontSize: typography.fontSize.xs, + fontFamily: typography.fontFamily.primary, + fontWeight: typography.fontWeight.semibold, + color: colors.text.secondary, + textAlign: 'left', + }; + // Only a folder with a resolvable path can be + // browsed; otherwise the header stays the label it was. + if (!folderPath) { + return ( +
+ + {group.folder} +
+ ); + } + // Keyed by the group's breadcrumb, not the stripped + // folder path — bracket siblings share the path and + // would hover in lockstep. + const isHovered = hoveredFolder === group.folder; + return ( + - ); - })()} - {group.entries.map((entry) => { - runningIndex += 1; - const i = runningIndex; - return ( - + ); + })()} + {group.entries.map((entry) => { + runningIndex += 1; + const i = runningIndex; + return ( +
-
- {entry.path} -
- - ); - })} -
- ); - })} + + {isFolder + ? capitalizeFirst(entry.label) + : entry.breadcrumb || entry.label} + + + {currentValueFor?.(entry) && ( + + {currentValueFor(entry)} + + )} + + + +
+ {entry.path} +
+ + ); + })} + + ); + })} )} diff --git a/app/src/components/flagship/ParameterTreeBrowser.tsx b/app/src/components/flagship/ParameterTreeBrowser.tsx index af8ae0479..eea86e382 100644 --- a/app/src/components/flagship/ParameterTreeBrowser.tsx +++ b/app/src/components/flagship/ParameterTreeBrowser.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react'; +import { useState } from 'react'; import { IconChevronDown, IconChevronRight, IconPlus } from '@tabler/icons-react'; import { Spinner, Text } from '@/components/ui'; import { colors, spacing, typography } from '@/designTokens'; @@ -12,20 +12,6 @@ interface ParameterTreeBrowserProps { addablePaths: Set; /** Paths already in the draft — shown with an "In draft" tag. */ draftPaths: Set; - /** - * Folder path to reveal — its ancestors expand, it opens, and it - * scrolls into view. Set when a search result's folder is opened, so - * a near miss leads to the parameters around it. - */ - expandTo?: string | null; - /** Bump to repeat a reveal of the same path (state equality would swallow it). */ - expandSeq?: number; -} - -/** Every ancestor path of a dotted parameter path, plus the path itself. */ -function pathWithAncestors(path: string): string[] { - const segments = path.split('.'); - return segments.map((_, index) => segments.slice(0, index + 1).join('.')); } /** @@ -38,41 +24,8 @@ export default function ParameterTreeBrowser({ onSelectLeaf, addablePaths, draftPaths, - expandTo = null, - expandSeq = 0, }: ParameterTreeBrowserProps) { const [expanded, setExpanded] = useState>(new Set()); - const containerRef = useRef(null); - const pendingScroll = useRef(null); - - useEffect(() => { - if (!expandTo) { - return; - } - pendingScroll.current = expandTo; - // A new Set even when the contents are unchanged, so the scroll - // effect below re-fires for a repeat reveal of the same path. - setExpanded((prev) => new Set([...prev, ...pathWithAncestors(expandTo)])); - }, [expandTo, expandSeq]); - - // The row only exists once the expansion has rendered, so the scroll - // waits for a render in which it is actually there — an animation - // frame scheduled alongside the state update fires too early and - // finds nothing. - useEffect(() => { - const target = pendingScroll.current; - if (!target) { - return; - } - // One attempt, then clear regardless: by this render the expansion - // has committed, so the row either exists now or never will — a - // armed leftover would rescan the tree on every later toggle and - // could yank the viewport to a stale target minutes on. - pendingScroll.current = null; - containerRef.current - ?.querySelector(`[data-path="${CSS.escape(target)}"]`) - ?.scrollIntoView({ block: 'center' }); - }, [expanded]); if (!tree) { return ( @@ -112,7 +65,6 @@ export default function ParameterTreeBrowser({
+ ) : ( + {crumb.label} + )} + + )) + ) : ( + + {folderLabel} + + )} - {folderEntries.length} parameter{folderEntries.length === 1 ? '' : 's'} + {folderView?.descendants.length ?? 0} parameter + {(folderView?.descendants.length ?? 0) === 1 ? '' : 's'}
- {folderEntries.map((entry, i) => ( + {/* The full path once, instead of repeated under every row. */} +
+ {browsing.path} +
+ {(folderView?.direct ?? []).map((entry, i) => ( - ))} + + + {name} + + + {sub.count} parameter{sub.count === 1 ? '' : 's'} + + {/* The constant affordance: folders open, rows add. */} + + + ); + })} )} {!browsing && diff --git a/app/src/pages/flagship/Build.page.tsx b/app/src/pages/flagship/Build.page.tsx index 6a661912e..e0f0743ef 100644 --- a/app/src/pages/flagship/Build.page.tsx +++ b/app/src/pages/flagship/Build.page.tsx @@ -83,6 +83,7 @@ export default function BuildPage() { stateLabels={stateLabels} index={searchIndex} onSelect={addEntry} + labelFor={(path) => parameters?.[path]?.label ?? null} // Always in flow on this page: a floating list would // cover the tree when it is open. resultsInFlow diff --git a/app/src/tests/unit/components/flagship/ParameterSearchBox.test.tsx b/app/src/tests/unit/components/flagship/ParameterSearchBox.test.tsx index b90ed7d8c..90d692546 100644 --- a/app/src/tests/unit/components/flagship/ParameterSearchBox.test.tsx +++ b/app/src/tests/unit/components/flagship/ParameterSearchBox.test.tsx @@ -51,6 +51,48 @@ const ENTRIES: ParameterSearchEntry[] = [ }, ]; +// A folder with both its own leaf and a nested subfolder, for the +// in-place folder browser: crumbs up, subfolder rows down. +const REFUNDABILITY_ENTRIES: ParameterSearchEntry[] = [ + { + path: 'gov.irs.credits.ctc.refundable.fully_refundable', + label: 'fully refundable', + breadcrumb: 'IRS → Credits → Child tax credit → Refundability → Fully refundable', + unit: 'bool', + description: null, + isContrib: false, + stateCode: null, + }, + { + path: 'gov.irs.credits.ctc.refundable.phase_in.rate', + label: 'rate', + breadcrumb: 'IRS → Credits → Child tax credit → Refundability → Phase-in → Rate', + unit: '/1', + description: null, + isContrib: false, + stateCode: null, + }, + { + path: 'gov.irs.credits.ctc.refundable.phase_in.threshold', + label: 'threshold', + breadcrumb: 'IRS → Credits → Child tax credit → Refundability → Phase-in → Threshold', + unit: 'currency-USD', + description: null, + isContrib: false, + stateCode: null, + }, +]; + +const NODE_LABELS: Record = { + 'gov.irs': 'IRS', + 'gov.irs.credits': 'Credits', + 'gov.irs.credits.ctc': 'Child tax credit', + 'gov.irs.credits.ctc.refundable': 'Refundability', + 'gov.irs.credits.ctc.refundable.phase_in': 'Phase-in', +}; + +const labelForNode = (path: string) => NODE_LABELS[path] ?? null; + describe('ParameterSearchBox', () => { test('given a matching query then results show breadcrumb and path', async () => { // Given @@ -313,6 +355,54 @@ describe('ParameterSearchBox', () => { expect(screen.queryByRole('button', { name: /back to matches/i })).not.toBeInTheDocument(); }); + test('given a breadcrumb crumb is clicked then the parent folder opens with subfolder rows', async () => { + // Given — browsing the Phase-in folder, reached from search + const user = userEvent.setup(); + render( + + ); + await user.type(screen.getByRole('combobox', { name: /search parameters/i }), 'phase-in'); + await user.click(screen.getAllByRole('button', { name: /^browse/i })[0]); + expect(screen.getByText('2 parameters')).toBeInTheDocument(); + + // When — stepping up one level via the breadcrumb + await user.click(screen.getByRole('button', { name: 'Refundability' })); + + // Then — the parent's own leaf shows, and Phase-in folds into a + // subfolder row instead of flattened arrow-prefixed rows + expect(screen.getByText('3 parameters')).toBeInTheDocument(); + expect(screen.getByText('Fully refundable')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /open phase-in/i })).toBeInTheDocument(); + expect(screen.queryByText('Phase-in → Rate')).not.toBeInTheDocument(); + }); + + test('given a subfolder row is clicked then the dropdown descends into it', async () => { + // Given — browsing the Refundability folder + const user = userEvent.setup(); + render( + + ); + await user.type(screen.getByRole('combobox', { name: /search parameters/i }), 'phase-in'); + await user.click(screen.getAllByRole('button', { name: /^browse/i })[0]); + await user.click(screen.getByRole('button', { name: 'Refundability' })); + + // When + await user.click(screen.getByRole('button', { name: /open phase-in/i })); + + // Then + expect(screen.getByText('2 parameters')).toBeInTheDocument(); + expect(screen.getByText('Rate')).toBeInTheDocument(); + expect(screen.getByText('Threshold')).toBeInTheDocument(); + }); + test('given escape inside a folder then it steps back to matches, not to empty', async () => { const user = userEvent.setup(); render(); From e68c5f5e1b3e4b6819551788e5c75a343f02599a Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Mon, 31 Aug 2026 10:33:10 +0200 Subject: [PATCH 10/10] Let the in-flow results list use the viewport, not a 420px dropdown cap On the Build page the results render in the page flow with most of the screen empty below them, yet kept the floating-dropdown height cap. In flow the cap is now 72vh; the floating variant stays 420. Co-Authored-By: Claude Fable 5 --- app/src/components/flagship/ParameterSearchBox.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/components/flagship/ParameterSearchBox.tsx b/app/src/components/flagship/ParameterSearchBox.tsx index bcf6962ee..3125e5029 100644 --- a/app/src/components/flagship/ParameterSearchBox.tsx +++ b/app/src/components/flagship/ParameterSearchBox.tsx @@ -439,7 +439,9 @@ export default function ParameterSearchBox({ borderRadius: 10, background: colors.background.primary, boxShadow: resultsInFlow ? 'none' : '0 8px 24px rgba(20, 32, 31, 0.12)', - maxHeight: 420, + // In flow the page scrolls, so the list can take most of the + // viewport; floating over other content it stays a dropdown. + maxHeight: resultsInFlow ? '72vh' : 420, overflowY: 'auto', }} >