Refine the station listing: muted verdict capsules, three-line names

The listing's verdict capsule now carries white text on muted semantic
fills instead of the neutral secondary tint: Saves #45825E, Costs
#A9635B, Break even #707070, Closest #58789D.

Fixed tones, not translucent tints, because white text on a 15% tint
over a white row measures ~1.8:1. Each tone sits at a relative
luminance of ~0.18 — the band where white clears 4.5:1 AND the pill
still separates ~3:1 from both a white row and the dark card fill — so
one value each works in light and dark without a dynamic colour. The
saturated palette colours cannot carry white text at all: systemGreen
measures 2.2:1, systemRed 3.6:1.

"Save" becomes "Saves", matching the detail sheet and reading as a
statement of the outcome; amounts, symbols, padding, radius and the
shape-follows-line-count rule are unchanged. Saves and Costs share an
84pt minimum width so their trailing edges line up down the list.

The price and its capsule are now one vertically grouped element (5pt),
with the badge keeping the column's full 12pt above, so the row no
longer reads as three equal lines.

Station names cap at three lines with a tail ellipsis: unbounded wrap
let one very long forecourt name set the height of the whole row. 59 of
the 8,073 names in the dump exceed three lines and now ellipsise; the
full name is always in Station Details. Addresses still wrap freely.

The baseline pill moves onto ListingFuelSavingsCapsule so the listing
shows the same soft blue and white text as its siblings, leaving
FuelSavingsCapsule to the detail sheet, which it now alone uses.
Badges keep their tinted styling: they annotate a price rather than
report an outcome.

