Merge pull request #101 from AmanTahiliani/release/v0.4.0-current-weekend

release: v0.4.0 - Weekend fidelity recovery
This commit is contained in:
Aman Tahiliani
2026-07-29 23:41:54 -04:00
committed by GitHub
22 changed files with 711 additions and 17 deletions

1
.gitignore vendored
View File

@@ -30,6 +30,7 @@ node_modules/
/test-results/ /test-results/
/playwright-report/ /playwright-report/
/blob-report/ /blob-report/
/release-fidelity/
/playwright/.cache/ /playwright/.cache/
/playwright/.auth/ /playwright/.auth/

View File

@@ -0,0 +1,32 @@
# v0.4.0 — Current Weekend & Product Truth
Product research references for the v0.4.0 feature sprint.
## Direction
The sprint replaces route-first navigation with a state-aware Weekend experience:
- Weekend is the adaptive home for what happened, what is live, and what is next.
- Preview content folds into Weekend before a session.
- Live remains a stable deep link and becomes Weekend's active-session state.
- Race Hub remains explicit completed-session analysis rather than a primary landing destination.
- Championship and Briefing remain dedicated destinations.
- Explore owns secondary discovery; Admin moves to operator utility.
## Mockups
- `mockups/weekend-between-races.png` — desktop between-races/post-weekend state.
- `mockups/weekend-live.png` — desktop active-session state; the circuit is static sector context, not live GPS.
- `mockups/weekend-between-sessions-mobile.png` — 390×844 between-session state.
These are directional references, not pixel-perfect specifications. Implementations must preserve the established box-box visual language, accessibility, data constraints, and responsive behavior while satisfying their issue acceptance criteria.
## Constraints
- No OpenF1 REST dependency during active sessions.
- Public live GPS is not assumed to be available.
- Championship round numbers exclude tests and cancelled meetings.
- Connection health, live-session state, archive availability, and local-analysis readiness are separate concepts.
- Future sessions must not render empty post-session analysis.
The authoritative product decisions and research packet are recorded in GitHub issue #71 under epic #70.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 366 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

@@ -57,3 +57,58 @@ npm run test:visual:prod:update
``` ```
Snapshots live under `tests/visual/__snapshots__/`. Snapshots live under `tests/visual/__snapshots__/`.
## Release Fidelity and Owner Review
Visual regression and mockup fidelity are deliberately separate gates. `npm run test:visual:prod` verifies the candidate against committed regression snapshots; it does not establish that the current UI matches the approved product design.
After the approved references are available at `docs/product/<version>/mockups/`, make an offline review packet. For v0.4.0 the packet pairs `weekend-between-races` and `weekend-live` at the 1280×800 desktop reference viewport, plus `weekend-between-sessions-mobile` at the 390×844 mobile reference viewport. The capture configuration uses the same seeded SQLite data and unreachable OpenF1 endpoint as the visual suite.
```bash
export RELEASE_FIDELITY_VERSION=v0.4.0
npm run release:fidelity:capture
npm run release:fidelity:packet
```
Open `release-fidelity/v0.4.0/index.html` and review every approved-mockup/candidate pair. The owner, not an automated tool, records a decision in the committed file `docs/release/owner-reviews/v0.4.0.md`:
```md
# Owner Fidelity Sign-off: v0.4.0
- Version: v0.4.0
- Candidate commit: <full commit SHA>
- Reviewed by: <owner name>
- Reviewed on: YYYY-MM-DD
- Decision: approved
```
Do not create the file or use `approved` until the owner has reviewed the packet. Once it is committed, the release gate can verify the evidence and decision:
```bash
npm run release:fidelity:verify
```
The verifier requires the sign-off commit to be `HEAD` and to change only `docs/release/owner-reviews/<version>.md`. Its full candidate SHA must equal `HEAD^`; any code change after approval requires a new owner sign-off. It also requires the owner, date, and approved decision fields. It intentionally cannot assess visual fidelity or create approval.
For a release candidate, `npm run release:fidelity:gate` runs production visual regression first, then capture, packet generation, and owner-evidence verification in that order. It will remain red until the owner has committed the sign-off.
## Deployment and Rollback
Build and preserve a SHA-256 record with the deployable binary. Verify the staged binary before replacing the running one:
```bash
mkdir -p dist
go build -trimpath -o dist/box-box ./cmd/main.go
sha256sum dist/box-box | tee dist/box-box.sha256
sha256sum -c dist/box-box.sha256
```
Record the current production binary and its SHA before deployment. If owner review rejects the release or deployment fails, restore that saved binary, then verify the restored SHA is byte-identical to the pre-deployment record:
```bash
sha256sum /srv/box-box/box-box
install -m 0755 /srv/box-box/backups/box-box.previous /srv/box-box/box-box
sha256sum -c /srv/box-box/backups/box-box.previous.sha256
```
Restart and health-check the service using the deployment environment's normal procedure. Keep the candidate SHA, prior SHA, fidelity packet path, and owner sign-off path with the release record.

View File

