mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 22:46:32 +00:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user