Verified: 261/261 swift test; Debug simulator and Release device
builds; all four fills and their white text measured from simulator
pixels in both appearances, and the detail sheet's .blue absent from
the listing entirely.
This commit is contained in:
FuelBoard Contributor
2026-10-18 19:45:19 +01:00
parent c46a7748e6
commit 244f9be640
6 changed files with 111 additions and 47 deletions
+31 -22
View File
@@ -1941,14 +1941,18 @@ struct StationRow: View {
// Name, full address, distance
VStack(alignment: .leading, spacing: 2) {
// The name WRAPS instead of truncating: the row is tall
// enough now, and a name cut to "Z A Service Stat…" cannot
// be told apart from the station next door. Baseline
// alignment keeps the notice symbols on the name's FIRST
// line instead of floating beside a two-line name.
// The name wraps, capped at THREE lines. The row is tall
// enough for a two-line name, but an unbounded wrap let one
// very long forecourt name set the height of the whole row —
// the excessive row heights this refinement removes. Past
// three lines the name ellipsises; the full name is always
// in Station Details. Baseline alignment keeps the notice
// symbols on the name's FIRST line instead of floating
// beside a two-line name.
HStack(alignment: .firstTextBaseline, spacing: 6) {
Text(station.name)
.font(.headline)
.lineLimit(3)
.multilineTextAlignment(.leading)
.fixedSize(horizontal: false, vertical: true)
ForEach(StationNotice.notices(for: station)) { notice in
@@ -2019,12 +2023,17 @@ struct StationRow: View {
if let badge = recommendationBadge {
badgeCapsule(badge)
}
// Price line: the status dot and the price, leading-aligned on
// the column's one axis. The star no longer ends this line — it
// sits under the station logo — so this whole column width now
// belongs to the price, which is what lets the wide pounds &
// pence form (£1.58⁷/L) render at full size rather than being
// scaled down or truncated.
// The price and its verdict capsule are ONE vertically grouped
// element: 5pt binds them as "this price, this outcome", while
// the badge keeps the column's full 12pt above so the three
// lines do not read as equals.
VStack(alignment: .leading, spacing: 5) {
// Price line: the status dot and the price, leading-aligned
// on the column's one axis. The star no longer ends this
// line — it sits under the station logo — so this whole
// column width now belongs to the price, which is what lets
// the wide pounds & pence form (£1.58⁷/L) render at full
// size rather than being scaled down or truncated.
if let price = station.prices[fuel] {
HStack(spacing: 5) {
PriceRatingDot(color: ragColor)
@@ -2042,6 +2051,7 @@ struct StationRow: View {
savingsCapsule(result)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
// The spec's ~20-24pt of air between the divider and the price
// column: the row's own 12pt spacing plus this 10pt.
@@ -2065,21 +2075,20 @@ struct StationRow: View {
.buttonStyle(.borderless)
}
/// Listing-only verdict. It deliberately omits money and the repeated
/// comparison caption; the full monetary result remains in More info.
@ViewBuilder
/// The listing's verdict capsule — ONE component for every status, so the
/// baseline's "Closest station" pill carries the same soft blue fill, white
/// text and shape rules as Saves, Costs and Break even. The detail sheet
/// keeps `FuelSavingsCapsule` to itself, unchanged.
private func savingsCapsule(_ result: FuelSavingsResult) -> some View {
if result.status == .baseline {
FuelSavingsCapsule(result: result)
} else {
ListingFuelSavingsCapsule(result: result)
}
}
/// One recommendation badge, above the price it annotates. Tinted rather
/// than the mockup's solid fill, like every other badge and capsule in the
/// app: coloured text keeps its contrast in both appearances, at every
/// Dynamic Type size, without introducing a colour outside the palette.
/// One recommendation badge, above the price it annotates. Badges stay
/// TINTED with coloured text — unlike the verdict capsules, which are filled
/// with white text: a badge annotates the price rather than reporting an
/// outcome, so it should stay quieter than the pill beneath it. Tinted text
/// also keeps its contrast in both appearances at every Dynamic Type size
/// without introducing a colour outside the palette.
private func badgeCapsule(_ badge: StationRecommendationBadge) -> some View {
StationLabelBadge(label: badge.title, color: badge.tint)
}
+55 -2
View File
@@ -186,15 +186,36 @@ struct ListingFuelSavingsCapsule: View {
// Never compress or truncate: the capsule wraps and the row grows taller.
.fixedSize(horizontal: false, vertical: true)
}
// White on the muted fill. These capsules carry a figure the eye is asked
// to read at a glance, which is why they are the only filled-and-white
// surfaces in the app; every badge stays tinted with coloured text.
.font(.caption.weight(.semibold))
.foregroundStyle(.primary)
.foregroundStyle(.white)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(shape.fill(Color.secondary.opacity(0.14)))
// Saves and Costs share one minimum width, so their trailing edges line
// up down the list whatever the figure's length. Break even and Closest
// station carry no figure and keep their natural width.
.frame(minWidth: minimumWidth, alignment: .leading)
.background(shape.fill(fill))
.accessibilityElement(children: .combine)
.accessibilityLabel(verdict.spokenText)
}
/// Saves and Costs only — the two verdicts that carry a figure. See the
/// frame above: 84pt is the width the widest common amount needs
/// ("£123.45 more"), so shorter figures pad rather than shrink the pill.
private var minimumWidth: CGFloat {
switch result.status {
case .worthTheDrive, .notWorthTheDrive: return 84
case .breakEven, .baseline: return 0
}
}
private var fill: Color {
ListingCapsuleFill.color(for: result.status)
}
/// One line keeps the capsule. Two or more switch to a rounded rect: a pill's
/// semicircular ends crowd the text corners at that height, while an 8pt
/// radius does not. Keyed to the LINE COUNT, so Break even flips shape only
@@ -214,3 +235,35 @@ struct ListingFuelSavingsCapsule: View {
}
}
}
/// The station listing's verdict-capsule fills: muted semantic colours carrying
/// white text and icon.
///
/// Muted AND fixed, deliberately not the translucent tints used elsewhere in the
/// app: white text on a 15% tint over a white row measures ~1.8:1 and is
/// unreadable. Each tone below sits at a relative luminance of ~0.18 — the band
/// where white text clears 4.5:1 AND the pill still separates ~3:1 from BOTH a
/// white row and the dark card fill — so one value each works in both
/// appearances without a dynamic colour.
///
/// The saturated palette colours cannot carry white text at all: measured
/// white-on-fill is 2.2:1 for `systemGreen`, 3.6:1 for `systemRed` and 4.0:1 for
/// `systemBlue`, every one of them below the 4.5:1 a caption needs. Muting is
/// therefore a contrast requirement here, not only the mockup's taste.
enum ListingCapsuleFill {
// Trailing comments record, per colour: white-on-fill / fill-vs-white-row /
// fill-vs-dark-row, measured rather than estimated.
static let saves = Color(red: 0x45 / 255, green: 0x82 / 255, blue: 0x5E / 255) // 4.56 / 4.56 / 3.06
static let costs = Color(red: 0xA9 / 255, green: 0x63 / 255, blue: 0x5B / 255) // 4.54 / 4.54 / 3.07
static let breakEven = Color(red: 0x70 / 255, green: 0x70 / 255, blue: 0x70 / 255) // 4.95 / 4.95 / 2.81
static let closest = Color(red: 0x58 / 255, green: 0x78 / 255, blue: 0x9D / 255) // 4.52 / 4.52 / 3.09
static func color(for status: FuelSavingsStatus) -> Color {
switch status {
case .worthTheDrive: return saves
case .notWorthTheDrive: return costs
case .breakEven: return breakEven
case .baseline: return closest
}
}
}
+4 -1
View File
@@ -521,7 +521,10 @@ private struct KeyStationLabelIndicator: View {
case "CHEAPEST":
StationLabelBadge(label: label, color: .blue)
case "CLOSEST":
FuelSavingsCapsule(result: baselineResult)
// The listing's own capsule, so the Key demonstrates the same soft
// blue fill and white text the listing shows. `FuelSavingsCapsule`
// remains the detail sheet's alone.
ListingFuelSavingsCapsule(result: baselineResult)
default:
Text("TOP")
.font(.caption2.bold())
-1
View File
@@ -40,7 +40,6 @@
"Costs more" = "Costs more";
"Filling up here costs more than at your closest station, including travel." = "Filling up here costs more than at your closest station, including travel.";
/* Station listing verdict capsule: the words above the figure */
"Save" = "Save";
"Costs" = "Costs";
"(approx.)" = "(approx.)";
"%@ more" = "%@ more";
@@ -34,9 +34,9 @@ final class FuelSavingsEstimateTests: XCTestCase {
// The figure takes its own line, so the capsule's shape is decided by the
// data rather than by how much width the price column hands it.
XCTAssertEqual(result(.worthTheDrive, .confirmed).listingVerdict().lines,
["Save", "£12.50"])
["Saves", "£12.50"])
XCTAssertEqual(result(.worthTheDrive, .estimated).listingVerdict().lines,
["Save", "£12.50", "(approx.)"])
["Saves", "£12.50", "(approx.)"])
XCTAssertEqual(result(.notWorthTheDrive, .confirmed).listingVerdict().lines,
["Costs", "£123.45 more"])
XCTAssertEqual(result(.notWorthTheDrive, .estimated).listingVerdict().lines,
@@ -57,7 +57,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
// VoiceOver keeps a single line: a drawn break is a spoken pause.
XCTAssertEqual(result(.worthTheDrive, .estimated).listingCapsuleHeadline(),
"Save £12.50 (approx.)")
"Saves £12.50 (approx.)")
}
func testDetailCapsulePlacesApproximationAfterTheFinancialResult() {
+2 -2
View File
@@ -271,7 +271,7 @@ extension FuelSavingsResult {
/// never straddle two lines, and a taller verdict grows the capsule downward
/// instead of widening the row's fixed price column.
struct ListingVerdict: Equatable {
/// "Save", "Costs", "Break even" or "Closest station".
/// "Saves", "Costs", "Break even" or "Closest station".
let headline: String
/// "£3.10" / "£1.20 more" — nil when there is no figure to show.
let amount: String?
@@ -293,7 +293,7 @@ extension FuelSavingsResult {
switch status {
case .worthTheDrive:
return ListingVerdict(
headline: String(localized: "Save"),
headline: String(localized: "Saves"),
amount: showsAmount ? FuelSavingsFormat.money(netSavingGBP) : nil,
qualifier: qualifier
)