From ce2941cbfe01d7f3f7f10eef1913bd3b87be8fd1 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Wed, 26 Aug 2026 10:00:00 +0200 Subject: [PATCH 1/4] Fix parameter search result layout and filter legibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things the search surface got wrong: A result row gave its value column unlimited width, so a list-valued parameter (a dozen variable names) starved the label beside it and stacked "Child Tax Credit added social security taxes" one word per line. The label now holds its row and the value truncates, with the full text on hover. The scope filter listed states as "UT only". It now names them — "Utah" — read from the model's own region labels rather than a name list, falling back to the code when metadata does not name one. "Contributed" named a filter without saying what it filtered. An adjacent tooltip explains that these are proposed reforms and experimental provisions rather than current law. Co-Authored-By: Claude Opus 5 --- .../flagship/ParameterSearchBox.tsx | 97 +++++++++++++++---- app/src/libs/metadataUtils.ts | 14 +++ app/src/pages/flagship/Build.page.tsx | 3 + .../flagship/ParameterSearchBox.test.tsx | 35 ++++++- changelog_entry.yaml | 2 +- 5 files changed, 130 insertions(+), 21 deletions(-) diff --git a/app/src/components/flagship/ParameterSearchBox.tsx b/app/src/components/flagship/ParameterSearchBox.tsx index 9580e8d4a..980c7cfc5 100644 --- a/app/src/components/flagship/ParameterSearchBox.tsx +++ b/app/src/components/flagship/ParameterSearchBox.tsx @@ -1,5 +1,6 @@ import { useMemo, useState } from 'react'; -import { IconFolder, IconSearch } from '@tabler/icons-react'; +import { IconFolder, IconInfoCircle, IconSearch } from '@tabler/icons-react'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui'; import { colors, spacing, typography } from '@/designTokens'; import { createParameterSearchIndex, @@ -22,8 +23,14 @@ interface ParameterSearchBoxProps { currentValueFor?: (entry: ParameterSearchEntry) => string | null; /** Derived concept clusters for variant-aware matching */ clusters?: string[][]; + /** State code → name, so the scope filter reads "California", not "CA only" */ + stateLabels?: Record; } +const CONTRIB_EXPLANATION = + 'Policy options contributed to the model — proposed reforms and ' + + 'experimental provisions that are not current law.'; + const RESULT_LIMIT = 20; const badgeStyle: React.CSSProperties = { @@ -89,6 +96,7 @@ export default function ParameterSearchBox({ placeholder = 'Search any parameter, e.g. child tax credit amount', currentValueFor, clusters = [], + stateLabels = {}, index: providedIndex, }: ParameterSearchBoxProps) { const [query, setQuery] = useState(''); @@ -99,7 +107,15 @@ export default function ParameterSearchBox({ () => providedIndex ?? createParameterSearchIndex(entries, clusters), [providedIndex, entries, clusters] ); - const stateCodes = useMemo(() => listStateCodes(entries), [entries]); + // Named states sort by name; any code the metadata does not name falls + // to the end of the list under its bare code. + const stateOptions = useMemo( + () => + listStateCodes(entries) + .map((code) => ({ code, label: stateLabels[code] ?? code.toUpperCase() })) + .sort((a, b) => a.label.localeCompare(b.label)), + [entries, stateLabels] + ); const groups = useMemo( () => groupSearchResults(searchParameters(index, query, RESULT_LIMIT, filters)), [index, query, filters] @@ -143,7 +159,7 @@ export default function ParameterSearchBox({ flexWrap: 'wrap', }} > - {stateCodes.length > 0 && ( + {stateOptions.length > 0 && ( )} - +
+ + {/* Outside the label: a control inside it would toggle the filter. */} + + + + + + {CONTRIB_EXPLANATION} + + +
{isFolder @@ -306,16 +361,22 @@ export default function ParameterSearchBox({ alignItems: 'center', gap: spacing.sm, flexShrink: 0, + // List-valued parameters (a dozen variable names) + // must not push the label out of its own row. + maxWidth: '45%', }} > {currentValueFor?.(entry) && ( {currentValueFor(entry)} diff --git a/app/src/libs/metadataUtils.ts b/app/src/libs/metadataUtils.ts index 533514933..42c6760ae 100644 --- a/app/src/libs/metadataUtils.ts +++ b/app/src/libs/metadataUtils.ts @@ -58,6 +58,20 @@ export const getRegions = createSelector( })) || [] ); +/** + * Two-letter state code → the model's own name for it ("ca" → + * "California"), read off the region options rather than a name list. + */ +export const getStateLabels = createSelector( + (state: RootState) => state.metadata.economyOptions.region, + (regions): Record => + Object.fromEntries( + (regions ?? []) + .filter((region) => region.name?.startsWith('state/')) + .map((region) => [region.name.slice('state/'.length), region.label]) + ) +); + export const getBasicInputFields = createSelector( [ (state: RootState) => state.metadata.basicInputs, diff --git a/app/src/pages/flagship/Build.page.tsx b/app/src/pages/flagship/Build.page.tsx index a5e2e1d1d..6b54005fc 100644 --- a/app/src/pages/flagship/Build.page.tsx +++ b/app/src/pages/flagship/Build.page.tsx @@ -6,6 +6,7 @@ import { Stack, Text, Title } from '@/components/ui'; import { colors, spacing, typography } from '@/designTokens'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; import { addDraftProvision, provisionFromSearchEntry, useDraftReform } from '@/libs/draftReform'; +import { getStateLabels } from '@/libs/metadataUtils'; import { ParameterSearchEntry, selectAddableParameterPaths, @@ -28,6 +29,7 @@ export default function BuildPage() { const countryId = useCurrentCountry(); const entries = useSelector(selectParameterSearchEntries); const clusters = useSelector(selectConceptClusters); + const stateLabels = useSelector(getStateLabels); // Store-memoized: survives navigation, so Build mounts don't rebuild it. const searchIndex = useSelector(selectParameterSearchIndex); const parameters = useSelector((state: RootState) => state.metadata.parameters); @@ -63,6 +65,7 @@ export default function BuildPage() { { diff --git a/app/src/tests/unit/components/flagship/ParameterSearchBox.test.tsx b/app/src/tests/unit/components/flagship/ParameterSearchBox.test.tsx index f913cb18e..61294c3c8 100644 --- a/app/src/tests/unit/components/flagship/ParameterSearchBox.test.tsx +++ b/app/src/tests/unit/components/flagship/ParameterSearchBox.test.tsx @@ -182,7 +182,9 @@ describe('ParameterSearchBox', () => { test('given a state scope then only that state appears with its badge', async () => { // Given const user = userEvent.setup(); - render(); + render( + + ); await user.selectOptions(screen.getByRole('combobox', { name: /state scope/i }), 'ut'); // When @@ -191,7 +193,36 @@ describe('ParameterSearchBox', () => { // Then expect(screen.getByText('Utah → Income tax → Child tax credit → Amount')).toBeInTheDocument(); expect(screen.queryByText('IRS → Credits → Child tax credit → Amount')).not.toBeInTheDocument(); - expect(screen.getByText('UT')).toBeInTheDocument(); + // The badge keeps the code; only the filter option spells the state out. + expect(screen.getByRole('option', { name: 'Utah' })).toBeInTheDocument(); + expect(screen.getAllByText('UT').length).toBeGreaterThan(0); + }); + + test('given state labels then the scope filter names states instead of codes', () => { + // Given / When + render( + + ); + + // Then + expect(screen.getByRole('option', { name: 'Utah' })).toBeInTheDocument(); + expect(screen.queryByRole('option', { name: 'UT only' })).not.toBeInTheDocument(); + }); + + test('given no label for a state then the filter falls back to its code', () => { + // Given / When + render(); + + // Then + expect(screen.getByRole('option', { name: 'UT' })).toBeInTheDocument(); + }); + + test('given the contributed filter then its meaning is available to the reader', () => { + // Given / When + render(); + + // Then + expect(screen.getByLabelText(/not current law/i)).toBeInTheDocument(); }); test('given a query with no match then no listbox renders', async () => { diff --git a/changelog_entry.yaml b/changelog_entry.yaml index cee43de38..561cf2e62 100644 --- a/changelog_entry.yaml +++ b/changelog_entry.yaml @@ -1 +1 @@ -- Connect the flagship reforms feed to the live bill tracker and remove the placeholder sample bills +- Name states in the parameter search filter, explain the contributed filter, and stop long parameter values from squeezing result labels From bbb80a4c9736621f2f7ab184ff87119dcff94933 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Wed, 26 Aug 2026 10:05:30 +0200 Subject: [PATCH 2/4] Lead Build with the search box, fold the policy tree behind a toggle Search is the surface people came for; the tree is the fallback for when you don't know what the parameter is called. Fourteen agency names sat above the fold arguing otherwise. The search box now sits centered in the workspace with the tree behind a toggle, which drops the vertical centering and expands in place when opened. Co-Authored-By: Claude Opus 5 --- app/src/pages/flagship/Build.page.tsx | 136 +++++++++++------- .../tests/unit/pages/flagship/Build.test.tsx | 38 +++++ 2 files changed, 126 insertions(+), 48 deletions(-) create mode 100644 app/src/tests/unit/pages/flagship/Build.test.tsx diff --git a/app/src/pages/flagship/Build.page.tsx b/app/src/pages/flagship/Build.page.tsx index 6b54005fc..aa1967d7c 100644 --- a/app/src/pages/flagship/Build.page.tsx +++ b/app/src/pages/flagship/Build.page.tsx @@ -1,8 +1,10 @@ +import { useState } from 'react'; +import { IconChevronDown } from '@tabler/icons-react'; import { useSelector } from 'react-redux'; import ParameterSearchBox from '@/components/flagship/ParameterSearchBox'; import ParameterTreeBrowser from '@/components/flagship/ParameterTreeBrowser'; import WorkspaceLayout from '@/components/flagship/WorkspaceLayout'; -import { Stack, Text, Title } from '@/components/ui'; +import { Button, Stack, Text, Title } from '@/components/ui'; import { colors, spacing, typography } from '@/designTokens'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; import { addDraftProvision, provisionFromSearchEntry, useDraftReform } from '@/libs/draftReform'; @@ -35,6 +37,9 @@ export default function BuildPage() { const parameters = useSelector((state: RootState) => state.metadata.parameters); const parameterTree = useSelector((state: RootState) => state.metadata.parameterTree); const draft = useDraftReform(); + // Search is the surface; the tree is the fallback for when you do not + // know what the thing is called, so it stays out of the way until asked for. + const [showTree, setShowTree] = useState(false); // Store-memoized like the index: built once per metadata load, not // per navigation or render. @@ -53,56 +58,91 @@ export default function BuildPage() { return ( - - Build a reform - - Search {entries.length > 0 ? entries.length.toLocaleString() : 'every'} parameter - {entries.length > 0 ? 's' : ''} — click one to add it to your draft. - - + + + Build a reform + + Search {entries.length > 0 ? entries.length.toLocaleString() : 'every'} parameter + {entries.length > 0 ? 's' : ''} — click one to add it to your draft. + + - {entries.length > 0 ? ( - { - const value = getCurrentValue(parameters?.[entry.path]?.values); - return value === undefined ? null : formatValue(value, entry.unit); - }} - /> - ) : ( - - Loading the parameter index… - - )} + {entries.length > 0 ? ( + { + const value = getCurrentValue(parameters?.[entry.path]?.values); + return value === undefined ? null : formatValue(value, entry.unit); + }} + /> + ) : ( + + Loading the parameter index… + + )} - - - Or browse the policy tree - - { - const entry = entriesByPath.get(path); - if (entry) { - addEntry(entry); - } - }} - /> +
+ +
+ + {showTree && ( +
+ { + const entry = entriesByPath.get(path); + if (entry) { + addEntry(entry); + } + }} + /> +
+ )}
); diff --git a/app/src/tests/unit/pages/flagship/Build.test.tsx b/app/src/tests/unit/pages/flagship/Build.test.tsx new file mode 100644 index 000000000..a0af54d52 --- /dev/null +++ b/app/src/tests/unit/pages/flagship/Build.test.tsx @@ -0,0 +1,38 @@ +import { render, screen, userEvent } from '@test-utils'; +import { describe, expect, test } from 'vitest'; +import BuildPage from '@/pages/flagship/Build.page'; + +describe('BuildPage', () => { + test('given the default view then search leads and the tree is out of the way', () => { + render(); + + expect(screen.getByRole('button', { name: /or browse the policy tree/i })).toHaveAttribute( + 'aria-expanded', + 'false' + ); + expect(screen.queryByText(/loading the policy tree/i)).not.toBeInTheDocument(); + }); + + test('given the toggle is clicked then the policy tree opens', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: /or browse the policy tree/i })); + + expect(screen.getByText(/loading the policy tree/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /hide the policy tree/i })).toHaveAttribute( + 'aria-expanded', + 'true' + ); + }); + + test('given the tree is open then clicking again closes it', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: /or browse the policy tree/i })); + await user.click(screen.getByRole('button', { name: /hide the policy tree/i })); + + expect(screen.queryByText(/loading the policy tree/i)).not.toBeInTheDocument(); + }); +}); From a9fac69eabeac24aa1b8c3b642542a61a595e7ff Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Wed, 26 Aug 2026 11:28:20 +0200 Subject: [PATCH 3/4] Open a search result's folder in the policy tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Search shows only the leaves that matched, so a folder header names a folder whose other members are invisible — exactly when someone has landed near what they wanted rather than on it. Folder headers are now buttons: clicking one opens the tree, expands the ancestor chain to that folder and scrolls it into view. The results stay up so the near miss can be compared against what the folder holds. Adding stays per-parameter. A folder can hold dozens, and a reform that silently touches all of them is not what clicking a header meant. Co-Authored-By: Claude Opus 5 --- .../flagship/ParameterSearchBox.tsx | 68 ++++++++++++++++--- .../flagship/ParameterTreeBrowser.tsx | 34 +++++++++- app/src/pages/flagship/Build.page.tsx | 18 ++++- .../flagship/ParameterSearchBox.test.tsx | 31 +++++++++ .../flagship/ParameterTreeBrowser.test.tsx | 31 +++++++++ 5 files changed, 170 insertions(+), 12 deletions(-) diff --git a/app/src/components/flagship/ParameterSearchBox.tsx b/app/src/components/flagship/ParameterSearchBox.tsx index 980c7cfc5..6cef8358c 100644 --- a/app/src/components/flagship/ParameterSearchBox.tsx +++ b/app/src/components/flagship/ParameterSearchBox.tsx @@ -1,5 +1,5 @@ import { useMemo, useState } from 'react'; -import { IconFolder, IconInfoCircle, IconSearch } from '@tabler/icons-react'; +import { IconArrowRight, IconFolder, IconInfoCircle, IconSearch } from '@tabler/icons-react'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui'; import { colors, spacing, typography } from '@/designTokens'; import { @@ -25,6 +25,18 @@ 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; +} + +/** The parent path of a leaf: gov.irs.credits.ctc.amount → gov.irs.credits.ctc. */ +function parentPath(path: string): string | null { + const lastDot = path.lastIndexOf('.'); + return lastDot > 0 ? path.slice(0, lastDot) : null; } const CONTRIB_EXPLANATION = @@ -97,6 +109,7 @@ export default function ParameterSearchBox({ currentValueFor, clusters = [], stateLabels = {}, + onOpenFolder, index: providedIndex, }: ParameterSearchBoxProps) { const [query, setQuery] = useState(''); @@ -290,23 +303,58 @@ export default function ParameterSearchBox({ 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, - }} - > - - {group.folder} -
- )} + textAlign: 'left', + }; + // Only a folder with a resolvable path can be opened; + // otherwise the header stays the label it was. + if (!onOpenFolder || !folderPath) { + return ( +
+ + {group.folder} +
+ ); + } + return ( + + ); + })()} {group.entries.map((entry) => { runningIndex += 1; const i = runningIndex; diff --git a/app/src/components/flagship/ParameterTreeBrowser.tsx b/app/src/components/flagship/ParameterTreeBrowser.tsx index f0e62f1e4..c4df0c27d 100644 --- a/app/src/components/flagship/ParameterTreeBrowser.tsx +++ b/app/src/components/flagship/ParameterTreeBrowser.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { IconChevronDown, IconChevronRight, IconPlus } from '@tabler/icons-react'; import { Text } from '@/components/ui'; import { colors, spacing, typography } from '@/designTokens'; @@ -12,6 +12,18 @@ 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; +} + +/** 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('.')); } /** @@ -24,8 +36,24 @@ export default function ParameterTreeBrowser({ onSelectLeaf, addablePaths, draftPaths, + expandTo = null, }: ParameterTreeBrowserProps) { const [expanded, setExpanded] = useState>(new Set()); + const containerRef = useRef(null); + + useEffect(() => { + if (!expandTo) { + return; + } + 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]); if (!tree) { return ( @@ -62,6 +90,7 @@ export default function ParameterTreeBrowser({
- - - + + + {/* Folded, the count is all that is left to say what is in there. */} + {open ? SOURCE_NOTES[draft.source] : provisionCount} + + + {open && ( +
+ + + {draft.provisions.map((provision) => ( + - {formatValue(provision.baselineValue, provision.unit)} → - - - + + + {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" - 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. - - )} - - - - {( - [ - { 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 ( - - ); - })} - - - - - - Baseline: current law · {new Date().getFullYear()} - - {runReport.error && ( - - {runReport.error} - - )} - + /> + {!hasEditedValue && draft.provisions.length > 0 && ( + + Values match current law so far — edit a value above to make this a reform. + + )} + - - {saveMutation.isError && ( - - Could not save the reform. Try again. - - )} - - - - - - + {( + [ + { 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 ( + + ); + })} + + + + + + Baseline: current law · {new Date().getFullYear()} + + {runReport.error && ( + + {runReport.error} + + )} + + + + {saveMutation.isError && ( + + Could not save the reform. Try again. + + )} + + + + + + +
+ )}
); } diff --git a/app/src/tests/unit/components/flagship/ReformPreviewCard.test.tsx b/app/src/tests/unit/components/flagship/ReformPreviewCard.test.tsx index 09bd3f43a..c200c4757 100644 --- a/app/src/tests/unit/components/flagship/ReformPreviewCard.test.tsx +++ b/app/src/tests/unit/components/flagship/ReformPreviewCard.test.tsx @@ -142,4 +142,47 @@ describe('ReformPreviewCard', () => { expect(screen.getByRole('button', { name: 'A household' })).toBeDisabled(); expect(getDraftReform()?.population).toEqual({ scope: 'national' }); }); + + test('given the header is clicked then the draft folds to its heading and count', async () => { + // Given + const user = userEvent.setup(); + seedDraft(); + renderCard(); + + // When + await user.click(screen.getByRole('button', { name: /here's your draft reform/i })); + + // Then — the count survives the fold; the editing controls do not + expect(screen.getByText('1 provision')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /run report/i })).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Reform name')).not.toBeInTheDocument(); + }); + + test('given a folded draft then clicking again restores the controls', async () => { + // Given + 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); + + // Then + expect(screen.getByRole('button', { name: /run report/i })).toBeInTheDocument(); + expect(screen.getByLabelText('Reform name')).toBeInTheDocument(); + }); + + test('given the default view then the draft is open', () => { + // Given / When + seedDraft(); + 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' + ); + }); });