Repository navigation
feat(web): expand the Lay rate chart into an overview map and a scrolling daily window - #958
Conversation
…t off the arithmetic Three defects found by looking at the captured frames rather than the diff: the permanent Drawer painted the sidebar over the overlay; the date rule's end label added 64px of scroll the chart had no days for; and a squashed panel put the plot on top of the legend at 390.
Side by side at 390 the To field had 137px of box for a 92px date, 28px of padding and a 14px picker icon, so the date ended three columns short of the icon. Both dates now take the full control column with Apply under them, and the phone plot loses 32px so the Escape hint stays on screen. specs/expanded-range-fields.spec.ts measures the clear columns between the date and the icon; it reads 3 on aa9d700 and 84 here.
ScreenshotsCaptured 1:1 on After frames captured at 1986080. The collapsed card gains an Expand control
The card itself is unchanged apart from the control: it keeps #940's 7/14-day range and its own remembered window. The expanded chart, by rangeNet-new surface, so these are after-only. 30 days is the preset the view opens on; 60 and 90 are custom ranges. Each opens with the newest day at the right edge.
At 1280 a 30-day range fits: the bars stay 22px and the empty space goes on the left, the pager is disabled and the box covers the whole map. At 390 nothing fits, so the map is always live. The window moving, and the box with itThe same 90-day range scrolled to the start and to the middle. The box tracks the window, and the edge cues light only at an edge that really hides days.
A day's readout, and keyboard focus on itThe focus frames are the #941 scope addition: the ring is two-toned, so the accent never has to be read against the
A range that just fits, and the long locale
The custom-range dates at 390Both dates take the full control column with Apply under them. Side by side, the To field had 137px of box for a 92px date, 28px of padding and a 14px picker icon, leaving the date three columns clear of the icon.
Review round 1 changed no pixels at either captured viewport — its fixes are modality, focus, announcements and arithmetic. These frames are the head's own all the same; the largest mean per-pixel difference against the round-0 set is 1.0/255, which is font rasterisation between runs. No console errors or CSP refusals were observed on any capture run. |
…ff the strip Round 1 of the #958 review (Claude Fable 5.1). The frame is a MUI Modal now, so siblings go aria-hidden, the body's scroll is locked and restored, Escape closes and Tab is contained however focus got where it is. The hand-rolled Tab and Escape handlers are gone and so is the literal z-index. Also: the gap comes off the strip rather than a JS constant, so the two media queries cannot disagree at exactly 900px; the re-anchor effect answers to the report; Page keys are left to a date field; a pager that disables hands focus to its neighbour; the window is announced once it settles; and the map claims only the horizontal axis where a phone cannot drag it.
Thirty days fits on either gap at that width, so the first version of this passed on the pre-fix build. Thirty-two is inside the window where the laid-out 2px gap fits and a JS-side 4px does not.
Review round 1 (Claude Fable 5.1, at
|
| claim | where | on 7f338f0 |
|---|---|---|
siblings are aria-hidden while open, and are not after |
ExpandedLayRate.test.tsx |
red |
| the body's scroll is locked, and restored | same | red |
| Tab stays inside after a click on the backdrop | specs/expanded-chart-modality.spec.ts |
red — focus left on Tab 1 |
| Escape closes from anywhere inside, focus returns to Expand | ExpandedLayRate.test.tsx, Dashboard.test.tsx |
red |
The Playwright spec also drives a real wheel over the backdrop and asserts window.scrollY does not move, then that it does again after close. On 7f338f0 the body's computed overflow was visible.
One consequence worth naming: with the page behind genuinely out of the accessibility tree, two Dashboard tests that read the card through an open chart could no longer see it. They close the chart first now. That is the fix working, not a workaround.
P3 · JS and CSS disagree at exactly 900px
Fix 1c920a13, taking your second option. ExpandedLayRate reads columnGap back off the strip, so the stylesheet is the only thing that decides it and no query can disagree with a constant. DAY_GAP_PHONE_PX had no production reader left and is deleted; DAY_GAP_PX stays as the fallback for jsdom, which loads no stylesheet. ExpandedLayRate.styles.test.ts now asserts the two rows narrow together rather than pinning the phone value to a constant nothing reads.
Failing first at 7f338f0, in specs/expanded-chart-modality.spec.ts: at 900px the strip lays out inside its region and the pager was still live. My first version of that test used the 30-day preset and passed on the broken build — 30 days fits on either gap at that width. It is pinned to 32 days now, inside the window the two answers actually disagree over (1986080f).
P3 · Crossing 900px while open jumps to the newest day
Fix 1c920a13. The effect is keyed on data. Two notes. The gap fix already removed gap from sync's identity, so the mutation you would expect here survives — the cause was gone. What [data] earns on its own is a new range of the same length: keyed on anything derived from the day count, a 30-day preset swapped for a 30-day custom range leaves the reader at the wrong end of a range they did not ask for. That is the test (re-anchors on the newest day when a new range of the same length arrives), red under }, [days]. Dashboard.tsx memoises expandedLine so the identity means what it says.
P3 · PageUp/PageDown cancelled inside the date fields
Fix 1c920a13. The handler returns early when the event target is an input or select. Red on removing that line.
P3 · A pager button disabling under focus drops focus to <body>
Fix 1c920a13. scrollLeft reads back clamped in the same tick, so the button knows it has reached the edge before the disabled attribute commits and hands focus to its neighbour. Red on routing the click back to the plain page().
P3 · The visible range is never announced
Fix 1c920a13. Split rather than made live in place: the visible .lay-expand-shown is aria-hidden and never lags the chart, and a permanently mounted .sr-only aria-live="polite" sibling carries the same sentence on a 500ms settle. A drag crosses a day boundary every 26px, so an undebounced region would speak about forty times on one sweep of a quarter; splitting them is also what keeps the sentence from being read twice. Red on removing aria-live.
P3 · touch-action: none kills vertical swipes on the map
Fix 1c920a13. pan-y in the phone rule; the desktop rule keeps none because that is where the box is dragged. Red on putting none back.
Your notes on tests and slop
- The wrong comment in
ExpandedLayRate.test.tsx(41 days against a 2,340px viewport) is corrected, and the.scroll-cuecomment no longer claims a token the rule does not use. DayStrip.focus.test.ts's accent-against-ink assertion: I have left it, and I think you are right. It pins a palette weakness, so a future palette that clears 3:1 turns it red for improving. I did not change it because it was not in the seven the coordinator accepted and dropping an assertion is their call, not mine. If you want it gone the figure belongs in the comment above the CSS rule; say so and it goes in round 2.- The duplicated
expandMapLabelis now genuinely load-bearing in one direction only — the visible label and the map'saria-label. Left as is. - Unrelated but found while here:
tools/simulation/uihad fivetscerrors undernoUncheckedIndexedAccessin the spec I added last round.npm testthere runs Playwright, nottsc, so nothing was catching them. Fixed in1c920a13.
Verification
npm test in web/: 3587 passed, 146 files (3575 before this round). Playwright quick suite against a stack built at this head: 112 passed in 5.7 min, both projects, inside the 10-minute PR limit. Typecheck clean in both projects. Screenshots recaptured at 1986080f and the screenshot comment patched in place; this round changed no pixels at either captured viewport, and the largest mean per-pixel difference against the round-0 set is 1.0/255.
…and its quiet Round 2 of the #958 review (Claude Fable 5.1). Opening focus comes from the button's own ref callback instead of data-mui-focusable, which had made Close the focus trap's focusTarget and its last tabbable node at once, so every Shift+Tab focused it again. The frame is counted through Dialog.tsx's own open-count, so #485 still records the debt when a PWA update banner mounts behind it. The live region no longer speaks for a window nobody moved, and the document's own overscroll is held off at the root, where the scroll chain actually is. Also, by owner decision: DayStrip.focus.test.ts no longer asserts the accent sits BELOW 3:1 against --ink. That pinned a palette weakness and would have gone red for an improvement; the figure is recorded in the stylesheet.
Shift+Tab from the one way out was a dead key while it carried data-mui-focusable; forward Tab happened to work, so the existing spec could not see it.
Review round 2 (Claude Fable 5.1, at
|
CI note: one failure on this head, and it is not this PR'sThe first run of That file is untouched by this branch, and the test passes locally three times Why it is load-sensitive is visible in the test's own comment: the assertion Not fixed here — it is outside this slice and the fix is a judgement about what |
|
Claude Fable 5.1, review round 3 at |







































Screenshots: before and after, 1280 and 390, light and dark — 39 frames, captured 1:1 from stacks built at the base and at this head.
Closes #941
Component plan.
docs/designs/822-mui-revamp.mdD2 pair 21 covers this screen:DayStrip,StockBar,GradingChip,BrandSplashand the rest are kept as they are, bespoke data marks whose CSS stays instyles.css. The expanded chart is that family's second surface. It reusesDayStrip's marks and keyboard model rather than redrawing them, and takes its frame fromBrandSplash(§ "Stays for good"). MUI supplies the controls it does elsewhere:TextField select nativefor the range,FilterDateFieldfor the custom dates,Buttonfor the pager, Apply and the way out.What changed
The chart. An Expand control on the Lay rate card opens the same chart full-screen, composed like the post-login splash: a fixed full-viewport backdrop, the content centred, one way out that takes focus on open, and a fade only under
prefers-reduced-motion: no-preference. It follows the app theme rather than forcing a dark lightbox (owner, 2026-09-24). Inside it, an overview map of the whole range sits above a daily window that is a nativeoverflow-xscroll region, with a box marking the days on screen.The sync has one writer each way. Scrolling writes the box's style; a press on the map writes
scrollLeft. A programmaticscrollLeftfiresscroll, which repaints the box, and the box never writes back, so the chain terminates after one hop and needs no guard flag. Giving the box its own index state is what brings that loop back.Ranges. The card keeps #940's 7/14-day ceiling. The chart offers the last 30 finished days or a custom span up to 90, remembered under its own storage key so opening it never moves the card's window, and read only while it is open, so a reader who never expands pays nothing for a 90-day report. Its custom form is held to its own ceiling, and rejects the 91st day naming 90, not 14.
Bars are 22px at every range (owner, 2026-09-24), never stretched.
justify-content: flex-endis the whole of "the newest day sits at the right edge"; a range narrower than the window leaves its empty space before the oldest day.lib/dayWindow.tsis the one oracle. A single pure function answers, from measured pixels, whether the range fits, which days are on screen, where the box sits and which edges hide something. The box, the two edge cues, the strip's tab stop and the "days shown" caption all read that one answer instead of each measuring the DOM their own way.The four scope additions, each with a test
.day.onis (0,2,0) and the global:focus-visibleis (0,1,0), so a focused day drew the same ink ring a hovered one draws. The new indicator is two-toned because no single tone clears 3:1 against both neighbours — the accent measures 1.20–1.55 against the--inka selected bar is filled with, in all four brands and both themes.DayStrip.focus.test.tsmeasures every adjacency of the chain (bar → surface tone → accent ring → card) for eight brand/theme pairs, and records the accent-against-ink figure that makes the second tone load-bearing rather than decorative. Fixed in the shared strip, so the card benefits too.days × slot + (days − 1) × gapagainstclientWidth, never fromscrollWidth.Three defects the captured frames found, and the diff did not
theme.zIndex.drawer(1200) and the overlay was at 60, so the sidebar covered the chart. It now stacks at MUI's modal layer.scrollWidth1140 againstclientWidth1076, and the view opened scrolled past its own first bars. The rule is clipped now, and the region's scrollable width is the strip's and nothing else.Owner review round
The To date field was cramped at 390: 137px of box for a 92px date, 28px of padding and a 14px icon left the date three columns clear of the icon. Both dates now take the full control column with Apply under them, and the phone plot loses 32px so the Escape hint stays on screen.
tools/simulation/ui/specs/expanded-range-fields.spec.tsmeasures the clear columns between the date and the icon — 3 onaa9d700, 84 now.Decisions taken
dayWindowreturns would carry it.useDayStrip+DaySlots, not a second strip, so the one tab stop, the arrow keys, the measured readout clamp and the three day states cannot differ between card and chart.docs/designs/941-expand-chart/commits the lab and its renderer, not theC2-*.pngrenders: they are reproducible from that file by name and are already attached to web: expand the Lay rate chart into a centred overview + scrollable daily view for ranges beyond 14 days #941.Tests
Measured on this branch before and after, never quoted.
webbefore (42c8916)webafterlib/dayWindow.test.tscomponents/DayStrip.focus.test.tscomponents/ExpandedLayRate.test.tsxcomponents/ExpandedLayRate.styles.test.tslib/layRateRange.test.tsroutes/Dashboard.test.tsxPlaywright quick suite at the head: 109 passed in 5.3 min, both projects, against an isolated stack built from this branch (108 before, plus the new phone spec).
dotnet test --filter "FullyQualifiedName~ImagePin|FullyQualifiedName~RealTree": 14 passed.Mutation checks
Every guard shown failing on a mutation before the claim was written.
fits-from-measurementcue-inside-the-scrollertab-stop-back-at-day-onefocus-ring-single-tone--focusfocus-ring-ties-the-selection-ring.day.on:focus-visible→.day:focus-visibleslot-drifts-from-the-arithmetic.bigstrip --slot22px → 20pxweek-jog-back-in-the-stripmargin-left: 9pxnewest-day-no-longer-at-the-right-edgeflex-end→flex-startexpanded-ceiling-loweredMAX_EXPANDED_RANGE_DAYS90 → 60expanded-read-issued-before-it-is-openedif (!expanded) returnfocus-not-returned-to-expandexpandRef.current?.focus()one-memory-for-two-windowsaa9d700, before the layout fixThe last two are worth a note.
one-memory-for-two-windowssurvived its first version of the test, which reopened the view without unmounting and so read React state rather than storage; the test now remounts. Andcue-inside-the-scroller's first mutation unbalanced the JSX, so the file failed to parse — a compile error, not a guard catching anything. Both were rewritten until the mutation was genuine.Deletions
None. No CSS class, component, export or i18n key was removed, so there is nothing to grep for.
DayStrip's internals moved intouseDayStrip/DaySlotswith its public props unchanged; its 15 existing tests pass untouched.Docs
specs/product/GLOSSARY.mdgains Expanded lay rate chart (#941) and the dashboard blurb names Expand; the in-app glossary gains the matching entry and the Help page's Dashboard section gains a paragraph — all three in en, es and tl, each using that locale's own control labels (#688): Expand/Ampliar/Palakihin, Range/Período/Saklaw, Custom range…/Período personalizado…/Sariling saklaw…, Back to dashboard/Volver al panel/Bumalik sa dashboard.Found, not fixed
HelpPage.test.tsxassertedgetByText(/always a/i)for a sentence about whole numbers; any later prose containing "always at" made it ambiguous. Tightened to/it is always a/i, which is what its own comment says it is for.selecttruncates its longest option at 390 (Período personalizado..). Pre-existing on the card too, untouched here.readme-farm's depleted third flock makes most days before 27 August read as partial, so the 60- and 90-day frames are hatched. Correct behaviour, not a defect, but it is why the long ranges look busier than a three-live-house farm would.