Compare commits
22 Commits
480e6ca860
...
feat/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1e6b6af35 | ||
|
|
21b29bec6a | ||
|
|
fbae59c431 | ||
|
|
3397352835 | ||
|
|
3a377d0bc4 | ||
|
|
3228e4adbd | ||
|
|
44a32bfd73 | ||
|
|
deda3bc4ad | ||
|
|
48030a6d7c | ||
|
|
74b33d1853 | ||
|
|
cefdac7006 | ||
|
|
7434621024 | ||
|
|
7be0b808fe | ||
|
|
26cd1857f6 | ||
|
|
8e0d0253c2 | ||
|
|
de614e6339 | ||
|
|
9ef8c7e9a3 | ||
|
|
dff187da92 | ||
|
|
9c0d37904c | ||
|
|
54ddc13f57 | ||
|
|
a1d8f85fd3 | ||
|
|
3b9b58ff7b |
1
.gitignore
vendored
@@ -30,6 +30,7 @@ node_modules/
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/release-fidelity/
|
||||
/playwright/.cache/
|
||||
/playwright/.auth/
|
||||
|
||||
|
||||
32
docs/product/v0.4.0/README.md
Normal 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.
|
||||
BIN
docs/product/v0.4.0/mockups/weekend-between-races.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
docs/product/v0.4.0/mockups/weekend-between-sessions-mobile.png
Normal file
|
After Width: | Height: | Size: 366 KiB |
BIN
docs/product/v0.4.0/mockups/weekend-live.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
@@ -57,3 +57,58 @@ npm run test:visual:prod:update
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
@@ -29,6 +29,8 @@ const SOURCE_DISPLAY: Record<string, string> = {
|
||||
'f1-youtube': 'F1 YouTube',
|
||||
}
|
||||
|
||||
const PREVIEW_LIMIT = 10
|
||||
|
||||
export function PaddockBriefing() {
|
||||
const now = useMemo(() => new Date(), [])
|
||||
|
||||
@@ -76,7 +78,9 @@ export function PaddockBriefing() {
|
||||
const sinceLabel = sinceLastLabel(meetings, now)
|
||||
|
||||
const unreadCount = news?.filter((i) => !i.read_at).length ?? 0
|
||||
const preview = sinceItems.length > 0 ? sinceItems.slice(0, 5) : (tagged.slice(0, 5))
|
||||
// The strip flexes to the bottom of the dashboard column, so offer more than
|
||||
// fits and let the overflow scroll rather than leaving the column short.
|
||||
const preview = sinceItems.length > 0 ? sinceItems.slice(0, PREVIEW_LIMIT) : tagged.slice(0, PREVIEW_LIMIT)
|
||||
|
||||
return (
|
||||
<section className="cc-briefing" data-testid="paddock-briefing">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useQueries, useQuery } from '@tanstack/react-query'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import {
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
currentAndNextSession,
|
||||
focusMeetingKind,
|
||||
formatSessionScheduleTime,
|
||||
meetingEndTime,
|
||||
meetingHasStarted,
|
||||
mostRecentPastMeeting,
|
||||
nextUpcomingMeeting,
|
||||
@@ -34,6 +35,8 @@ const missingDatasets = Object.fromEntries(
|
||||
) as WeekendSession['datasets']
|
||||
|
||||
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 (meetingHasStarted(meeting, now)) return 'past'
|
||||
return 'future'
|
||||
@@ -53,6 +56,9 @@ function pickAnalysisSession(weekend: Weekend | undefined): WeekendSession | und
|
||||
|
||||
export function CommandCenterPage() {
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
const calendarScrollRef = useRef<HTMLDivElement | null>(null)
|
||||
const focusCardRef = useRef<HTMLAnchorElement | null>(null)
|
||||
const railTakenOver = useRef(false)
|
||||
|
||||
const seasonsQuery = useQuery({
|
||||
queryKey: ['seasons'],
|
||||
@@ -163,6 +169,12 @@ export function CommandCenterPage() {
|
||||
const lastPastWeekend = lastPastMeeting ? weekendsByKey.get(lastPastMeeting.meeting_key) : undefined
|
||||
const lastRaceAnalysis = pickAnalysisSession(lastPastWeekend)
|
||||
const lastRaceSessionKey = lastRaceAnalysis?.session.session_key
|
||||
const railSessions =
|
||||
focusWeekendSessions.length > 0
|
||||
? focusWeekendSessions
|
||||
: heroStateKind === 'between'
|
||||
? lastPastWeekend?.sessions ?? []
|
||||
: []
|
||||
|
||||
const lastRaceHubQuery = useQuery({
|
||||
queryKey: ['race-hub', lastRaceSessionKey, 'hero-podium'],
|
||||
@@ -178,6 +190,66 @@ export function CommandCenterPage() {
|
||||
|
||||
const lastRacePodium = lastRaceHubQuery.data?.results ?? []
|
||||
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.'
|
||||
|
||||
// Park the calendar rail on the GP in focus so it lands centred, instead of
|
||||
// making the reader scroll past every round that has already happened.
|
||||
const focusMeetingKey = focusMeeting?.meeting_key
|
||||
useEffect(() => {
|
||||
railTakenOver.current = false
|
||||
}, [focusMeetingKey])
|
||||
|
||||
useEffect(() => {
|
||||
const rail = calendarScrollRef.current
|
||||
const card = focusCardRef.current
|
||||
if (!rail || !card) return
|
||||
|
||||
const sync = () => {
|
||||
const overflows = rail.scrollWidth > rail.clientWidth + 1
|
||||
rail.classList.toggle('is-scrollable', overflows)
|
||||
if (!overflows || railTakenOver.current) return
|
||||
const railBox = rail.getBoundingClientRect()
|
||||
const cardBox = card.getBoundingClientRect()
|
||||
const offset = rail.scrollLeft + (cardBox.left - railBox.left)
|
||||
const target = offset - (rail.clientWidth - cardBox.width) / 2
|
||||
rail.scrollLeft = Math.max(0, Math.min(target, rail.scrollWidth - rail.clientWidth))
|
||||
}
|
||||
|
||||
sync()
|
||||
|
||||
const takeOver = () => {
|
||||
railTakenOver.current = true
|
||||
}
|
||||
rail.addEventListener('wheel', takeOver, { passive: true })
|
||||
rail.addEventListener('touchstart', takeOver, { passive: true })
|
||||
rail.addEventListener('pointerdown', takeOver)
|
||||
rail.addEventListener('keydown', takeOver)
|
||||
|
||||
// The strip reflows as fonts load and as the column width changes, so keep
|
||||
// re-centring until the reader takes the rail over themselves.
|
||||
let observer: ResizeObserver | undefined
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
observer = new ResizeObserver(sync)
|
||||
observer.observe(rail)
|
||||
}
|
||||
|
||||
return () => {
|
||||
observer?.disconnect()
|
||||
rail.removeEventListener('wheel', takeOver)
|
||||
rail.removeEventListener('touchstart', takeOver)
|
||||
rail.removeEventListener('pointerdown', takeOver)
|
||||
rail.removeEventListener('keydown', takeOver)
|
||||
}
|
||||
}, [focusMeetingKey, seasonMeetings.length])
|
||||
|
||||
if (seasonsQuery.isLoading) {
|
||||
return <div className="page loading-state">loading command center…</div>
|
||||
@@ -226,7 +298,7 @@ export function CommandCenterPage() {
|
||||
<div className="cc-topbar">
|
||||
<span className="cc-topbar-label mono">box-box · command center</span>
|
||||
<span className="cc-topbar-meta mono">
|
||||
{latestSeason} season · {meetingStats.full}/{meetingStats.total || 0} weekends full
|
||||
{latestSeason} season · weekend desk
|
||||
</span>
|
||||
<span className="cc-live-pill" data-testid="cc-live-status">
|
||||
<span className={`cc-live-dot ${liveActive ? 'live' : ''}`} />
|
||||
@@ -262,6 +334,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-main">
|
||||
<div
|
||||
@@ -278,7 +370,7 @@ export function CommandCenterPage() {
|
||||
{seasonMeetings.length} rounds · {meetingStats.full}/{meetingStats.total || 0} local
|
||||
</span>
|
||||
</div>
|
||||
<div className="cc-calendar-grid" role="list">
|
||||
<div className="cc-calendar-grid" role="list" ref={calendarScrollRef} data-testid="cc-calendar-rail">
|
||||
{seasonMeetings.map((meeting, index) => {
|
||||
const weekend = weekendsByKey.get(meeting.meeting_key)
|
||||
const target = pickAnalysisSession(weekend)
|
||||
@@ -288,6 +380,7 @@ export function CommandCenterPage() {
|
||||
return (
|
||||
<Link
|
||||
key={meeting.meeting_key}
|
||||
ref={status === 'focus' ? focusCardRef : undefined}
|
||||
to="/race-hub"
|
||||
search={targetKey ? { session_key: targetKey } : {}}
|
||||
className={`cc-calendar-card cc-calendar-${status}`}
|
||||
@@ -298,7 +391,9 @@ export function CommandCenterPage() {
|
||||
<div className="cc-calendar-accent" aria-hidden="true" />
|
||||
<div className="cc-calendar-top mono">
|
||||
<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 className="cc-calendar-id">
|
||||
{countryFlag(meeting) && <span className="cc-calendar-flag">{countryFlag(meeting)}</span>}
|
||||
@@ -318,6 +413,7 @@ export function CommandCenterPage() {
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
<PaddockBriefing />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -371,14 +467,14 @@ export function CommandCenterPage() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{focusWeekendSessions.length > 0 && (
|
||||
{railSessions.length > 0 && (
|
||||
<section className="cc-schedule" data-testid="cc-schedule">
|
||||
<div className="sec-header">
|
||||
<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 className="cc-session-strip" role="list">
|
||||
{focusWeekendSessions.map(({ session, source, datasets }) => {
|
||||
{railSessions.map(({ session, source, datasets }) => {
|
||||
const status = classifySessionStatus(session, nowDate)
|
||||
const isNext = nextSession?.session_key === session.session_key
|
||||
const isCurrent = currentSession?.session_key === session.session_key
|
||||
@@ -404,9 +500,9 @@ export function CommandCenterPage() {
|
||||
</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-cov mono">
|
||||
<div className="cc-session-cov mono" title={formatCoverageHint(datasets)}>
|
||||
<span className={`cc-cov-dot cc-cov-${source}`} aria-hidden="true" />
|
||||
{formatCoverageHint(datasets)}
|
||||
{source === 'local' ? 'Analysis ready' : formatCoverageHint(datasets)}
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
@@ -415,7 +511,10 @@ export function CommandCenterPage() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
<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>
|
||||
@@ -448,4 +547,3 @@ function FormSparkline({ form, color }: { form: number[], color: string }) {
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -58,35 +58,67 @@ function SectionState({
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
function circuitIdentity(meeting: Meeting): { name: string; place: string } {
|
||||
const name = meeting.circuit_short_name?.trim() || meeting.meeting_name
|
||||
const location = meeting.location?.trim() ?? ''
|
||||
const country = meeting.country_name?.trim() ?? ''
|
||||
const parts: string[] = []
|
||||
if (location) parts.push(location)
|
||||
if (country && country !== location) parts.push(country)
|
||||
return { name, place: parts.join(' · ') }
|
||||
}
|
||||
|
||||
function TrackOutlineCard({
|
||||
meeting,
|
||||
outline,
|
||||
loading,
|
||||
error,
|
||||
accent,
|
||||
}: {
|
||||
meeting: Meeting
|
||||
outline: TrackOutline | null | undefined
|
||||
loading: boolean
|
||||
error: Error | null
|
||||
accent: string
|
||||
}) {
|
||||
const outlinePath = useMemo(() => buildOutlinePath(outline?.points ?? []), [outline])
|
||||
const identity = circuitIdentity(meeting)
|
||||
|
||||
return (
|
||||
<section className="preview-card" data-testid="preview-track-card">
|
||||
<h2 className="preview-card-title">Circuit</h2>
|
||||
<SectionState
|
||||
loading={loading}
|
||||
error={error}
|
||||
empty={!outline || !outlinePath}
|
||||
emptyMessage="Track outline unavailable for this circuit"
|
||||
>
|
||||
let body: ReactNode
|
||||
if (loading) {
|
||||
body = <div className="preview-section-state">Loading…</div>
|
||||
} else if (error) {
|
||||
body = (
|
||||
<div className="preview-section-state error">
|
||||
{error instanceof Error ? error.message : 'Failed to load'}
|
||||
</div>
|
||||
)
|
||||
} else if (outlinePath) {
|
||||
body = (
|
||||
<div className="preview-track-stage" style={{ ['--preview-accent' as string]: accent }}>
|
||||
<svg className="preview-track-svg" viewBox="0 0 100 100" role="img" aria-label="Circuit outline">
|
||||
<path className="preview-track-shadow" d={outlinePath} />
|
||||
<path className="preview-track-outline" d={outlinePath} />
|
||||
</svg>
|
||||
</div>
|
||||
</SectionState>
|
||||
)
|
||||
} else {
|
||||
body = (
|
||||
<div className="preview-track-fallback" data-testid="preview-track-fallback">
|
||||
<div className="preview-track-fallback-glyph" aria-hidden />
|
||||
<p className="preview-track-fallback-name">{identity.name}</p>
|
||||
{identity.place && <p className="preview-track-fallback-place">{identity.place}</p>}
|
||||
<p className="preview-track-fallback-hint">
|
||||
A GPS track outline is not available in the local cache.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="preview-card" data-testid="preview-track-card">
|
||||
<h2 className="preview-card-title">Circuit</h2>
|
||||
{body}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -408,6 +440,7 @@ export function RacePreviewPage() {
|
||||
|
||||
<div className="preview-grid">
|
||||
<TrackOutlineCard
|
||||
meeting={previewMeeting}
|
||||
outline={trackOutlineQuery.data}
|
||||
loading={trackOutlineQuery.isLoading}
|
||||
error={trackOutlineQuery.isError ? (trackOutlineQuery.error as Error) : null}
|
||||
|
||||
@@ -1865,13 +1865,22 @@ a { color: inherit; text-decoration: none; }
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 65fr) minmax(0, 35fr);
|
||||
gap: var(--s6);
|
||||
align-items: start;
|
||||
/* Stretch, not start: whichever column is taller sets the row height and the
|
||||
other fills down to it, so the two never end on different baselines. */
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.cc-dashboard-panel {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.cc-dashboard-main {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.cc-dashboard-panel {
|
||||
@@ -3357,7 +3366,15 @@ a { color: inherit; text-decoration: none; }
|
||||
|
||||
/* ── Command Center — Paddock Briefing preview strip ── */
|
||||
.cc-briefing {
|
||||
margin-top: var(--s7);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s3);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.cc-briefing .sec-header {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.briefing-state {
|
||||
@@ -3396,6 +3413,14 @@ a { color: inherit; text-decoration: none; }
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
/* Takes the column's leftover height; extra headlines scroll instead of
|
||||
pushing the panel past the sidebar. */
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.14) transparent;
|
||||
}
|
||||
|
||||
.cc-brief-item {
|
||||
@@ -4764,3 +4789,153 @@ a { color: inherit; text-decoration: none; }
|
||||
background: #f82f34;
|
||||
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;
|
||||
overscroll-behavior-x: contain;
|
||||
/* No native scrollbar: it sits inside the edge mask below, so it renders as a
|
||||
half-faded light slab on the dark panel. The strip opens centred on the
|
||||
current GP and the fades mark the overflow, so nothing is lost by hiding it. */
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.cc-calendar-grid::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* The strip opens centred on the GP in focus, so rounds run off both edges —
|
||||
fade them out rather than slicing a card in half. */
|
||||
.cc-calendar-grid.is-scrollable {
|
||||
-webkit-mask-image: linear-gradient(to right, transparent 0, #000 28px, #000 calc(100% - 28px), transparent 100%);
|
||||
mask-image: linear-gradient(to right, transparent 0, #000 28px, #000 calc(100% - 28px), transparent 100%);
|
||||
}
|
||||
|
||||
.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; }
|
||||
}
|
||||
|
||||
@@ -156,7 +156,6 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s2);
|
||||
margin-bottom: var(--s3);
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
font-family: var(--f-mono);
|
||||
|
||||
@@ -203,6 +203,47 @@
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.preview-track-fallback {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
gap: var(--s2);
|
||||
min-height: 180px;
|
||||
padding: var(--s5);
|
||||
}
|
||||
|
||||
.preview-track-fallback-glyph {
|
||||
width: 88px;
|
||||
height: 52px;
|
||||
margin-bottom: var(--s2);
|
||||
border: 2px dashed rgba(255, 255, 255, 0.14);
|
||||
border-radius: 42% 50% 46% 54%;
|
||||
}
|
||||
|
||||
.preview-track-fallback-name {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-1);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.preview-track-fallback-place {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-2);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.preview-track-fallback-hint {
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.5;
|
||||
color: var(--text-3);
|
||||
max-width: 22rem;
|
||||
margin: var(--s2) 0 0;
|
||||
}
|
||||
|
||||
.preview-podium {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -189,6 +189,9 @@ describe('CommandCenterPage', () => {
|
||||
expect(screen.getByTestId('cc-focus')).toHaveTextContent('Monaco')
|
||||
expect(screen.getByTestId('cc-season-calendar')).toHaveTextContent('Season Calendar')
|
||||
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.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-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')
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -223,6 +223,8 @@ describe('RacePreviewPage', () => {
|
||||
expect(screen.getByTestId('preview-header')).toHaveTextContent('Monaco')
|
||||
expect(screen.getByTestId('preview-schedule')).toHaveTextContent('FP1')
|
||||
expect(screen.getByTestId('preview-track-card')).toBeInTheDocument()
|
||||
expect(screen.getByRole('img', { name: 'Circuit outline' })).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('preview-track-fallback')).not.toBeInTheDocument()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('preview-last-year-card')).toHaveTextContent('VER')
|
||||
@@ -273,5 +275,75 @@ describe('RacePreviewPage', () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('preview-last-year-card')).toHaveTextContent('First time on the calendar')
|
||||
})
|
||||
|
||||
const fallback = screen.getByTestId('preview-track-fallback')
|
||||
expect(screen.getByTestId('preview-track-card')).toContainElement(fallback)
|
||||
expect(fallback).toHaveTextContent('Monaco')
|
||||
expect(fallback).toHaveTextContent('A GPS track outline is not available in the local cache.')
|
||||
expect(screen.queryByRole('img', { name: 'Circuit outline' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows a designed circuit fallback when the track outline is unavailable', async () => {
|
||||
const italianGp: Meeting = {
|
||||
...upcomingMeeting,
|
||||
meeting_name: 'Italian Grand Prix',
|
||||
meeting_official_name: 'Formula 1 Pirelli Gran Premio d\'Italia',
|
||||
location: 'Monza',
|
||||
country_name: 'Italy',
|
||||
country_code: 'ITA',
|
||||
circuit_short_name: 'Monza',
|
||||
}
|
||||
|
||||
mockFetchMeetings.mockImplementation(async (year: number) => {
|
||||
if (year === 2099) return [italianGp]
|
||||
if (year === 2098) return [{ ...italianGp, meeting_key: 90, year: 2098 }]
|
||||
return []
|
||||
})
|
||||
mockFetchSessions.mockImplementation(async (meetingKey: number) => {
|
||||
if (meetingKey === 100) return sessions
|
||||
if (meetingKey === 90) return [priorRaceSession]
|
||||
return []
|
||||
})
|
||||
mockFetchTrackOutline.mockResolvedValue(null)
|
||||
|
||||
renderPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('preview-track-fallback')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const card = screen.getByTestId('preview-track-card')
|
||||
const fallback = screen.getByTestId('preview-track-fallback')
|
||||
expect(card).toContainElement(fallback)
|
||||
expect(card).toHaveTextContent('Circuit')
|
||||
expect(fallback).toHaveTextContent('Monza')
|
||||
expect(fallback).toHaveTextContent('Italy')
|
||||
expect(fallback).toHaveTextContent('A GPS track outline is not available in the local cache.')
|
||||
expect(fallback).not.toHaveTextContent(/unknown/i)
|
||||
expect(screen.queryByRole('img', { name: 'Circuit outline' })).not.toBeInTheDocument()
|
||||
expect(card).not.toHaveTextContent('Track outline unavailable for this circuit')
|
||||
})
|
||||
|
||||
it('keeps a thrown outline request distinct from the designed unavailable state', async () => {
|
||||
mockFetchMeetings.mockImplementation(async (year: number) => {
|
||||
if (year === 2099) return [upcomingMeeting]
|
||||
if (year === 2098) return [priorMeeting]
|
||||
return []
|
||||
})
|
||||
mockFetchSessions.mockImplementation(async (meetingKey: number) => {
|
||||
if (meetingKey === 100) return sessions
|
||||
if (meetingKey === 90) return [priorRaceSession]
|
||||
return []
|
||||
})
|
||||
mockFetchTrackOutline.mockRejectedValue(new Error('outline request failed'))
|
||||
|
||||
renderPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('preview-track-card')).toHaveTextContent('outline request failed')
|
||||
})
|
||||
|
||||
expect(screen.queryByTestId('preview-track-fallback')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('img', { name: 'Circuit outline' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,7 +11,12 @@
|
||||
"test:visual": "playwright test --config playwright.visual.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: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",
|
||||
"devDependencies": {
|
||||
|
||||
37
playwright.fidelity.config.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
})
|
||||
@@ -7,10 +7,11 @@ const WEB_PORT = process.env.BOXBOX_WEB_PORT ?? '15173'
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/visual',
|
||||
fullyParallel: true,
|
||||
// All projects share one seeded SQLite database behind the same Go server.
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
workers: 1,
|
||||
reporter: process.env.CI ? 'github' : 'html',
|
||||
snapshotPathTemplate: '{testDir}/{testFileDir}/__snapshots__/{projectName}/{arg}{ext}',
|
||||
expect: {
|
||||
|
||||
86
scripts/release-fidelity/generate.mjs
Normal 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 },
|
||||
)
|
||||
}
|
||||
27
scripts/release-fidelity/generate.test.mjs
Normal 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/)
|
||||
})
|
||||
50
scripts/release-fidelity/verify.mjs
Normal 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
|
||||
}
|
||||
80
scripts/release-fidelity/verify.test.mjs
Normal 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
|
||||
})
|
||||
})
|
||||
@@ -12,6 +12,30 @@ test.describe('Command Center', () => {
|
||||
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 }) => {
|
||||
await page.goto(`/race-hub?session_key=${FULL_SESSION}`)
|
||||
await page.getByRole('link', { name: 'Command' }).click()
|
||||
|
||||
73
tests/release-fidelity/capture.spec.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
Before Width: | Height: | Size: 148 KiB After Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 129 KiB After Width: | Height: | Size: 160 KiB |
@@ -8,6 +8,20 @@ export const VIEWPORTS = {
|
||||
|
||||
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. */
|
||||
export async function waitForScreenshotReady(page: Page): Promise<void> {
|
||||
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> {
|
||||
await routeSeededSeasonCalendar(page)
|
||||
await page.goto('/')
|
||||
await expect(page.getByTestId('command-center')).toBeVisible()
|
||||
await expect(page.getByTestId('cc-focus')).toBeVisible()
|
||||
await expect(page.getByTestId('cc-session-9472')).toBeVisible()
|
||||
// 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 expect(page.getByTestId('cc-calendar-1229')).toBeVisible({ timeout: 15_000 })
|
||||
await waitForScreenshotReady(page)
|
||||
}
|
||||
|
||||
|
||||