Skip to content

iPhone charts: a tap enlarges, the edge goes back, no empty plot (UX pass 3, U4) - #71

Merged
frahlg merged 7 commits into
mainfrom
claude/ios-u4-charts
Oct 15, 2026
Merged

frahlg merged 7 commits into
mainfrom
claude/ios-u4-charts

Conversation

@frahlg

@frahlg frahlg commented Oct 15, 2026

Copy link
Copy Markdown
Member

Package U4 from the third UX review (design/ftw-ios-polish-2026-10/ux-pass-3/ux-pass-3.md, section 6: findings 6, 7, 11 (the price view), 16 (the car's scale), 20, 21 (the plot), 25, 45 and 53). It builds on U1 (#64) and U2 (#65) and runs beside U3, which owns the watch; it touches no watch file.

A tap on a chart lit the lock-on and ticked before the chart opened. A back swipe from the screen's edge over a chart, the dial or the style scale scrubbed instead of going back. A swipe up that started on the plan dial's ring held the page still. Since U2 made the node chart tall, a 446 W battery drew a line near zero over an empty plot, and Today stopped at now although the plan and forecast know the rest of the day.

What changes

A tap is a tap. ScrubOverlay and the live trace's overlay own the tap (FingerGate, rule TouchIntent in FTWShared). A touch that lifts before it moves 6 pt and before 150 ms calls onTap and never touches the scrub model, the hero or the haptics; the first scrub move waits for either. A second finger is a span at once. NodeView passes onTap and drops its simultaneous TapGesture.

The edge goes back. The overlays and the style scale's track take no touch that starts within 20 pt of the screen's leading edge (EdgeRule, leavesTheEdge()). A finger that starts further in and slides past the end still has the first slot, clamped.

The dial ring. It scrubs on a move along the ring that is not mostly up or down, or after the finger rests 0.1 s (RingIntent); a move up or down, or across the ring, scrolls the plan, and while the ring scrubs the page holds still (.scrollDisabled). At three and nine o'clock, where along the ring is up and down, the page wins and a rest starts the scrub.

The live scale comes from its own five minutes (LiveSeries.domain): each side ends on nice(1.1 × its peak), a side exists only where the window goes past rest, so one sign keeps zero at the edge. A source at rest (every reading within Flow.idleW, 42 W; the home never rests) puts zero mid-plot and draws the dotted baseline under its readings in place of the solid zero, with the head on the last reading. The watch passes no rest and keeps its floor.

Today draws the rest of the day as the ghost Ahead draws (HistoryPicture.Ghost): the battery's and the grid's plan as faint bars (fill 0.14, a 1 px cap), the car's windows the same, the sun's and the home's forecast as a dashed line, from now to midnight on the fixed 00–24 axis. The scale holds the ghost too, so the day reads as one picture. The finger still stops at the last measured slot: a plan is not a reading.

Ahead. The price strip is flat ink at 0.32 and 20 pt, Now's price row at its least, so it no longer speaks louder than the plan. A forecast runs through its own slots' middles on the Steffen path (AheadPicture.marks), so an hourly forecast on a grid of quarters bends instead of stepping.

Axes. The right gutter holds the widest label of the scale on screen (ChartGutter, SF Mono's advance of 0.62 of its size), so "20 kW" and "15 kW" are whole on the SE. The direction words show only where no data runs under them (CornerWords).

The car's scale on Ahead is the charger's most (maxChargeW, 11 kW in the demo), with a plan or without one, so it no longer flips between 15 and 5 kW.

The price view. The chart takes what the rest of the page leaves, from 240 pt up to 0.55 of the screen's height; what is left sits between the chart and its lines, never at the foot (as U2's node view). Under "incl. VAT" one mono line says what the plan already read does at these prices: GIVES 19:02–21:15 AT 105–173 · STORES 23:00–01:30 AT 30–55 ÖRE (Swedish GER … VID …, LAGRAR … VID …). Where the line has no room (the SE at XXXL) it drops its second part, never ends in "…". No plan, no line. PriceBars draws its time axis with ChartAxes.time.

Where this departs from the review, and why

  • The ring has no gesture of its own. In the simulator, any gesture on the ring held the page still for every swipe that started there: a DragGesture from 0 pt, the same from 6 pt beside the scroll, a 0.1 s LongPressGesture before a drag, the empty-tap trick and SpatialEventGesture. A drag that waits 20 pt let the page scroll but never fired, even sideways. Only a gesture on the ScrollView itself received every touch while the page still scrolled. So PlanView holds one DragGesture beside its scroll and hands each touch to the dial (DialTouch), which asks whether it started on the ring. That gesture also leaves the screen's leading 20 pt alone: without that it stopped the back swipe on the whole plan view. PlanView.swift is U7's file; this adds a binding, the gesture and one line, nothing else.
  • The price chart reaches 0.55 of the screen, not 0.5 capped at 440. At half the screen the Pro Max kept 129 pt of plain ground between the chart and its lines, over the 120 pt the done list allows.
  • Every node view now asks for the forecast, not only the sun's and the home's, so the battery's Today can draw the sun's and the home's ghost when their chips are on. One read when the view opens, as the plan view does.
  • The dial's centre on wide phones. The gesture used half the dial's side as its centre, but its frame is the whole width: on a Pro Max (408 pt wide, 340 tall) a finger read 34 pt off. It now uses the frame's centre.
  • A debug flag, -FTWDemoBatteryW 450, holds the demo battery near a power with a few watts of noise, for the 450 W pictures.

Evidence

Demo, iOS 26.5 simulators, iPhone SE (3rd generation) and iPhone 17 Pro Max, English. Pictures and logs are on claude/ios-u4-shots (u4/).

Tests. swift test on this branch over main c26af60 (#67): FTWShared 240 and FTWKit 426 tests pass (new: ChartFingerTests, 14 tests), and the app builds. UI tests, over main ea69097 (#67 changed the session and carriers, no chart or view file): on the SE, the final commits pass 19 of 19 (NowFitsTests 17, ChartFingerTests 2); on the Pro Max, NowFitsTests 17 of 17 one commit earlier (before the plan line's short form) and ChartFingerTests 1 passed, 1 skipped (the whole plan fits a Pro Max, so nothing scrolls; the test checks that with a swipe under the dial and skips). testTheStyleScaleSendsOnceWhenTheFingerLetsGo, which U1 and U2 saw fail on main, passed in all three runs here.

The tap. ChartFingerTests.testATapOnEachChartEnlargesItAndADragOnlyScrubs drags across Live, Today and Ahead (nothing opens) and taps each (the chart opens on the whole screen). The scrub log of that run: each drag logs scrub starts: moved 6.5 pt and its slots; each tap logs tap: lifted after 65–76 ms, moved 0.0 pt, no scrub and no touching index. Recording · log

The edge, in the simulator. The simulator control tool's edge swipe (the OS edge gesture, from x = 1) over the battery's Live chart: before, it scrubbed (slots 0 → 20) and stayed; after, it goes back to Now with no scrub line logged. The same swipe goes back over the price chart, the car's plan and the style scale (which stays on Balanced), and over the plan view's text.

before
after

The dial. testTheDialRingScrollsUpAndDownAndHoldsThePageWhileItScrubs on the SE: a rest at one o'clock then round to four scrubs with the page still; a quick move along the top scrubs with the page still; a swipe up from nine o'clock scrolls the plan. Its log: dial rested 100 ms · dial scrubs, then dial decides after 46 ms on a move of 0.0, -9.5 pt · dial passes the touch to the page.

bands.py.

  • The price view, Pro Max: before 373 pt of ground under "incl. VAT"; after no band over 81 pt (dark and light 80.3 between the chart and its lines; XXXL 56.3 at most). SE: the foot band 191.5 → 31 pt.
  • Live with a 450 W battery, SE: before (the review's own 0.4.3 shot, 446 W on a 3 kW scale) the line sat in the top sixth of the plot with 167 pt empty under it; after (450 W held, 444–458 W) the scale is 750 W and the reading fills 146 of the plot's 240 pt (61 %); the largest empty band in the plot is 89.5 pt. A steady 450 W gives a 500 W scale (unit test); the demo's few watts of noise take 1.1 × the peak past 500.

Frames, in the simulator. The debug probe (-FTWFrameLog YES -FTWAutoScrub YES) on the SE, ten seconds of a fast thumb then a two-finger span: the price drew its surface 0 times while 23–97 slots a second changed, with no late frame in 9 of those 11 seconds and 25–28 ms late in the other two; 48 h the same, 0 surface draws, no late frame in 9 of 11 seconds and about 51 ms in two. Other sessions loaded the laptop at the time.

Before and after

Each picture: before (main 0888d8c, U1 and U2 merged) on the left, after on the right. Today, Live and the price in dark, light and the largest standard size; the rest in dark.

SE, Today (battery with the sun and the home)

Pro Max, Today

SE, the price

Pro Max, the price

SE, a source at rest (the car) and the grid's whole "20 kW"

SE, the car's Ahead on its charger's 11 kW

Every screen:

The demo moves with the clock: the before and after of one screen are minutes apart, so a figure may differ. In the evening the demo battery gives about 2.3 kW, so the battery's Live sheets show both on 3 kW; the 450 W comparison is above. The sun was down during every shot, so the forecast's new curve shows in the unit test, not on screen.

Not checked

  • On a device: the back swipe with a real thumb (here: the simulator's OS edge gesture), a real thumb's timing against the 6 pt and 150 ms of a tap and the dial's 0.1 s rest, and Animation Hitches (Instruments does not run it in the simulator; the numbers above come from the debug probe).
  • Today at 06:00 in a picture: the shots ran between 16:30 and 19:30. todayAtSixShowsTheWholeDayInOnePicture builds that morning: one scale for the measured night and the planned day, the plan's ghost from the 06:00 slot, the sun's forecast as a line, the finger stopping at the last measured slot.

🤖 Generated with Claude Code

frahlg and others added 7 commits October 15, 2026 19:35
…Today's ghost, the forecast's curve (U4, shared logic)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ecides, Today's ghost, the price at half the screen (U4)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ring scrolls (U4)

Any gesture on the ring held the page still, even beside the scroll.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e back swipe (U4)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hart's pictures (U4)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…creen; the spare sits above its lines (U4)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…an ellipsis; the ring test skips where the plan fits (U4)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-15T17:46:25.860520Z 42f800c PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 42f800c6ca

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

guard b > a else { continue }
let prices = (0..<grid.count).filter { grid.startMs($0) < b && grid.endMs($0) > a }.compactMap { minor[$0] }
guard let lo = prices.min(), let hi = prices.max() else { continue }
func whole(_ m: Double) -> String { String(Int(PriceUnits.display(max(0, m), currency).rounded())) }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve below-zero prices in the plan summary

When a charge or discharge run overlaps a negative tariff, max(0, m) converts every negative price to zero before building the displayed range, so a −30 öre slot is reported as 0 and a −30…20 range as 0–20. This makes the new plan summary misstate the prices that drove the plan; preserve the negative value or use the existing below-zero price presentation rather than clamping it.

Useful? React with 👍 / 👎.

@frahlg
frahlg merged commit 70862ed into main Oct 15, 2026
1 check passed
@frahlg frahlg mentioned this pull request Oct 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant