From 28222bac91d82e49752159d6ab5500d74c9e0b1a Mon Sep 17 00:00:00 2001
From: Ben Limmer
Date: Wed, 27 May 2026 08:59:30 -0600
Subject: [PATCH 1/5] feat(report): lead with the solution and apply storybook
review feedback
Restructures the HTML report around reviewer feedback on the Chromatic
story: the solution now leads, the assumptions are a hero-level control,
and several sections are reworked.
- Hero: drop the secondary stat line; surface the global assumptions as a
collapsed disclosure (numbers shown, expands to edit)
- Lead with the 'Today vs. PatchWave auto-merge' solution section; the
full waitlist CTA closes the report above the methodology, with a short
'Join the waitlist' button ending the solution section
- Calculator: tick dots at slider break points, relabel the center metric
to 'PRs auto-merged' to match the slider, and a theme-aware --savings
green for light-mode contrast
- Cost table: add a Time (hrs) column
- Collapse the 90-180/180+ PR-age buckets into a single 90+ bucket
- Render top repos by severity as a horizontal stacked-bar histogram
- Derive the story's global CVE totals from the per-repo rows so they
reconcile
- Remove the adjustable-assumptions footnote now that the control is in
the hero
---
src/report/aggregate.test.ts | 4 +-
src/report/aggregate.ts | 3 +-
src/report/testFactories.ts | 3 +-
src/report/web/App.browser.test.tsx | 55 ++---------
src/report/web/App.stories.tsx | 38 ++++++--
src/report/web/App.tsx | 2 +-
src/report/web/acts/AutomatedStory.tsx | 53 +++++++----
src/report/web/acts/CostStory.tsx | 10 +-
src/report/web/acts/MethodologyAppendix.tsx | 26 +++--
src/report/web/acts/RiskStory.tsx | 95 ++++++++++---------
src/report/web/acts/Verdict.tsx | 59 +-----------
.../web/hooks/useAssumptionsDisclosure.tsx | 3 -
.../web/primitives/AssumptionsFootnote.tsx | 22 -----
.../web/primitives/FootnoteReference.tsx | 5 +-
src/report/web/primitives/HeroAssumptions.tsx | 30 ++++++
src/report/web/primitives/PersonRow.tsx | 4 +-
src/report/web/primitives/StackedBar.tsx | 6 +-
src/report/web/styles.css | 7 ++
18 files changed, 193 insertions(+), 232 deletions(-)
delete mode 100644 src/report/web/primitives/AssumptionsFootnote.tsx
create mode 100644 src/report/web/primitives/HeroAssumptions.tsx
diff --git a/src/report/aggregate.test.ts b/src/report/aggregate.test.ts
index 1f6303d..95f5d5e 100644
--- a/src/report/aggregate.test.ts
+++ b/src/report/aggregate.test.ts
@@ -35,8 +35,8 @@ test('counts merged-in-window PRs and surfaces backlog age buckets', () => {
mergedInWindowCount: 1,
oldestOpenDays: expect.any(Number) as number,
});
- // The 263-day-old PR should fall into the 180+ bucket.
- const oldBucket = bundle.prBacklog.openAgeBuckets.find((b) => b.label === '180+ days');
+ // The 263-day-old PR should fall into the 90+ bucket (90 and 180+ are no longer split).
+ const oldBucket = bundle.prBacklog.openAgeBuckets.find((b) => b.label === '90+ days');
expect(oldBucket?.count).toBe(1);
});
diff --git a/src/report/aggregate.ts b/src/report/aggregate.ts
index 1c4364c..90bba7c 100644
--- a/src/report/aggregate.ts
+++ b/src/report/aggregate.ts
@@ -235,8 +235,7 @@ function buildPrBacklog(data: CollectedData, now: Instant, windowStart: Instant)
{ label: '0–30 days', min: 0, max: 30 },
{ label: '30–60 days', min: 30, max: 60 },
{ label: '60–90 days', min: 60, max: 90 },
- { label: '90–180 days', min: 90, max: 180 },
- { label: '180+ days', min: 180, max: Number.POSITIVE_INFINITY },
+ { label: '90+ days', min: 90, max: Number.POSITIVE_INFINITY },
];
const openAgeBuckets = buckets.map((b) => ({
label: b.label,
diff --git a/src/report/testFactories.ts b/src/report/testFactories.ts
index 5394c07..7e739ef 100644
--- a/src/report/testFactories.ts
+++ b/src/report/testFactories.ts
@@ -56,8 +56,7 @@ export const prBacklog = Factory.define(() => ({
{ label: '0–30 days', count: 40 },
{ label: '30–60 days', count: 18 },
{ label: '60–90 days', count: 6 },
- { label: '90–180 days', count: 25 },
- { label: '180+ days', count: 13 },
+ { label: '90+ days', count: 38 },
],
oldestOpenDays: 312,
bumpTypeSplit: [
diff --git a/src/report/web/App.browser.test.tsx b/src/report/web/App.browser.test.tsx
index 0c09d8a..f124fe7 100644
--- a/src/report/web/App.browser.test.tsx
+++ b/src/report/web/App.browser.test.tsx
@@ -12,7 +12,6 @@ import { verdictCopy, verdictTestIds } from './acts/Verdict.tsx';
import { AnalyticsProvider } from './analytics/AnalyticsContext.tsx';
import { App, appTestIds } from './App.tsx';
import { assumptionInputTestIds } from './primitives/AssumptionInput.tsx';
-import { assumptionsFootnoteTestId } from './primitives/AssumptionsFootnote.tsx';
import { footnoteReferenceTestId } from './primitives/FootnoteReference.tsx';
import type { EmbeddedReportData } from './types.ts';
@@ -73,52 +72,23 @@ describe('App report shell', () => {
expect(screen.getByTestId(automatedStoryTestIds.patchwaveCost)).toHaveTextContent('$4,104/yr');
});
- it('reveals the methodology assumptions panel when an estimate footnote is clicked', () => {
- renderReport();
- const details = screen.getByTestId(assumptionInputTestIds.container).closest('details');
- expect(details).toBeTruthy();
- expect(details).not.toHaveAttribute('open');
-
- const footnote = screen.getAllByTestId(assumptionsFootnoteTestId)[0];
- if (!footnote) throw new Error('missing assumptions footnote');
- const restore = suppressNavigation();
- fireEvent.click(footnote);
- restore();
-
- expect(details).toHaveAttribute('open');
- });
-
- it('switches back to the calculation tab when an assumptions footnote is clicked from raw data', () => {
- renderReport();
- fireEvent.click(screen.getByText('How this report was calculated'));
- fireEvent.click(screen.getByRole('tab', { name: 'Raw data' }));
- expect(screen.queryByTestId(assumptionInputTestIds.container)).not.toBeInTheDocument();
-
- const restore = suppressNavigation();
- fireEvent.click(screen.getAllByTestId(assumptionsFootnoteTestId)[0] as HTMLElement);
- restore();
-
- expect(screen.getByRole('tab', { name: 'Calculation' })).toHaveAttribute('aria-selected', 'true');
- expect(screen.getByTestId(assumptionInputTestIds.container)).toBeInTheDocument();
- });
-
it('lists footnotes in ascending first-appearance order', () => {
renderReport();
fireEvent.click(screen.getByText('How this report was calculated'));
const sources = screen.getByTestId(methodologyAppendixTestIds.sources);
- expect(sources).toHaveTextContent('1. Adjustable cost assumptions.');
+ // The solution section leads the report, so its Mohayeji citation is the first footnote.
+ expect(sources).toHaveTextContent('1. Mohayeji et al. 2025');
expect(sources).toHaveTextContent('2. VulnCheck, May 2026');
expect(sources).toHaveTextContent('3. Anthropic, "Project Glasswing');
expect(sources).toHaveTextContent('4. Anthropic, Coordinated Vulnerability Disclosure dashboard');
- expect(sources).toHaveTextContent('5. Mohayeji et al. 2025');
- expect(sources).toHaveTextContent('6. Atlassian State of Developer Experience Report 2025.');
+ expect(sources).toHaveTextContent('5. Atlassian State of Developer Experience Report 2025.');
});
it('opens the appendix source note instead of navigating directly when a citation is clicked', () => {
renderReport();
- const details = screen.getByTestId(assumptionInputTestIds.container).closest('details');
+ const details = screen.getByTestId(methodologyAppendixTestIds.section).querySelector('details');
expect(details).toBeTruthy();
expect(details).not.toHaveAttribute('open');
@@ -136,7 +106,6 @@ describe('App report shell', () => {
it('renders the ok CVE state with severity counts', () => {
renderReport();
- expect(screen.getByTestId(verdictTestIds.cveLine)).toHaveTextContent('7 open security alerts');
expect(screen.getByTestId(riskStoryTestIds.heading)).toHaveTextContent('7 open security alerts');
expect(screen.getByTestId(riskStoryTestIds.severityBar)).toBeInTheDocument();
});
@@ -196,7 +165,6 @@ describe('App report shell', () => {
},
});
- expect(screen.getByTestId(verdictTestIds.cveLine)).toHaveTextContent(verdictCopy.cveScopeMissing);
expect(screen.getByTestId(riskStoryTestIds.heading)).toHaveTextContent(riskStoryCopy.scopeMissingHeading);
expect(screen.getByTestId(riskStoryTestIds.scopeRefreshCommand)).toHaveTextContent(
'gh auth refresh -s security_events',
@@ -213,8 +181,7 @@ describe('App report shell', () => {
expect(screen.getByTestId(openPrAgeStoryTestIds.section)).toHaveTextContent(openPrAgeStoryCopy.heading);
expect(screen.getByTestId(riskStoryTestIds.section)).toHaveTextContent(riskStoryCopy.eyebrow);
expect(screen.getByTestId(callToActionTestIds.section)).toHaveTextContent(callToActionCopy.heading);
- expect(screen.getByTestId(verdictTestIds.section)).toHaveTextContent('based on adjustable1 assumptions');
- expect(screen.getByTestId(automatedStoryTestIds.todayCost).parentElement).toHaveTextContent('Adjustable1 estimate');
+ expect(screen.getByTestId(verdictTestIds.section)).toHaveTextContent('based on adjustable assumptions');
expect(screen.getByTestId(verdictTestIds.primaryCta)).toHaveTextContent(verdictCopy.primaryCta);
expect(screen.getByTestId(verdictTestIds.primaryCta)).toHaveAttribute('data-variant', 'default');
expect(screen.getByTestId(callToActionTestIds.cta)).toHaveTextContent(callToActionCopy.ctaLabel);
@@ -230,16 +197,6 @@ describe('App report shell', () => {
expect(screen.getByTestId(methodologyAppendixTestIds.section)).not.toHaveTextContent('patchwave.ai');
});
- it('places every assumptions footnote immediately after adjustable', () => {
- renderReport();
- fireEvent.click(screen.getByText('How this report was calculated'));
-
- for (const footnote of screen.getAllByTestId(assumptionsFootnoteTestId)) {
- const previousText = footnote.previousSibling?.textContent ?? '';
- expect(previousText.trimEnd().toLowerCase().endsWith('adjustable')).toBe(true);
- }
- });
-
it('combines person merge and review rows and labels the cost window', () => {
renderReport();
@@ -306,7 +263,7 @@ describe('App report shell', () => {
},
});
- // The input lives on the Calculation tab and unmounts on Raw data, so adjust before navigating.
+ // The assumptions control lives in the hero, so it stays editable regardless of the appendix tab.
const assumptions = screen.getByTestId(assumptionInputTestIds.container);
fireEvent.change(within(assumptions).getByTestId(assumptionInputTestIds.minutesPerPr), {
target: { value: '10' },
diff --git a/src/report/web/App.stories.tsx b/src/report/web/App.stories.tsx
index 19b0d83..277b1a8 100644
--- a/src/report/web/App.stories.tsx
+++ b/src/report/web/App.stories.tsx
@@ -7,6 +7,27 @@ import { App } from './App.tsx';
// Defaults cover most of the page; the overrides here only fill the spots that
// would otherwise render as single-row tables (language mix, top repos by
// severity) so the snapshot exercises the full UI.
+//
+// The global severity totals are summed from the per-repo rows so the
+// distribution bar and the per-repo breakdown always reconcile, mirroring how
+// production derives both from the same alert list.
+const topReposBySeverity = [
+ { repo: 'acme/api', critical: 1, high: 2, medium: 1, low: 0 },
+ { repo: 'acme/web', critical: 0, high: 1, medium: 3, low: 2 },
+ { repo: 'acme/billing', critical: 0, high: 0, medium: 2, low: 5 },
+ { repo: 'acme/worker', critical: 0, high: 0, medium: 2, low: 1 },
+ { repo: 'acme/mobile', critical: 0, high: 0, medium: 1, low: 3 },
+ { repo: 'acme/legacy-api', critical: 0, high: 0, medium: 1, low: 1 },
+ { repo: 'acme/internal-tools', critical: 0, high: 0, medium: 0, low: 4 },
+ { repo: 'acme/docs', critical: 0, high: 0, medium: 0, low: 2 },
+];
+const bySeverity = {
+ critical: sumBy(topReposBySeverity, 'critical'),
+ high: sumBy(topReposBySeverity, 'high'),
+ medium: sumBy(topReposBySeverity, 'medium'),
+ low: sumBy(topReposBySeverity, 'low'),
+};
+
const sampleReport = toEmbeddedShape(
reportBundle.build({
orgOverview: orgOverview.build({
@@ -18,16 +39,9 @@ const sampleReport = toEmbeddedShape(
],
}),
cve: cveExposureOk.build({
- topReposBySeverity: [
- { repo: 'acme/api', critical: 1, high: 2, medium: 1, low: 0 },
- { repo: 'acme/web', critical: 0, high: 1, medium: 3, low: 2 },
- { repo: 'acme/billing', critical: 0, high: 0, medium: 2, low: 5 },
- { repo: 'acme/worker', critical: 0, high: 0, medium: 2, low: 1 },
- { repo: 'acme/mobile', critical: 0, high: 0, medium: 1, low: 3 },
- { repo: 'acme/legacy-api', critical: 0, high: 0, medium: 1, low: 1 },
- { repo: 'acme/internal-tools', critical: 0, high: 0, medium: 0, low: 4 },
- { repo: 'acme/docs', critical: 0, high: 0, medium: 0, low: 2 },
- ],
+ totalOpenAlerts: bySeverity.critical + bySeverity.high + bySeverity.medium + bySeverity.low,
+ bySeverity,
+ topReposBySeverity,
reposWithSecurityAlertsDisabled: ['acme/legacy-cron'],
}),
people: people.build({
@@ -86,3 +100,7 @@ export const CveScopeMissing: Story = {
data: { ...sampleReport, cve: cveExposureScopeMissing.build() },
},
};
+
+function sumBy(rows: readonly T[], key: keyof T): number {
+ return rows.reduce((total, row) => total + (row[key] as number), 0);
+}
diff --git a/src/report/web/App.tsx b/src/report/web/App.tsx
index c418b56..7b3b55e 100644
--- a/src/report/web/App.tsx
+++ b/src/report/web/App.tsx
@@ -31,10 +31,10 @@ export function App({ data }: { data: EmbeddedReportData }) {
+
-
diff --git a/src/report/web/acts/AutomatedStory.tsx b/src/report/web/acts/AutomatedStory.tsx
index ea967e5..bf8fd2f 100644
--- a/src/report/web/acts/AutomatedStory.tsx
+++ b/src/report/web/acts/AutomatedStory.tsx
@@ -1,8 +1,10 @@
import { useState } from 'react';
+import { useAnalytics } from '../analytics/AnalyticsContext.tsx';
+import { Button } from '../components/ui/button.tsx';
import { fmtUsd } from '../format/money.ts';
import { useAssumptions } from '../hooks/useAssumptions.tsx';
-import { AssumptionsFootnote } from '../primitives/AssumptionsFootnote.tsx';
import { Citation } from '../primitives/Citation.tsx';
+import { callToActionCopy } from './CallToAction.tsx';
export const automatedStoryTestIds = {
section: 'automated-story-section',
@@ -10,14 +12,22 @@ export const automatedStoryTestIds = {
patchwaveCost: 'automated-story-patchwave-cost',
delta: 'automated-story-delta',
shareSlider: 'automated-story-share-slider',
+ waitlistCta: 'automated-story-waitlist-cta',
} as const;
const SHARE_MIN = 50;
const SHARE_MAX = 80;
+const SHARE_STEP = 5;
const SHARE_DEFAULT = 65;
+const SHARE_MID = (SHARE_MIN + SHARE_MAX) / 2;
+const SHARE_STOPS = Array.from(
+ { length: (SHARE_MAX - SHARE_MIN) / SHARE_STEP + 1 },
+ (_, i) => SHARE_MIN + i * SHARE_STEP,
+);
export function AutomatedStory() {
const { assumptions, derived } = useAssumptions();
+ const analytics = useAnalytics();
const [sharePct, setSharePct] = useState(SHARE_DEFAULT);
const todayCost = derived.annualCostUsd;
@@ -43,7 +53,6 @@ export function AutomatedStory() {
label="Today"
value={`${fmtUsd(todayCost)}/yr`}
sub={quarterHoursLabel(todayCost, assumptions.hourlyRateUsd)}
- footnote
/>
- cost recovered
+ PRs auto-merged
setSharePct(Number(e.target.value))}
- className="accent-primary mt-3 w-full"
+ className="accent-primary mt-3 block w-full"
/>
+
+ {SHARE_STOPS.map((stop) => (
+
+ ))}
+
{SHARE_MIN}%
+ {SHARE_MID}%
{SHARE_MAX}%
@@ -92,6 +110,16 @@ export function AutomatedStory() {
wrong
.
+
+
+ analytics.capture('cta_clicked', { which: 'automated_story_waitlist' })}
+ >
+ {callToActionCopy.ctaLabel}
+
+
);
}
@@ -102,34 +130,23 @@ function CompareCard({
sub,
testId,
accent = false,
- footnote = false,
}: {
label: string;
value: string;
sub: string;
testId: string;
accent?: boolean;
- footnote?: boolean;
}) {
return (
{label}
{value}
-
- {footnote && (
- <>
- Adjustable
-
estimate
-
·
- >
- )}
- {sub}
-
+
{sub}
);
}
diff --git a/src/report/web/acts/CostStory.tsx b/src/report/web/acts/CostStory.tsx
index 9826c01..f771602 100644
--- a/src/report/web/acts/CostStory.tsx
+++ b/src/report/web/acts/CostStory.tsx
@@ -2,7 +2,6 @@ import { useState } from 'react';
import { useEmbeddedData } from '../data/EmbeddedDataContext.tsx';
import { fmtUsd } from '../format/money.ts';
import { useAssumptions } from '../hooks/useAssumptions.tsx';
-import { AssumptionsFootnote } from '../primitives/AssumptionsFootnote.tsx';
import { PersonRow } from '../primitives/PersonRow.tsx';
export const costStoryTestIds = {
@@ -39,8 +38,7 @@ export function CostStory() {
In the last {data.meta.windowDays} days, your team merged{' '}
{humanMergeCount.toLocaleString()} Dependabot PRs by hand.
- Anything a bot auto-merged is left out. At adjustable
- defaults of{' '}
+ Anything a bot auto-merged is left out. At{' '}
{assumptions.minutesPerPr} minutes per PR and{' '}
${assumptions.hourlyRateUsd}/hr , that comes out to:
@@ -95,7 +93,7 @@ function CostCell({
}
function PeopleTable({ windowDays }: { windowDays: number }) {
- const { derived } = useAssumptions();
+ const { assumptions, derived } = useAssumptions();
const [expanded, setExpanded] = useState(false);
const people = combinedPeopleRows(derived.mergers, derived.reviewers);
const visiblePeople = expanded ? people : people.slice(0, INITIAL_PEOPLE_COUNT);
@@ -120,6 +118,7 @@ function PeopleTable({ windowDays }: { windowDays: number }) {
Person
Count
+ Time (hrs)
Cost over last {windowDays} days
Annualized
@@ -131,13 +130,14 @@ function PeopleTable({ windowDays }: { windowDays: number }) {
login={r.login}
mergedCount={r.mergedCount}
reviewedCount={r.reviewedCount}
+ windowHours={Math.round(r.windowCostUsd / assumptions.hourlyRateUsd)}
windowCostUsd={r.windowCostUsd}
annualCostUsd={r.annualCostUsd}
/>
))}
{hiddenCount > 0 || expanded ? (
-
+
{activeTab === 'calculation' && (
-
+
Cost assumptions
- Every cost and time figure in this report is modeled from these two inputs. Change them and the
- numbers throughout recalculate. The defaults are intentionally conservative.
+ Every cost and time figure in this report is modeled from two inputs: a loaded hourly rate and the
+ minutes spent per PR. Adjust them at the top of the report and the numbers throughout recalculate.
+ The defaults are intentionally conservative.
+
+
+ Currently ${assumptions.hourlyRateUsd}/hr and{' '}
+ {assumptions.minutesPerPr} min per PR.
-
@@ -99,8 +99,7 @@ export function MethodologyAppendix() {
value={
<>
(Human merges + reviews) × minutes per PR ÷ 60 × hourly rate.
- Bot-merged PRs are excluded. Currently using adjustable
- assumptions:{' '}
+ Bot-merged PRs are excluded. Currently using adjustable assumptions:{' '}
{data.costEstimate.humanMergeCount + data.costEstimate.humanReviewCount} actions ×{' '}
{assumptions.minutesPerPr} min ÷ 60 × ${assumptions.hourlyRateUsd}
/hr.
@@ -127,11 +126,8 @@ export function MethodologyAppendix() {
- The adjustable
- 5 min/PR default is deliberately low. The
- adjustable
- $150/hr default reflects a $300k engineer
- cost divided by 2,000 working hours.
+ The adjustable 5 min/PR default is deliberately low. The adjustable $150/hr default reflects a $300k
+ engineer cost divided by 2,000 working hours.
Estimates only; real savings vary by team. The defaults are intentionally conservative
diff --git a/src/report/web/acts/RiskStory.tsx b/src/report/web/acts/RiskStory.tsx
index faa2b7d..01a73f8 100644
--- a/src/report/web/acts/RiskStory.tsx
+++ b/src/report/web/acts/RiskStory.tsx
@@ -2,7 +2,7 @@ import { useState } from 'react';
import { useEmbeddedData } from '../data/EmbeddedDataContext.tsx';
import { Citation } from '../primitives/Citation.tsx';
import { FootnoteReference } from '../primitives/FootnoteReference.tsx';
-import { StackedBar } from '../primitives/StackedBar.tsx';
+import { SEGMENTS, StackedBar } from '../primitives/StackedBar.tsx';
export const riskStoryTestIds = {
section: 'risk-story-section',
@@ -122,7 +122,7 @@ export function RiskStory() {
Top repos by severity
-
+
>
)}
@@ -158,56 +158,65 @@ interface RepoSeverityRow {
low: number;
}
-function TopReposBySeverityTable({ repos }: { repos: readonly RepoSeverityRow[] }) {
+// Bars scale to the busiest repo so length reads as volume; the distribution
+// bar above already carries the legend, so these rows omit it.
+function TopReposBySeverityBars({ repos }: { repos: readonly RepoSeverityRow[] }) {
const [expanded, setExpanded] = useState(false);
const visibleRepos = expanded ? repos : repos.slice(0, INITIAL_REPO_COUNT);
const hiddenCount = repos.length - visibleRepos.length;
+ const maxTotal = Math.max(1, ...repos.map(repoTotal));
return (
-
-
-
-
- Repo
- Critical
- High
- Medium
- Low
-
-
-
- {visibleRepos.map((r) => (
-
- {r.repo}
- {r.critical || ''}
- {r.high || ''}
- {r.medium || ''}
- {r.low || ''}
-
- ))}
- {hiddenCount > 0 || expanded ? (
-
-
- setExpanded((open) => !open)}
- className="text-muted-foreground hover:text-foreground text-sm font-medium underline-offset-4 hover:underline"
- >
- {expanded ? 'Show top 5' : `Show ${hiddenCount.toLocaleString()} more`}
-
-
-
- ) : null}
-
-
+
+ {visibleRepos.map((r) => (
+
+ ))}
+ {hiddenCount > 0 || expanded ? (
+ setExpanded((open) => !open)}
+ className="text-muted-foreground hover:text-foreground pt-1 text-sm font-medium underline-offset-4 hover:underline"
+ >
+ {expanded ? 'Show top 5' : `Show ${hiddenCount.toLocaleString()} more`}
+
+ ) : null}
);
}
+function RepoSeverityBar({ repo, maxTotal }: { repo: RepoSeverityRow; maxTotal: number }) {
+ const total = repoTotal(repo);
+ const counts = { critical: repo.critical, high: repo.high, medium: repo.medium, low: repo.low };
+ return (
+
+
+ {repo.repo}
+
+
+
+ {SEGMENTS.map((s) => {
+ const v = counts[s.key];
+ if (v === 0) return null;
+ return (
+
+ );
+ })}
+
+
+
{total.toLocaleString()}
+
+ );
+}
+
+function repoTotal(r: RepoSeverityRow): number {
+ return r.critical + r.high + r.medium + r.low;
+}
+
function AgeCell({ label, days, tone }: { label: string; days: number; tone: 'critical' | 'high' }) {
const color = tone === 'critical' ? 'text-destructive' : 'text-tangerine';
return (
diff --git a/src/report/web/acts/Verdict.tsx b/src/report/web/acts/Verdict.tsx
index 169ca83..9355efc 100644
--- a/src/report/web/acts/Verdict.tsx
+++ b/src/report/web/acts/Verdict.tsx
@@ -1,14 +1,12 @@
import { useAnalytics } from '../analytics/AnalyticsContext.tsx';
import { Button } from '../components/ui/button.tsx';
-import { useEmbeddedData } from '../data/EmbeddedDataContext.tsx';
import { fmtUsd } from '../format/money.ts';
import { useAssumptions } from '../hooks/useAssumptions.tsx';
-import { AssumptionsFootnote } from '../primitives/AssumptionsFootnote.tsx';
+import { HeroAssumptions } from '../primitives/HeroAssumptions.tsx';
export const verdictTestIds = {
section: 'verdict-section',
annualCost: 'verdict-annual-cost',
- cveLine: 'verdict-cve-line',
primaryCta: 'verdict-primary-cta',
} as const;
@@ -16,7 +14,6 @@ export const verdictCopy = {
costLeadIn: 'Your engineering team spends',
costTrailer: 'triaging, reviewing, and merging Dependabot PRs',
primaryCta: 'See how PatchWave helps',
- cveScopeMissing: 'CVE exposure not measured (missing GitHub scope)',
} as const;
export function Verdict() {
@@ -34,11 +31,12 @@ export function Verdict() {
/year
- {verdictCopy.costTrailer}, based on adjustable
- assumptions
+ {verdictCopy.costTrailer}, based on adjustable assumptions
-
+
+
+
);
}
-
-// One dense line under the headline: volume and time first, the alert count as a
-// tail signal. The CVE fragment also carries the not-measured state so the
-// report never silently drops the security signal.
-function SupportingFacts() {
- const { prBacklog: pr } = useEmbeddedData();
- const oldestSuffix = pr.oldestOpenDays !== null ? ` (oldest ${pr.oldestOpenDays} days)` : '';
-
- return (
-
-
- {pr.mergedInWindowCount.toLocaleString()} Dependabot PRs
- merged
-
-
-
- {pr.openCount.toLocaleString()} still open{oldestSuffix}
-
-
-
-
- );
-}
-
-function CveFact() {
- const cve = useEmbeddedData().cve;
- if (cve.status === 'scope-missing') {
- return (
-
- {verdictCopy.cveScopeMissing}
-
- );
- }
- return (
-
- {cve.totalOpenAlerts.toLocaleString()} open security alerts
-
- );
-}
-
-function Dot() {
- return (
-
- ·
-
- );
-}
diff --git a/src/report/web/hooks/useAssumptionsDisclosure.tsx b/src/report/web/hooks/useAssumptionsDisclosure.tsx
index 9bb3e40..64017ff 100644
--- a/src/report/web/hooks/useAssumptionsDisclosure.tsx
+++ b/src/report/web/hooks/useAssumptionsDisclosure.tsx
@@ -28,9 +28,6 @@ export function AssumptionsDisclosureProvider({ children }: { children: ReactNod
const revealHashTarget = () => {
if (window.location.hash.length <= 1) return;
const target = decodeURIComponent(window.location.hash.slice(1));
- if (target === 'appendix-assumptions') {
- reveal('calculation');
- }
if (target === 'appendix-sources' || target.startsWith('footnote-')) {
setOpen(true);
}
diff --git a/src/report/web/primitives/AssumptionsFootnote.tsx b/src/report/web/primitives/AssumptionsFootnote.tsx
deleted file mode 100644
index 7576e5a..0000000
--- a/src/report/web/primitives/AssumptionsFootnote.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-import { useAnalytics } from '../analytics/AnalyticsContext.tsx';
-import { FootnoteReference } from './FootnoteReference.tsx';
-
-export const assumptionsFootnoteTestId = 'assumptions-footnote';
-export const assumptionsFootnoteId = 'assumptions';
-export const assumptionsPanelId = 'appendix-assumptions';
-
-export function AssumptionsFootnote({ from, className }: { from: string; className?: string }) {
- const analytics = useAnalytics();
- return (
-