@@ -20,6 +20,7 @@ import {
currentAndNextSession, currentAndNextSession,
focusMeetingKind, focusMeetingKind,
formatSessionScheduleTime, formatSessionScheduleTime,
meetingEndTime,
meetingHasStarted, meetingHasStarted,
mostRecentPastMeeting, mostRecentPastMeeting,
nextUpcomingMeeting, nextUpcomingMeeting,
@@ -34,6 +35,8 @@ const missingDatasets = Object.fromEntries(
) as WeekendSession['datasets'] ) as WeekendSession['datasets']
function meetingStatus(meeting: Meeting, focusKey: number | undefined, now: Date) { function meetingStatus(meeting: Meeting, focusKey: number | undefined, now: Date) {
const end = meetingEndTime(meeting)
if (end && now >= end) return 'past'
if (meeting.meeting_key === focusKey) return 'focus' if (meeting.meeting_key === focusKey) return 'focus'
if (meetingHasStarted(meeting, now)) return 'past' if (meetingHasStarted(meeting, now)) return 'past'
return 'future' return 'future'
@@ -163,6 +166,12 @@ export function CommandCenterPage() {
const lastPastWeekend = lastPastMeeting ? weekendsByKey.get(lastPastMeeting.meeting_key) : undefined const lastPastWeekend = lastPastMeeting ? weekendsByKey.get(lastPastMeeting.meeting_key) : undefined
const lastRaceAnalysis = pickAnalysisSession(lastPastWeekend) const lastRaceAnalysis = pickAnalysisSession(lastPastWeekend)
const lastRaceSessionKey = lastRaceAnalysis?.session.session_key const lastRaceSessionKey = lastRaceAnalysis?.session.session_key
const railSessions =
focusWeekendSessions.length > 0
? focusWeekendSessions
: heroStateKind === 'between'
? lastPastWeekend?.sessions ?? []
: []
const lastRaceHubQuery = useQuery({ const lastRaceHubQuery = useQuery({
queryKey: ['race-hub', lastRaceSessionKey, 'hero-podium'], queryKey: ['race-hub', lastRaceSessionKey, 'hero-podium'],
@@ -178,6 +187,16 @@ export function CommandCenterPage() {
const lastRacePodium = lastRaceHubQuery.data?.results ?? [] const lastRacePodium = lastRaceHubQuery.data?.results ?? []
const lastRaceName = champHub?.last_race ?? lastPastMeeting?.meeting_name ?? '' const lastRaceName = champHub?.last_race ?? lastPastMeeting?.meeting_name ?? ''
const focusNarrative =
heroStateKind === 'between'
? lastRaceName
? `${lastRaceName} is in the archive. The next chapter begins at ${focusMeeting?.circuit_short_name || focusMeeting?.meeting_name || 'the next round'}.`
: 'The season is between race weekends. Use the timeline to revisit a completed round or look ahead.'
: currentSession
? `${currentSession.session_name} is the active chapter of this weekend. Session detail remains available as it lands locally.`
: nextSession
? `${nextSession.session_name} is next on the timetable. The weekend rail keeps every session and its local analysis in reach.`
: 'The weekend timetable is ready to explore.'
if (seasonsQuery.isLoading) { if (seasonsQuery.isLoading) {
return <div className="page loading-state">loading command center</div> return <div className="page loading-state">loading command center</div>
@@ -226,7 +245,7 @@ export function CommandCenterPage() {
<div className="cc-topbar"> <div className="cc-topbar">
<span className="cc-topbar-label mono">box-box · command center</span> <span className="cc-topbar-label mono">box-box · command center</span>
<span className="cc-topbar-meta mono"> <span className="cc-topbar-meta mono">
{latestSeason} season · {meetingStats.full}/{meetingStats.total || 0} weekends full {latestSeason} season · weekend desk
</span> </span>
<span className="cc-live-pill" data-testid="cc-live-status"> <span className="cc-live-pill" data-testid="cc-live-status">
<span className={`cc-live-dot ${liveActive ? 'live' : ''}`} /> <span className={`cc-live-dot ${liveActive ? 'live' : ''}`} />
@@ -262,6 +281,26 @@ export function CommandCenterPage() {
/> />
)} )}
{focusMeeting && (
<section className="cc-context" data-testid="cc-circuit-context">
<div className="cc-context-decal mono" aria-hidden="true">
{countryDecal(focusMeeting)}
</div>
<div className="cc-context-copy">
<span className="cc-context-kicker mono">Circuit context</span>
<h2>{focusMeeting.circuit_short_name || focusMeeting.meeting_name}</h2>
<p>{focusNarrative}</p>
</div>
<div className="cc-context-meta mono">
<span>{focusMeeting.location || focusMeeting.country_name}</span>
<span>{formatGpDateRange(focusMeeting)}</span>
<Link to="/preview" className="cc-context-preview">
Weekend preview
</Link>
</div>
</section>
)}
<div className="cc-dashboard-grid"> <div className="cc-dashboard-grid">
<div className="cc-dashboard-main"> <div className="cc-dashboard-main">
<div <div
@@ -298,7 +337,9 @@ export function CommandCenterPage() {
<div className="cc-calendar-accent" aria-hidden="true" /> <div className="cc-calendar-accent" aria-hidden="true" />
<div className="cc-calendar-top mono"> <div className="cc-calendar-top mono">
<span className="cc-calendar-round">R{String(index + 1).padStart(2, '0')}</span> <span className="cc-calendar-round">R{String(index + 1).padStart(2, '0')}</span>
<span className={`cc-cov-dot cc-cov-${weekend?.source ?? 'none'}`} aria-hidden="true" /> <span className={`cc-calendar-status cc-calendar-status-${status}`}>
{status === 'past' ? 'Archive' : status === 'focus' ? 'Now' : 'Ahead'}
</span>
</div> </div>
<div className="cc-calendar-id"> <div className="cc-calendar-id">
{countryFlag(meeting) && <span className="cc-calendar-flag">{countryFlag(meeting)}</span>} {countryFlag(meeting) && <span className="cc-calendar-flag">{countryFlag(meeting)}</span>}
@@ -371,14 +412,14 @@ export function CommandCenterPage() {
</section> </section>
)} )}
{focusWeekendSessions.length > 0 && ( {railSessions.length > 0 && (
<section className="cc-schedule" data-testid="cc-schedule"> <section className="cc-schedule" data-testid="cc-schedule">
<div className="sec-header"> <div className="sec-header">
<span className="sec-title">Weekend Schedule</span> <span className="sec-title">Weekend Schedule</span>
<span className="sec-meta mono">{focusWeekendSessions.length} sessions</span> <span className="sec-meta mono">{railSessions.length} sessions</span>
</div> </div>
<div className="cc-session-strip" role="list"> <div className="cc-session-strip" role="list">
{focusWeekendSessions.map(({ session, source, datasets }) => { {railSessions.map(({ session, source, datasets }) => {
const status = classifySessionStatus(session, nowDate) const status = classifySessionStatus(session, nowDate)
const isNext = nextSession?.session_key === session.session_key const isNext = nextSession?.session_key === session.session_key
const isCurrent = currentSession?.session_key === session.session_key const isCurrent = currentSession?.session_key === session.session_key
@@ -404,9 +445,9 @@ export function CommandCenterPage() {
</div> </div>
<div className="cc-session-name">{session.session_name}</div> <div className="cc-session-name">{session.session_name}</div>
<div className="cc-session-time mono">{formatSessionScheduleTime(session.date_start)}</div> <div className="cc-session-time mono">{formatSessionScheduleTime(session.date_start)}</div>
<div className="cc-session-cov mono"> <div className="cc-session-cov mono" title={formatCoverageHint(datasets)}>
<span className={`cc-cov-dot cc-cov-${source}`} aria-hidden="true" /> <span className={`cc-cov-dot cc-cov-${source}`} aria-hidden="true" />
{formatCoverageHint(datasets)} {source === 'local' ? 'Analysis ready' : formatCoverageHint(datasets)}
</div> </div>
</Link> </Link>
) )
@@ -416,6 +457,10 @@ export function CommandCenterPage() {
)} )}
<PaddockBriefing /> <PaddockBriefing />
<div className="cc-data-note mono" data-testid="cc-data-note">
<span className="cc-cov-dot cc-cov-local" aria-hidden="true" />
{meetingStats.full}/{meetingStats.total || 0} locally complete · availability is shown per session
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -448,4 +493,3 @@ function FormSparkline({ form, color }: { form: number[], color: string }) {
</svg> </svg>
) )
} }

View File

@@ -4764,3 +4764,137 @@ a { color: inherit; text-decoration: none; }
background: #f82f34; background: #f82f34;
transform: translateY(-1px); transform: translateY(-1px);
} }
/* Weekend recut: put the active circuit and season journey ahead of data plumbing. */
.cc-context {
position: relative;
display: grid;
grid-template-columns: minmax(84px, 0.3fr) minmax(0, 1fr) auto;
gap: var(--s5);
align-items: center;
min-height: 112px;
padding: var(--s5) var(--s6);
overflow: hidden;
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
background:
linear-gradient(90deg, color-mix(in srgb, var(--gp-accent) 9%, transparent), transparent 46%),
var(--surface);
}
.cc-context-decal {
color: color-mix(in srgb, var(--gp-accent) 58%, var(--text));
font-size: clamp(38px, 6vw, 72px);
font-weight: 800;
letter-spacing: -0.08em;
line-height: 0.75;
opacity: 0.72;
}
.cc-context-copy h2 {
font-size: 18px;
line-height: 1.15;
}
.cc-context-kicker {
display: block;
margin-bottom: var(--s2);
color: var(--text-3);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.13em;
text-transform: uppercase;
}
.cc-context-copy p {
max-width: 64ch;
margin-top: var(--s2);
color: var(--text-2);
font-size: 12px;
}
.cc-context-meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: var(--s2);
color: var(--text-3);
font-size: 10px;
text-align: right;
}
.cc-context-preview {
margin-top: var(--s2);
color: var(--text-2);
font-weight: 700;
}
.cc-context-preview:hover { color: var(--text); }
.cc-calendar-grid {
display: flex;
gap: 0;
padding: var(--s3) 0 var(--s4);
overflow-x: auto;
scrollbar-width: thin;
}
.cc-calendar-card {
min-width: 112px;
min-height: 138px;
flex: 1 0 112px;
padding: var(--s3) var(--s4);
border-radius: 0;
border-width: 1px 1px 1px 0;
background: transparent;
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
.cc-calendar-card:first-child { border-left-width: 1px; }
.cc-calendar-card:hover { transform: none; box-shadow: inset 0 -2px 0 var(--gp-card-accent); }
.cc-calendar-card::before { display: none; }
.cc-calendar-accent { top: auto; bottom: 0; height: 2px; opacity: 0.65; }
.cc-calendar-past { filter: none; opacity: 0.62; }
.cc-calendar-past:hover { filter: none; opacity: 1; }
.cc-calendar-focus { background: color-mix(in srgb, var(--gp-card-accent) 10%, transparent); }
.cc-calendar-decal { font-size: 60px; bottom: -5px; }
.cc-calendar-status {
font-size: 9px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-3);
}
.cc-calendar-status-focus { color: var(--gp-card-accent); }
.cc-calendar-status-future { color: var(--text-2); }
.cc-data-note {
display: flex;
align-items: center;
gap: var(--s2);
padding-top: var(--s4);
border-top: 1px solid var(--border);
color: var(--text-3);
font-size: 10px;
}
@media (max-width: 899px) {
.cc-dashboard-grid { gap: var(--s5); }
.cc-dashboard-sidebar { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--s5); }
.cc-briefing, .cc-data-note { grid-column: 1 / -1; }
}
@media (max-width: 640px) {
.cc-context { grid-template-columns: 1fr auto; gap: var(--s4); padding: var(--s4); }
.cc-context-decal { display: none; }
.cc-context-meta { align-items: flex-start; grid-column: 1 / -1; text-align: left; }
.cc-context-copy h2 { font-size: 17px; }
.cc-context-copy p { font-size: 11px; }
.cc-dashboard-sidebar { display: flex; }
.cc-calendar-grid { margin: 0 calc(var(--s4) * -1); padding-left: var(--s4); padding-right: var(--s4); }
.cc-calendar-card { min-width: 104px; flex-basis: 104px; }
.cc-calendar-circuit { display: none; }
.cc-champ-snapshot { order: 2; }
.cc-schedule { order: 1; }
.cc-briefing { order: 3; margin-top: var(--s4); }
.cc-data-note { order: 4; }
}

View File

@@ -189,6 +189,9 @@ describe('CommandCenterPage', () => {
expect(screen.getByTestId('cc-focus')).toHaveTextContent('Monaco') expect(screen.getByTestId('cc-focus')).toHaveTextContent('Monaco')
expect(screen.getByTestId('cc-season-calendar')).toHaveTextContent('Season Calendar') expect(screen.getByTestId('cc-season-calendar')).toHaveTextContent('Season Calendar')
expect(screen.getByTestId('cc-calendar-1229')).toHaveTextContent('R01') expect(screen.getByTestId('cc-calendar-1229')).toHaveTextContent('R01')
expect(screen.getByTestId('cc-circuit-context')).toHaveTextContent('Circuit context')
expect(screen.getByTestId('cc-circuit-context')).toHaveTextContent('Weekend preview')
expect(screen.getByTestId('cc-data-note')).toHaveTextContent('availability is shown per session')
expect(screen.getByText('No live session')).toBeInTheDocument() expect(screen.getByText('No live session')).toBeInTheDocument()
expect(screen.getByTestId('hero-last-race-link')).toHaveTextContent('Monaco') expect(screen.getByTestId('hero-last-race-link')).toHaveTextContent('Monaco')
}) })
@@ -238,8 +241,40 @@ describe('CommandCenterPage', () => {
}) })
expect(screen.getByTestId('cc-focus')).toHaveTextContent('Live now') expect(screen.getByTestId('cc-focus')).toHaveTextContent('Live now')
expect(screen.getByTestId('cc-session-9602')).toHaveTextContent('On track') expect(screen.getByTestId('cc-session-9602')).toHaveTextContent('On track')
expect(screen.getByTestId('cc-calendar-1301')).toHaveTextContent('Archive')
expect(screen.getByTestId('cc-calendar-1302')).toHaveTextContent('Now')
expect(screen.getByTestId('hero-live-link')).toHaveAttribute('href', '/live') expect(screen.getByTestId('hero-live-link')).toHaveAttribute('href', '/live')
vi.useRealTimers() vi.useRealTimers()
}) })
it('keeps the completed local session rail available when the next weekend has no sessions', async () => {
const nextMeeting = {
...meeting,
meeting_key: 1303,
meeting_name: 'Canada',
country_name: 'Canada',
country_code: 'CAN',
circuit_short_name: 'Montreal',
date_start: '2026-06-12T00:00:00+00:00',
date_end: '2026-06-14T23:59:59+00:00',
year: 2026,
}
vi.setSystemTime(new Date('2026-06-06T15:15:00Z'))
mockFetchSeasons.mockResolvedValue([2026])
mockFetchLocalMeetings.mockResolvedValue([meeting])
mockFetchSeasonMeetings.mockResolvedValue([meeting, nextMeeting])
mockFetchWeekend.mockResolvedValue(weekend)
mockFetchSessions.mockResolvedValue([])
renderPage()
await waitFor(() => {
expect(screen.getByTestId('cc-session-9472')).toBeInTheDocument()
})
expect(screen.getByTestId('cc-schedule')).toHaveTextContent('1 sessions')
vi.useRealTimers()
})
}) })

