Repository navigation
Security: filter chips, side-by-side Overview, trend range fixes - #105
Conversation
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… 5-12 rows) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…Reset filters Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… Overview Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…d indent) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Only the points inside the range reach the chart, so the y axis rescales and lines no longer run off the left edge on Last year / 90 / 30 days. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…easurement Last year / 90 days drew a 365- or 90-day axis even when the history is a few weeks, squeezing every line against the right edge. The axis now never starts before All time does. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The trend response carries historyStart (the first measured day, whatever since is). A range that would cover all of it draws the same chart as All time, so the Range select disables it as "(not enough history yet)". Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
msogin
left a comment
There was a problem hiding this comment.
Smartling review
Reviewed with the Smartling org review prompt (fullstack). No critical issues found. Findings:
- 🟡
queries.ts:325historyStartignores the team filter, so a team whose history starts later can still be offered ranges that look identical to All time. - 🔵
vulnerabilities-content.tsx:142: with few teams the trend card's minimum height is taller than the ownership card's body, so the 'same height' layout leaves empty space under the teams table. - 🔵
trend-model.ts:30: whenhistoryStartis null (no measurements), every range except the current one shows '(not enough history yet)'. That is accurate but worth a test or a note in the comment. - 🟣
filter-bar.tsx:77: removing the Repo chip moves focus to Codebase. Should focus go to the next chip, or to Clear all, instead?
Assessment: OK to merge, with the warning fixed or explicitly accepted.
| const snaps = await loadTrendSnapshotRows(p.org, chosen); | ||
| // The first measured day whatever `since` is, from the set index alone: lets the page tell a range that cuts history from one | ||
| // that covers all of it (and would draw the same chart as All time). | ||
| const historyStart = pickTrendSets(index, { codebase: f.codebase, severity }).reduce<string | null>( |
There was a problem hiding this comment.
🟡 warning: historyStart is computed from the set index for the codebase and severity only. The team filter is ignored. With a team selected, the chart draws only that team's line, so if the team's first measurement is later than the org's, a range can still look the same as All time and stay enabled. The opposite can't happen, because the org history always starts at or before the team's. If greying out ranges should work per team, take the min date from computeTrend's points for the team, or document that historyStart is org-wide. It also runs pickTrendSets over the index a second time, which is cheap but could be folded into one pass.
| <TrendCard {...viewProps} /> | ||
| {/* Below 1100px (OVERVIEW_STACK_BP) the cards stack; from 1100px they sit side by side, the same height: the ownership | ||
| card's body (sized to the org's teams) sets the row, and the trend card stretches to it. */} | ||
| <div data-testid="overview-grid" className="grid grid-cols-1 items-stretch min-[1100px]:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]" style={{ gap: PAGE_GAP }}> |
There was a problem hiding this comment.
🔵 suggestion: Side by side, the trend card has a minimum height. That is the header in column layout, the plot at least 160px, the 112px legend, and the rule and footnote, which comes to roughly 420px or more. ownershipBodyH(5..6) plus the card's tab row comes to less than that. For orgs with about 6 teams or fewer, the trend card sets the row height and the ownership card stretches with blank space under its fixed-height body. Consider letting the ownership body grow to fill the stretched card (flex-1 with min-height: bodyH), or check visually with a 5-team fixture.
| */ | ||
| export function rangeCutsHistory(range: TrendRange, historyStart: string | null, today: string): boolean { | ||
| if (range === 'all') return true; | ||
| return historyStart !== null && historyStart < addDays(today, -RANGE_DAYS[range]); |
There was a problem hiding this comment.
🔵 suggestion: The doc comment says unknown history (null) 'cuts nothing'. As a result, an org with no measurements gets every range except the current one disabled as '(not enough history yet)'. That is reasonable, but no test covers it. Add a case to vuln-trend-card.test.tsx for historyStart: null so the behaviour is pinned.
| const chips: Array<{ key: string; label: string; remove: () => void; focus: () => void }> = []; | ||
| if (!url.isDefault.codebase) chips.push({ key: 'codebase', label: `Codebase: ${CODEBASE_LABELS[url.codebase]}`, remove: () => url.setCodebase(CODEBASE_SCHEMA.default as (typeof CODEBASE_GROUPS)[number]), focus: () => codebaseRef.current?.focus() }); | ||
| if (!url.isDefault.team && url.team) chips.push({ key: 'team', label: `Team: ${url.team}`, remove: () => url.setTeam(null), focus: () => teamRef.current?.focus() }); | ||
| if (!url.isDefault.repo && url.repo) chips.push({ key: 'repo', label: `Repo: ${url.repo}`, remove: () => url.setRepo(null), focus: () => codebaseRef.current?.focus() }); // no repo select exists, so focus falls back to Codebase |
There was a problem hiding this comment.
🟣 question: There's no Repo select, so removing the Repo chip moves focus to the Codebase select, which is unrelated. Would focus on the next chip, or on 'Clear all' if it's still visible, be more predictable for keyboard users?
msogin
left a comment
There was a problem hiding this comment.
Review-loop (2 personas): Sr. Frontend Eng + Correctness, Sr. Architect + UX/A11y/Maintainability. 1 loop.
No Critical issues. Findings:
- I trend-model.ts:55:
trendDomainclamps to the first point of a series the server already cut atsince, so a ranged axis can start later than the window when there's a gap at the start of the window. UsehistoryStartinstead. - I filter-bar.tsx:182: the chip row has no group label or list semantics.
- I (follow-up) filter-chip.tsx:20: the new FilterChip copies the inline chip markup in Projects, Team and Settings, whose × buttons have no accessible name. Open a ticket to move them onto FilterChip.
- S trend-model.ts:55: add a test for a ranged domain with a gap at the start of the window.
- S filter-bar.tsx:76: removing the Team or Codebase chip also clears Repo, so one × removes two chips. Is that intended?
- S filter-bar.tsx:192: Clear all shows at 1 or more active filters here, but Projects shows it only above 1. Pick one rule.
- S trend-card.tsx:106: while a new key loads, the disabled range options use the previous key's
historyStart. It's cosmetic and short-lived.
Checked with nothing found: historyStart is additive and backward compatible (MCP passes it through), ownershipBodyH clamping, focus return after ×, hidden Clear all a11y, and docs match the code.
| const allStart = dates[0] && dates[0] < floor ? dates[0] : floor; | ||
| if (range === 'all') return { start: allStart, end }; | ||
| const windowStart = addDays(today, -RANGE_DAYS[range]); | ||
| return { start: windowStart > allStart ? windowStart : allStart, end }; |
There was a problem hiding this comment.
Important: allStart comes from series, but the server already dropped sets before since (pickTrendSets). With a 200-day history, 90d range and no data between day -90 and day -30, the axis starts at day -30, not at the window start, which contradicts the docs. Clamp against the response's historyStart instead. Also worth adding a test for a long history with a sparse window start.
| > | ||
| Reset filters | ||
| </button> | ||
| <div data-testid="chip-row" className="flex min-w-0 flex-nowrap items-center overflow-hidden" style={{ height: FILTER_CHIP_ROW_H, marginTop: FILTER_CHIP_ROW_GAP, gap: FILTER_ROW_GAP }}> |
There was a problem hiding this comment.
Important (a11y): The chip row is a plain div, so a screen reader hears the "Remove … filter" buttons with no context. Add role="group" aria-label="Active filters" (or use ul/li).
| return ( | ||
| <span | ||
| data-testid="filter-chip" | ||
| className="inline-flex min-w-0 shrink items-center gap-1.5 bg-accent/20 text-accent-lighter text-xs font-medium px-2.5 py-1 rounded-lg border border-accent/30" |
There was a problem hiding this comment.
Important (follow-up): This class string is identical to the inline chips in projects-content.tsx, report/[id]/team/page.tsx and settings, whose bare × buttons have no accessible name. Suggest a ticket to move those pages onto FilterChip.
| // One chip per non-default filter, in the selects' order; × resets that filter and puts focus on its select (the chip is gone). | ||
| const chips: Array<{ key: string; label: string; remove: () => void; focus: () => void }> = []; | ||
| if (!url.isDefault.codebase) chips.push({ key: 'codebase', label: `Codebase: ${CODEBASE_LABELS[url.codebase]}`, remove: () => url.setCodebase(CODEBASE_SCHEMA.default as (typeof CODEBASE_GROUPS)[number]), focus: () => codebaseRef.current?.focus() }); | ||
| if (!url.isDefault.team && url.team) chips.push({ key: 'team', label: `Team: ${url.team}`, remove: () => url.setTeam(null), focus: () => teamRef.current?.focus() }); |
There was a problem hiding this comment.
Suggestion/question: setTeam and setCodebase also null repo, so one × removes two chips. Is that intended? If so, a test or note would help.
| <button | ||
| type="button" | ||
| onClick={() => { url.resetFilters(); codebaseRef.current?.focus(); }} | ||
| disabled={url.isDefault.all} |
There was a problem hiding this comment.
Suggestion: Clear all shows at 1 or more active filters here, while Projects uses activeFilterCount > 1. Worth settling one rule before FilterChip spreads.
| {/* A range the history fits inside would draw the same chart as All time: greyed out, unless it is the current value | ||
| (a disabled selected option would read as broken). Until the trend loads, every range is offered. */} | ||
| {TREND_RANGES.map(([v, label]) => { | ||
| const empty = historyStart !== undefined && v !== url.range && !rangeCutsHistory(v, historyStart, today); |
There was a problem hiding this comment.
Suggestion (minor): Under keepPreviousData, historyStart belongs to the previous codebase/severity while the new one loads, so options can briefly grey out against the wrong history. It's cosmetic.
- Trend axis starts from the response's historyStart, not the series' first point (already cut at since), so a gap at the start of a window no longer starts the axis late. - Chip row is a group labelled "Active filters". - Pin the no-measurements case of the Range select; note that historyStart is org-wide, never per team. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
I've addressed the review in 84472d2. Fixed
Accepted and documented
Answers
Follow-ups, not in this PR
|
What changes
Filter chips. Each filter that isn't at its default shows as a removable chip under the dropdowns, like on Projects and Team. That includes Repo, which used to be set from the repo list with nothing in the bar showing it. × resets that filter and returns focus to its dropdown. "Clear all" replaces "Reset filters". The chip row's height is reserved, so adding or removing a chip never moves the page.
Teams panel. It's sized from the org's total number of teams: 5 to 12 rows of 40px, never from the filtered list. An org with 10 teams no longer has to scroll. Filtering never resizes the panel.
Overview layout. From 1100px wide, the teams panel (60%) and the trend card (40%) sit side by side at the same height. Below 1100px they stack as before.
Trend range (existing bugs found while testing on real data):
historyStart. A range that the history fits inside would draw the same chart as All time, so the Range select greys it out as "(not enough history yet)".docs/vulnerabilities-page.md("Layout stability" and the trend card) is updated to match.Spec:
docs/superpowers/specs/2026-10-22-security-overview-layout-design.mdPlan:
docs/superpowers/plans/2026-10-22-security-overview-layout.mdTesting
npm test: 3430 pass, 2 fail. Both failures are onmaintoo:chart-no-literal-colors, and the docs "Page modules" check, which trips on an untracked duplicate file in my checkout.Known follow-up
In the side-by-side layout, the team table's Total row is too narrow for four-digit Resolved totals: the Change column's "0" overlaps them. Long team names are also cut off with "…".
🤖 Generated with Claude Code