fix(client): the audit round over the 4.3.1 release

Ride minutes and late marks follow an edited timetable instead of the routing
that fetched them; a landing day's join band reads as the ride it is rather
than a nine hour drive of no length; the refuel offer is there on the road
out of a terminal or a pick-up desk; the phone's stage map draws the ride;
the share page numbers its stops the way the planner does; the PDF lists a
day the way the day plan lists it, from both shells; a poster-less clip draws
its play badge everywhere, not only in the editor; the hero title has room
for its descenders and the list card keeps its badge clear; the release
notice keeps its close control in reach when it stacks; a link chip's actions
are thumb-sized and its delete asks first; the Google retry line and the
Google-only switch appear only where Google answers.
This commit is contained in:
Maurice
2026-09-22 12:10:21 +02:00
parent cb85ba915c
commit 85b5756e89
78 changed files with 2476 additions and 486 deletions
@@ -1,4 +1,4 @@
// FE-RN-001 to FE-RN-014
// FE-RN-001 to FE-RN-016
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '../../../tests/helpers/render'
import userEvent from '@testing-library/user-event'
@@ -209,4 +209,15 @@ describe('ReleaseNoticeModal', () => {
// The places card keeps the pill: that one is about the index.
expect(cards[0].querySelector('.trek-mark .pill-bg')).not.toBeNull()
})
it('FE-RN-016: the close button belongs to the panel, not the note', () => {
// Below 1080px the panel scrolls and the note is its second screen, so the
// stylesheet sticks the button to the panel's own top edge. That only works
// while it is a child of .rn-panel; put it back in the note and it scrolls a
// whole release half out of reach with every CSS assertion still green.
renderModal(releaseNotice())
const close = screen.getByRole('button', { name: 'Close' })
expect(close.parentElement?.classList.contains('rn-panel')).toBe(true)
expect(close.closest('.rn-note')).toBeNull()
})
})
@@ -64,6 +64,14 @@ export function ReleaseNoticeModal({ notice, visible, onDismiss, onCTA, onSecond
aria-describedby={bodyId}
className="rn-panel"
>
{/* A child of the panel, not the note: stacked, the panel scrolls and the
note is its second screen, so the button sticks to the panel's edge. */}
{notice.dismissible && (
<button type="button" className="rn-close" onClick={onDismiss} aria-label={t('common.close')}>
<X size={17} strokeWidth={2} />
</button>
)}
{/* ── Left: the release ─────────────────────────────────────────── */}
<div className="rn-release">
<div className="rn-release-grain" aria-hidden="true" />
@@ -127,12 +135,6 @@ export function ReleaseNoticeModal({ notice, visible, onDismiss, onCTA, onSecond
{/* ── Right: the note ───────────────────────────────────────────── */}
<div className="rn-note">
{notice.dismissible && (
<button type="button" className="rn-close" onClick={onDismiss} aria-label={t('common.close')}>
<X size={17} strokeWidth={2} />
</button>
)}
<div className="rn-note-body">
<div className="rn-note-eyebrow">{t(release.note.eyebrowKey)}</div>
<h3 className="rn-note-title">{t(release.note.titleKey)}</h3>
@@ -50,6 +50,7 @@
.rn-close { box-sizing: content-box; }
.rn-panel {
position: relative;
width: min(1420px, calc(100% - 2px));
max-height: 100%;
display: grid;
@@ -67,12 +68,60 @@
line-height: normal;
}
/* The panel's, not the note's: in the stacked layout below it has to outlast the
release half, which scrolls by first. */
.rn-close {
position: absolute;
top: 22px;
/* Logical, so the X sits at the reading end in Arabic too. With the physical
right it stayed over the release half there, which the note's palette below
is not made for, and the stacked layout put it at the other end anyway. */
inset-inline-end: 22px;
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--bg-hover);
border: 1px solid var(--border-faint);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
z-index: 2;
cursor: pointer;
transition: background-color 0.15s ease, color 0.15s ease;
}
.rn-close:hover { background: var(--bg-selected); color: var(--text-primary); }
/* Below the two-column breakpoint the halves stack and the panel scrolls. The
rows must go back to auto: with the single minmax row from above, the note took
the whole panel and the release half was left with nothing but its padding. */
@media (max-width: 1080px) {
.rn-panel { grid-template-columns: 1fr; grid-template-rows: auto; overflow-y: auto; }
.rn-panel { grid-template-columns: 1fr; grid-template-rows: auto auto; overflow-y: auto; }
.rn-release { grid-area: 1 / 1; }
.rn-note { grid-area: 2 / 1; }
.rn-release, .rn-release-inner, .rn-note-body { overflow: visible; }
/* The X used to sit in the note, a whole release half below the top edge, and
at a tablet's width the backdrop is gone too. As a grid item over both rows
it sticks 22px under the top edge for the entire scroll, over either half. */
.rn-close {
position: sticky;
grid-row: 1 / -1;
grid-column: 1;
justify-self: end;
align-self: start;
margin: 22px;
/* At the top of the scroll it hangs over the release half, which is dark in
both themes. The note's palette is a near-transparent fill and secondary
ink: in the light theme that is a slate glyph on near black, roughly 1.8:1,
so the only way to dismiss the notice on a tablet was invisible. An opaque
chip with primary ink carries over either half, the way .rn-support below
gets a solid band once it sticks. */
background: color-mix(in srgb, var(--bg-card) 92%, var(--text-primary));
border-color: var(--border-primary);
color: var(--text-primary);
box-shadow: 0 2px 8px oklch(0 0 0 / 0.25);
}
/* Once the note has scrolled into view, the ask stays at the bottom edge. The
band's usual tint is translucent, so it gets a solid one here. */
.rn-support {
@@ -560,26 +609,6 @@
min-height: 0;
}
.rn-close {
position: absolute;
top: 22px;
right: 22px;
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--bg-hover);
border: 1px solid var(--border-faint);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
z-index: 2;
cursor: pointer;
transition: background-color 0.15s ease, color 0.15s ease;
}
.rn-close:hover { background: var(--bg-selected); color: var(--text-primary); }
.rn-note-body {
flex: 1;
min-height: 0;
@@ -12,10 +12,108 @@ describe('release notice css', () => {
const notice = read('src/components/SystemNotices/releaseNotice.css')
const tokens = read('src/index.css')
const block = (selector: string): string => {
const at = notice.indexOf(selector)
const within = (source: string, selector: string): string => {
const at = source.indexOf(selector)
expect(at, `${selector} missing from releaseNotice.css`).toBeGreaterThan(-1)
return notice.slice(at, notice.indexOf('}', at) + 1)
return source.slice(at, source.indexOf('}', at) + 1)
}
const block = (selector: string): string => within(notice, selector)
// A half is named twice: indented in the stacked block, where it is only placed
// in the grid, and at the top level, where it is painted. Anchor on the newline
// to get the second.
const painted = (selector: string): string => within(notice, `\n${selector}`)
const stacked = (): string => {
const at = notice.indexOf('@media (max-width: 1080px)')
expect(at, 'the stacked block').toBeGreaterThan(-1)
return notice.slice(at, notice.indexOf('\n}', at))
}
// How many rules deep a position sits, comments taken out so a brace in prose
// cannot shift the count. Zero means the rule applies at every width.
const depthAt = (index: number): number => {
let depth = 0
for (const ch of notice.slice(0, index).replace(/\/\*[\s\S]*?\*\//g, '')) {
if (ch === '{') depth += 1
else if (ch === '}') depth -= 1
}
return depth
}
const decl = (rule: string, prop: string): string => {
const found = new RegExp(`(?:^|[;{\\n])\\s*${prop}:\\s*([^;]+);`).exec(rule)
expect(found, `${prop} missing from ${rule.slice(0, 44)}`).not.toBeNull()
return found![1].trim()
}
interface Rgba { r: number; g: number; b: number; a: number }
// The custom properties of one theme, so a declaration can be resolved the way
// the browser resolves it instead of being taken on trust.
const theme = (opener: string): Record<string, string> => {
const at = tokens.indexOf(opener)
expect(at, `${opener} missing from index.css`).toBeGreaterThan(-1)
const body = tokens.slice(at, tokens.indexOf('\n}', at))
const map: Record<string, string> = {}
for (const [, token, value] of body.matchAll(/(--[\w-]+):\s*([^;]+);/g)) map[token] = value.trim()
return map
}
const rgba = (value: string, vars: Record<string, string>): Rgba => {
const v = value.trim()
const ref = /^var\((--[\w-]+)\)$/.exec(v)
if (ref) {
const token = vars[ref[1]]
expect(token, `${ref[1]} missing from index.css`).toBeDefined()
return rgba(token, vars)
}
const mix = /^color-mix\(in srgb,\s*(.+?)\s+([\d.]+)%,\s*(.+)\)$/.exec(v)
if (mix) {
const share = Number(mix[2]) / 100
const first = rgba(mix[1], vars)
const second = rgba(mix[3], vars)
const blend = (x: number, y: number): number => x * share + y * (1 - share)
return {
r: blend(first.r, second.r),
g: blend(first.g, second.g),
b: blend(first.b, second.b),
a: blend(first.a, second.a)
}
}
const hex = /^#([0-9a-f]{6})$/i.exec(v)
if (hex) {
return {
r: parseInt(hex[1].slice(0, 2), 16),
g: parseInt(hex[1].slice(2, 4), 16),
b: parseInt(hex[1].slice(4, 6), 16),
a: 1
}
}
const fn = /^rgba?\(([^)]+)\)$/.exec(v)
expect(fn, `${value} is not a colour this test can resolve`).not.toBeNull()
const parts = fn![1].split(',').map(Number)
return { r: parts[0], g: parts[1], b: parts[2], a: parts.length > 3 ? parts[3] : 1 }
}
const over = (front: Rgba, back: Rgba): Rgba => ({
r: front.r * front.a + back.r * (1 - front.a),
g: front.g * front.a + back.g * (1 - front.a),
b: front.b * front.a + back.b * (1 - front.a),
a: 1
})
const contrast = (one: Rgba, other: Rgba): number => {
const luminance = (c: Rgba): number => {
const channel = (raw: number): number => {
const v = raw / 255
return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4
}
return 0.2126 * channel(c.r) + 0.7152 * channel(c.g) + 0.0722 * channel(c.b)
}
const [light, dark] = [luminance(one), luminance(other)].sort((a, b) => b - a)
return (light + 0.05) / (dark + 0.05)
}
const step = (name: string): number => {
@@ -47,4 +145,60 @@ describe('release notice css', () => {
expect(columns).toMatch(/flex-shrink:\s*0/)
expect(block('.rn-promise {')).toMatch(/overflow:\s*hidden/)
})
it('FE-COMP-RELEASENOTICECSS-004: stacked, the close button stays on screen for the whole scroll', () => {
// Below 1080px the panel scrolls as one column with the release half first.
// The X lived in the note, so on a tablet it was a whole release half below
// the fold, and at 900px and under there is no backdrop to tap instead. It
// is a grid item spanning both rows now, stuck to the panel's top edge; the
// base rule is absolute and must come first or it wins the cascade.
const at = notice.indexOf('@media (max-width: 1080px)')
const base = notice.indexOf('.rn-close {\n position: absolute')
expect(base, 'the absolute close rule').toBeGreaterThan(-1)
expect(at).toBeGreaterThan(base)
const close = within(stacked(), '.rn-close {')
expect(close).toMatch(/position:\s*sticky/)
expect(close).toMatch(/grid-row:\s*1 \/ -1/)
// Both halves need a definite cell, or auto-placement steps around the button.
expect(stacked()).toMatch(/\.rn-release \{ grid-area: 1 \/ 1; \}/)
expect(stacked()).toMatch(/\.rn-note \{ grid-area: 2 \/ 1; \}/)
expect(block('.rn-panel {')).toMatch(/position:\s*relative/)
// Sticky without an offset never sticks, and the offset is the base rule's:
// scope that into a width query of its own and the button silently scrolls
// away again with every assertion above still green.
expect(decl(notice.slice(base), 'top'), 'the close button has no offset to stick to').toMatch(/^\d+px$/)
expect(depthAt(base), 'the offset the sticky rule leans on sits inside a query').toBe(0)
})
it('FE-COMP-RELEASENOTICECSS-005: stacked, the close button reads over the release half too', () => {
// Stuck to the panel's top edge the X hangs over .rn-release, a brand panel
// that is dark in both themes, but it kept the palette it had on the white
// note: a near-transparent fill under secondary ink. Resolved against the real
// tokens that is a slate glyph on near black in the light theme, about 1.8:1,
// and below 1080px this button is the only way out of the notice. Both halves
// are one scroll, so the chip has to carry over either.
const gradient = decl(painted('.rn-release {'), 'background')
const backdrops = [...gradient.matchAll(/#[0-9a-f]{6}/gi)].map(match => match[0])
expect(backdrops.length, 'the release half paints no gradient stops').toBeGreaterThan(1)
backdrops.push(decl(painted('.rn-note {'), 'background'))
const themes = [
{ name: 'light', vars: theme(':root {') },
{ name: 'dark', vars: theme('.dark {') }
]
const states = [
{ name: 'at rest', rule: within(stacked(), '.rn-close {') },
{ name: 'hovered', rule: block('.rn-close:hover {') }
]
for (const { name, vars } of themes) {
for (const state of states) {
const glyph = rgba(decl(state.rule, 'color'), vars)
for (const behind of backdrops) {
const chip = over(rgba(decl(state.rule, 'background'), vars), rgba(behind, vars))
expect(contrast(glyph, chip), `${name}, ${state.name}, over ${behind}`).toBeGreaterThanOrEqual(4.5)
}
}
}
})
})