View File

@@ -11,7 +11,12 @@
"test:visual": "playwright test --config playwright.visual.config.ts", "test:visual": "playwright test --config playwright.visual.config.ts",
"test:visual:prod": "playwright test --config playwright.visual.prod.config.ts", "test:visual:prod": "playwright test --config playwright.visual.prod.config.ts",
"test:visual:update": "playwright test --config playwright.visual.config.ts --update-snapshots", "test:visual:update": "playwright test --config playwright.visual.config.ts --update-snapshots",
"test:visual:prod:update": "playwright test --config playwright.visual.prod.config.ts --update-snapshots" "test:visual:prod:update": "playwright test --config playwright.visual.prod.config.ts --update-snapshots",
"release:fidelity:capture": "playwright test --config playwright.fidelity.config.ts",
"release:fidelity:packet": "node scripts/release-fidelity/generate.mjs",
"release:fidelity:verify": "node scripts/release-fidelity/verify.mjs",
"release:fidelity:gate": "npm run test:visual:prod && npm run release:fidelity:capture && npm run release:fidelity:packet && npm run release:fidelity:verify",
"test:release-fidelity": "node --test scripts/release-fidelity/*.test.mjs"
}, },
"type": "commonjs", "type": "commonjs",
"devDependencies": { "devDependencies": {

View File

@@ -0,0 +1,37 @@
import { defineConfig } from '@playwright/test'
import { VIEWPORTS } from './tests/visual/helpers'
const E2E_DB = '.playwright/boxbox-fidelity.db'
const API_PORT = process.env.BOXBOX_API_PORT ?? '18080'
const WEB_PORT = process.env.BOXBOX_WEB_PORT ?? '15173'
// Candidate captures are evidence for owner review, not regression baselines.
export default defineConfig({
testDir: './tests/release-fidelity',
fullyParallel: true,
forbidOnly: !!process.env.CI,
reporter: process.env.CI ? 'github' : 'list',
use: {
baseURL: `http://localhost:${WEB_PORT}`,
colorScheme: 'dark',
trace: 'on-first-retry',
},
projects: [
{ name: 'desktop', use: { browserName: 'chromium', viewport: VIEWPORTS.desktop } },
{ name: 'mobile', use: { browserName: 'chromium', viewport: VIEWPORTS.mobile } },
],
webServer: [
{
command: `go run ./scripts/seed-e2e-db/main.go --db ${E2E_DB} && BOXBOX_DISABLE_LIVE=1 BOXBOX_OPENF1_BASE_URL=http://127.0.0.1:9 go run ./cmd/main.go --web --db ${E2E_DB} --port ${API_PORT}`,
url: `http://localhost:${API_PORT}/api/v1/race-hub?session_key=9472`,
reuseExistingServer: false,
timeout: 120_000,
},
{
command: `BOXBOX_API_PORT=${API_PORT} npm run dev --prefix frontend -- --port ${WEB_PORT} --strictPort`,
url: `http://localhost:${WEB_PORT}`,
reuseExistingServer: false,
timeout: 120_000,
},
],
})

View File

@@ -7,10 +7,11 @@ const WEB_PORT = process.env.BOXBOX_WEB_PORT ?? '15173'
export default defineConfig({ export default defineConfig({
testDir: './tests/visual', testDir: './tests/visual',
fullyParallel: true, // All projects share one seeded SQLite database behind the same Go server.
fullyParallel: false,
forbidOnly: !!process.env.CI, forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0, retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined, workers: 1,
reporter: process.env.CI ? 'github' : 'html', reporter: process.env.CI ? 'github' : 'html',
snapshotPathTemplate: '{testDir}/{testFileDir}/__snapshots__/{projectName}/{arg}{ext}', snapshotPathTemplate: '{testDir}/{testFileDir}/__snapshots__/{projectName}/{arg}{ext}',
expect: { expect: {

View File

@@ -0,0 +1,86 @@
import { readdir, readFile, stat, writeFile } from 'node:fs/promises'
import { join, relative, resolve } from 'node:path'
const references = [
{ viewport: 'desktop', name: 'weekend-between-races' },
{ viewport: 'desktop', name: 'weekend-live' },
{ viewport: 'mobile', name: 'weekend-between-sessions-mobile' },
]
function option(name, fallback) {
const index = process.argv.indexOf(`--${name}`)
return index === -1 ? fallback : process.argv[index + 1]
}
async function filesBelow(directory) {
const entries = await readdir(directory, { withFileTypes: true })
const files = await Promise.all(entries.map(async (entry) => {
const path = join(directory, entry.name)
return entry.isDirectory() ? filesBelow(path) : [path]
}))
return files.flat()
}
async function locateMockup(root, name) {
const preferred = join(root, `${name}.png`)
try {
await stat(preferred)
return preferred
} catch {
const matches = (await filesBelow(root)).filter((path) => path.endsWith(`${name}.png`))
return matches.length === 1 ? matches[0] : null
}
}
function imageData(path) {
return readFile(path).then((data) => `data:image/png;base64,${data.toString('base64')}`)
}
export async function generatePacket({ version, evidence, mockups }) {
const candidateRoot = join(evidence, 'candidate')
const pairs = []
const missing = []
try {
await stat(mockups)
} catch {
throw new Error(`Approved mockups directory is missing: ${mockups}. It is supplied by the product reference integration.`)
}
for (const { viewport, name } of references) {
const candidate = join(candidateRoot, viewport, `${name}.png`)
const reference = await locateMockup(mockups, name)
try {
await stat(candidate)
} catch {
missing.push(`candidate: ${candidate}`)
}
if (!reference) missing.push(`mockup: ${join(mockups, `${name}.png`)}`)
if (reference) pairs.push({ viewport, name, candidate, reference })
}
if (missing.length) {
throw new Error(`Release-fidelity packet is incomplete:\n${missing.join('\n')}`)
}
const cards = await Promise.all(pairs.map(async ({ viewport, name, candidate, reference }) => `
<section><h2>${viewport}: ${name}</h2><div class="pair">
<figure><figcaption>Approved mockup</figcaption><img src="${await imageData(reference)}"></figure>
<figure><figcaption>Candidate</figcaption><img src="${await imageData(candidate)}"></figure>
</div><p>Reference: <code>${relative(process.cwd(), reference)}</code></p></section>`))
const summary = `# Release Fidelity Evidence: ${version}\n\n- Candidate screenshots: candidate/\n- Approved mockups: ${relative(process.cwd(), mockups)}\n- Review: human owner decision required; this packet does not approve the release.\n\nOpen index.html for side-by-side evidence.\n`
const html = `<!doctype html><title>${version} release-fidelity review</title><style>body{background:#111;color:#eee;font:16px system-ui;margin:2rem}section{margin:3rem 0}.pair{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:1rem}figure{margin:0}img{max-width:100%;border:1px solid #555}figcaption{font-weight:700;margin-bottom:.5rem}code{color:#9fd}@media(max-width:700px){.pair{grid-template-columns:1fr}}</style><h1>${version} Release-Fidelity Review</h1><p>Visual regression is a separate automated gate. This packet is evidence for a subjective owner review and is not approval.</p>${cards.join('')}</html>`
await writeFile(join(evidence, 'summary.md'), summary)
await writeFile(join(evidence, 'index.html'), html)
}
if (import.meta.url === `file://${process.argv[1]}`) {
const version = option('version', process.env.RELEASE_FIDELITY_VERSION ?? 'v0.4.0')
const output = process.env.RELEASE_FIDELITY_OUTPUT ?? 'release-fidelity'
const evidence = resolve(option('evidence', join(output, version)))
const mockups = resolve(option('mockups', join('docs/product', version, 'mockups')))
generatePacket({ version, evidence, mockups }).then(
() => console.log(`Release-fidelity packet: ${join(evidence, 'index.html')}`),
(error) => { console.error(error.message); process.exitCode = 1 },
)
}

View File

@@ -0,0 +1,27 @@
import assert from 'node:assert/strict'
import { mkdtemp, mkdir, readFile, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import test from 'node:test'
import { generatePacket } from './generate.mjs'
test('generates a self-contained side-by-side packet for desktop and mobile', async () => {
const root = await mkdtemp(join(tmpdir(), 'boxbox-fidelity-'))
const evidence = join(root, 'evidence')
const mockups = join(root, 'mockups')
const references = [
['desktop', 'weekend-between-races'],
['desktop', 'weekend-live'],
['mobile', 'weekend-between-sessions-mobile'],
]
for (const [viewport, name] of references) {
await mkdir(join(evidence, 'candidate', viewport), { recursive: true })
await mkdir(mockups, { recursive: true })
await writeFile(join(evidence, 'candidate', viewport, `${name}.png`), 'candidate')
await writeFile(join(mockups, `${name}.png`), 'mockup')
}
await generatePacket({ version: 'v-test', evidence, mockups })
assert.match(await readFile(join(evidence, 'index.html'), 'utf8'), /Approved mockup/)
assert.match(await readFile(join(evidence, 'summary.md'), 'utf8'), /human owner decision required/)
})

View File

@@ -0,0 +1,50 @@
import { readFile } from 'node:fs/promises'
import { execFileSync } from 'node:child_process'
import { join, resolve } from 'node:path'
function option(name, fallback) {
const index = process.argv.indexOf(`--${name}`)
return index === -1 ? fallback : process.argv[index + 1]
}
function git(...args) {
return execFileSync('git', args, { encoding: 'utf8' }).trim()
}
const version = option('version', process.env.RELEASE_FIDELITY_VERSION ?? 'v0.4.0')
const output = process.env.RELEASE_FIDELITY_OUTPUT ?? 'release-fidelity'
const evidence = resolve(option('evidence', join(output, version)))
const signoff = option('signoff', join('docs/release/owner-reviews', `${version}.md`))
const required = ['index.html', 'summary.md']
try {
for (const file of required) await readFile(join(evidence, file))
git('cat-file', '-e', `HEAD:${signoff}`)
const text = git('show', `HEAD:${signoff}`)
const candidate = text.match(/^- Candidate commit: ([0-9a-f]{40})$/mi)?.[1]
if (!candidate) throw new Error(`${signoff} must contain a full candidate commit SHA`)
git('cat-file', '-e', `${candidate}^{commit}`)
const parent = git('rev-parse', 'HEAD^')
const changed = git('diff', '--name-only', 'HEAD^', 'HEAD').split('\n').filter(Boolean)
if (changed.length !== 1 || changed[0] !== signoff) {
throw new Error(`HEAD must contain only the sign-off file change: ${signoff}`)
}
if (candidate.toLowerCase() !== parent.toLowerCase()) {
throw new Error(`${signoff} candidate must equal HEAD^ (${parent})`)
}
const fields = [
['Version', version],
['Reviewed by', '.+'],
['Reviewed on', '\\d{4}-\\d{2}-\\d{2}'],
['Decision', 'approved'],
]
for (const [name, value] of fields) {
if (!new RegExp(`^- ${name}: ${value}$`, 'm').test(text)) {
throw new Error(`${signoff} must contain "- ${name}: ${value}"`)
}
}
console.log(`Release-fidelity evidence and committed owner approval verified for ${version} at ${candidate}.`)
} catch (error) {
console.error(`Release-fidelity gate blocked: ${error.message}`)
process.exitCode = 1
}

View File

@@ -0,0 +1,80 @@
import assert from 'node:assert/strict'
import { execFileSync } from 'node:child_process'
import { mkdtemp, mkdir, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import test from 'node:test'
const verifier = resolve('scripts/release-fidelity/verify.mjs')
function git(directory, ...args) {
return execFileSync('git', args, { cwd: directory, encoding: 'utf8' }).trim()
}
function verify(root, evidence) {
return execFileSync(process.execPath, [verifier, '--version', 'v-test', '--evidence', evidence], {
cwd: root,
encoding: 'utf8',
})
}
async function approvedCandidate({ changeCodeWithSignoff = false, decision = 'approved' } = {}) {
const root = await mkdtemp(join(tmpdir(), 'boxbox-fidelity-verify-'))
git(root, 'init')
git(root, 'config', 'user.email', 'test@example.com')
git(root, 'config', 'user.name', 'Test')
await writeFile(join(root, 'candidate.txt'), 'candidate')
git(root, 'add', 'candidate.txt')
git(root, 'commit', '-m', 'candidate')
const candidate = git(root, 'rev-parse', 'HEAD')
await mkdir(join(root, 'docs/release/owner-reviews'), { recursive: true })
await writeFile(join(root, 'docs/release/owner-reviews/v-test.md'), `- Version: v-test\n- Candidate commit: ${candidate}\n- Reviewed by: Owner\n- Reviewed on: 2026-07-30\n- Decision: ${decision}\n`)
if (changeCodeWithSignoff) await writeFile(join(root, 'candidate.txt'), 'changed with approval')
git(root, 'add', 'docs/release/owner-reviews/v-test.md')
if (changeCodeWithSignoff) git(root, 'add', 'candidate.txt')
git(root, 'commit', '-m', 'owner sign-off')
const evidence = join(root, 'evidence')
await mkdir(evidence)
await writeFile(join(evidence, 'index.html'), '')
await writeFile(join(evidence, 'summary.md'), '')
return { candidate, evidence, root }
}
function assertBlocked(root, evidence) {
assert.throws(() => verify(root, evidence), (error) => {
assert.match(String(error.stderr), /HEAD must contain only the sign-off file change/)
return true
})
}
test('accepts a sign-off-only HEAD for its direct parent candidate', async () => {
const { candidate, evidence, root } = await approvedCandidate()
const output = verify(root, evidence)
assert.match(output, new RegExp(candidate))
})
test('rejects code committed after approval', async () => {
const { evidence, root } = await approvedCandidate()
await writeFile(join(root, 'candidate.txt'), 'changed after review')
git(root, 'add', 'candidate.txt')
git(root, 'commit', '-m', 'code after approval')
assertBlocked(root, evidence)
})
test('rejects a sign-off commit that also changes code', async () => {
const { evidence, root } = await approvedCandidate({ changeCodeWithSignoff: true })
assertBlocked(root, evidence)
})
test('rejects a dirty working-tree edit that spoofs approval', async () => {
const { candidate, evidence, root } = await approvedCandidate({ decision: 'rejected' })
await writeFile(join(root, 'docs/release/owner-reviews/v-test.md'), `- Version: v-test\n- Candidate commit: ${candidate}\n- Reviewed by: Owner\n- Reviewed on: 2026-07-30\n- Decision: approved\n`)
assert.throws(() => verify(root, evidence), (error) => {
assert.match(String(error.stderr), /Decision: approved/)
return true
})
})

View File

@@ -12,6 +12,30 @@ test.describe('Command Center', () => {
await expect(page.getByTestId('hero-last-race-link')).toBeVisible() await expect(page.getByTestId('hero-last-race-link')).toBeVisible()
}) })
test('falls back to the local calendar when season metadata fails', async ({ page }) => {
await page.route(/\/api\/v1\/meetings(?:\?.*)?$/, async (route) => {
const url = new URL(route.request().url())
if (url.searchParams.get('year') !== '2025' || url.searchParams.get('source') !== 'openf1') {
await route.continue()
return
}
await route.fulfill({
status: 503,
contentType: 'application/json',
body: JSON.stringify({ error: 'season calendar unavailable' }),
})
})
await page.goto('/')
await expect(page.getByTestId('command-center')).toBeVisible()
await expect(page.getByTestId('cc-calendar-1229')).toBeVisible()
await expect(
page.getByText('Using local meetings because the full calendar could not load.'),
).toBeVisible({ timeout: 15_000 })
})
test('nav link reaches command center from race hub', async ({ page }) => { test('nav link reaches command center from race hub', async ({ page }) => {
await page.goto(`/race-hub?session_key=${FULL_SESSION}`) await page.goto(`/race-hub?session_key=${FULL_SESSION}`)
await page.getByRole('link', { name: 'Command' }).click() await page.getByRole('link', { name: 'Command' }).click()

View File

@@ -0,0 +1,73 @@
import { mkdir } from 'node:fs/promises'
import { join } from 'node:path'
import { expect, test, type Page } from '@playwright/test'
import { waitForScreenshotReady } from '../visual/helpers'
const version = process.env.RELEASE_FIDELITY_VERSION ?? 'v0.4.0'
const output = process.env.RELEASE_FIDELITY_OUTPUT ?? 'release-fidelity'
const activeLiveState = {
is_live: true,
data: {
Drivers: {
'1': { RacingNumber: '1', Position: 1, Interval: '' },
'44': { RacingNumber: '44', Position: 2, Interval: '+2.314' },
},
DriverInfo: {
'1': { RacingNumber: '1', Tla: 'VER', TeamColour: '3671C6' },
'44': { RacingNumber: '44', Tla: 'HAM', TeamColour: 'E8002D' },
},
Tyres: {},
RCMessages: [],
Weather: {},
Session: { MeetingName: 'Monaco', SessionName: 'Race', SessionType: 'Race' },
TrackStatus: '1',
},
}
async function capture(page: Page, name: string, project: string): Promise<void> {
const directory = join(output, version, 'candidate', project)
await mkdir(directory, { recursive: true })
await page.screenshot({ fullPage: true, path: join(directory, `${name}.png`) })
}
async function useSeededCalendar(page: Page): Promise<void> {
await page.route(/\/api\/v1\/meetings\?year=2025&source=openf1$/, (route) =>
route.fulfill({ contentType: 'application/json', body: '[]' }),
)
}
async function gotoCommandCenterFidelityReady(page: Page): Promise<void> {
await page.goto('/')
await expect(page.getByTestId('command-center')).toBeVisible()
await expect(page.getByTestId('cc-focus')).toBeVisible()
await expect(page.getByTestId('cc-calendar-1229')).toBeVisible()
await page.waitForLoadState('networkidle')
await waitForScreenshotReady(page)
}
test.describe('release-fidelity candidate captures', () => {
test('approved screen set', async ({ page }, testInfo) => {
const project = testInfo.project.name
await useSeededCalendar(page)
if (project === 'desktop') {
await page.clock.install({ time: new Date('2025-06-01T12:00:00Z') })
await gotoCommandCenterFidelityReady(page)
await expect(page.getByTestId('hero-last-race')).toBeVisible()
await capture(page, 'weekend-between-races', project)
await page.route('**/api/v1/live/state', (route) =>
route.fulfill({ contentType: 'application/json', body: JSON.stringify(activeLiveState) }),
)
await page.clock.setFixedTime(new Date('2025-05-25T14:00:00Z'))
await gotoCommandCenterFidelityReady(page)
await expect(page.getByTestId('hero-live-timing')).toBeVisible()
await capture(page, 'weekend-live', project)
} else {
await page.clock.install({ time: new Date('2025-05-25T12:00:00Z') })
await gotoCommandCenterFidelityReady(page)
await expect(page.getByTestId('hero-countdown')).toContainText('Next')
await capture(page, 'weekend-between-sessions-mobile', project)
}
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

After

Width:  |  Height:  |  Size: 154 KiB

View File

@@ -8,6 +8,20 @@ export const VIEWPORTS = {
export const FULL_SESSION = 9472 export const FULL_SESSION = 9472
async function routeSeededSeasonCalendar(page: Page): Promise<void> {
await page.route(/\/api\/v1\/meetings(?:\?.*)?$/, async (route) => {
const url = new URL(route.request().url())
if (url.searchParams.get('year') !== '2025' || url.searchParams.get('source') !== 'openf1') {
await route.continue()
return
}
url.searchParams.set('source', 'local')
const response = await route.fetch({ url: url.toString() })
await route.fulfill({ response })
})
}
/** Wait for web fonts and layout to settle before screenshots. */ /** Wait for web fonts and layout to settle before screenshots. */
export async function waitForScreenshotReady(page: Page): Promise<void> { export async function waitForScreenshotReady(page: Page): Promise<void> {
await page.evaluate(() => document.fonts.ready) await page.evaluate(() => document.fonts.ready)
@@ -15,15 +29,11 @@ export async function waitForScreenshotReady(page: Page): Promise<void> {
} }
export async function gotoCommandCenterReady(page: Page): Promise<void> { export async function gotoCommandCenterReady(page: Page): Promise<void> {
await routeSeededSeasonCalendar(page)
await page.goto('/') await page.goto('/')
await expect(page.getByTestId('command-center')).toBeVisible() await expect(page.getByTestId('command-center')).toBeVisible()
await expect(page.getByTestId('cc-focus')).toBeVisible() await expect(page.getByTestId('cc-focus')).toBeVisible()
await expect(page.getByTestId('cc-session-9472')).toBeVisible() await expect(page.getByTestId('cc-calendar-1229')).toBeVisible({ timeout: 15_000 })
// The e2e stack runs with an unreachable OpenF1 base URL, so wait for the
// season-calendar query to settle on its local fallback before screenshotting.
await expect(
page.getByText('Using local meetings because the full calendar could not load.'),
).toBeVisible()
await waitForScreenshotReady(page) await waitForScreenshotReady(page)
} }