Skip to content

CLUE-669: WaveRunner date set-up styling - #3028

Open
lbondaryk wants to merge 61 commits into
masterfrom
CLUE-669-waverunner-tile-date-set-up-styling
Open

lbondaryk wants to merge 61 commits into
masterfrom
CLUE-669-waverunner-tile-date-set-up-styling

Conversation

@lbondaryk

Copy link
Copy Markdown
Contributor

Closes CLUE-669.

Styles the WaveRunner tile's Data Setup panel to Michael's design, and makes an end date before the start date unselectable.

Why the controls had to be replaced

Neither native control can meet the design. <input type="datetime-local"> draws its calendar as browser chrome, and a <select>'s option list is OS chrome — in both cases CSS cannot reach the part the design specifies. So "style the dropdowns and the date pickers" necessarily meant replacing both with markup we own. That is the bulk of the work; the CSS is the easy half.

  • Dropdowns → CLUE's existing CustomSelect, already used by the app header, the student/problem/class menus and sort-work. No new dependency, and they inherit the house keyboard behaviour.
  • Date fields → React Aria's DatePicker. Nothing in the repo or in Concord's own packages does this; both were checked. One new dependency, Apache-2.0.

Bundle cost: +71.5 KB gzipped, measured before and after on a chunk that is lazily loaded, so only students who open a WaveRunner tile pay it. The first measurement read 0 KB because nothing imported the library yet — that number was an artifact and was redone properly.

Stored values are unchanged

The model still stores "YYYY-MM-DD" strings. @internationalized/date's CalendarDate carries no timezone, so parsing and formatting round-trip exactly and no UTC/local question arises. Nothing downstream of TimeRange changes.

Deliberate behaviour changes

Called out here rather than discovered in review:

  1. Default range is now 2026-09-01 to 2026-10-01.
  2. run accepts a single-day range, matching loadData. The two disagreed — loadData allowed start == end and run did not — which was harmless only while no UI could produce it. The calendar now can.
  3. Neither field reaches past today, taken in UTC to match the model's dates and the seismic archive.
  4. The tile asks for a height it knows rather than measuring: the panel's rhythm is pinned in _tile-metrics.scss and totals 164px, so the tile requests that side by side and twice it when the panels stack. It cannot feed back, because the layout choice depends on the tile's width.
  5. One status line carries whatever the tile has to say. Reserving a row per message left an empty one sitting between the graph and the text that was showing.

Out of scope

The time is displayed as a fixed 12:00 AM and is not settable — deferred deliberately. Two things surfaced during review that need their own stories:

  • Typing a date is spinbutton-style, not free text. React Aria's date field is a row of spinbuttons by design: / is not a separator, two-digit years do not expand, and digits spill to the next segment. None of it is configurable. Making it type naturally means a plain text field we parse ourselves, keeping React Aria for the calendar.
  • Event Categories is hardcoded to -. There is no category concept in the WaveRunner model or the shared seismic types; the UI is a placeholder for work not yet done.

Testing

94 tests across the plugin, mutation-checked at each risky point: the Clear/Cancel/OK buffering, the minValue/maxValue bounds, and the typed-date handling each fail when the mechanism under test is removed.

Two tests were caught asserting nothing and rewritten. One checked a picker bound using an adjacent-month day — React Aria disables those regardless of any bound, so it passed whether or not the bound was wired. Both bounds are now asserted on in-month days.

Worth a reviewer's attention: jest does not compile SCSS, so a green suite says nothing about whether the stylesheets build. A broken stylesheet slipped through on this branch once. Every stylesheet is now compiled with sass directly as part of verification.

Full suite 5344 passing, tsc clean, lint 0 errors, all stylesheets compile, no hex colours — every value comes from vars.scss.

🤖 Generated with Claude Code

lbondaryk and others added 30 commits October 5, 2026 11:24
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…6-10-01

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Bundle delta: 0 KB measured (dist totals 231680 KB before and after;
no src file imports the new packages yet, so webpack tree-shakes them
out entirely). Installed-package disk footprint (not the shipped
bundle size) is ~9.9 MB for react-aria-components and ~1.5 MB for
@internationalized/date; actual gzip/minified contribution will only
be measurable once the DateField component is built and imported.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…lectable

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
React Aria disables adjacent-month days regardless of minValue or maxValue, so the previous
assertion on a trailing October day passed whether or not the bound was wired. Both bounds are
now asserted inside the focused month, and each fails if its prop is removed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
CustomSelect resolves its header as `title || selectedItem.text`, so the static title the
dropdowns were given would have left the closed control reading "Choose a station" however
many times a student changed it, with only the tick in the open list to say otherwise. The
placeholder is now supplied only while nothing is selected.

The list assertions move inside the list, since a selected label also appears in the header.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
CustomSelect falls back to treating its first item as selected when none is marked, which ticked
and emboldened a station the student had not picked - visible in exactly the state the design
calls Default, since no unit configures defaultStation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both values the designer gave already exist as tokens: #d8eff5 is $workspace-teal-light-6 and
#b7e2ec is $workspace-teal-light-4, so no new colour was added.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The white resting fill added for the design matched the house open-state rule on specificity, so
load order decided which won and the header reverted to white as soon as the list opened. The open
state is now restated in the tile's own override, where it outranks the resting fill.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pad month and day to two digits so the field stops changing width as the date changes.

Move the calendar glyph to the front of the field and render it in dark teal. The icon copied
from the Data Card carried a 24x24 spacer rect and a card-background rect; with a fill applied
either would have painted a solid block over the glyph, so both are removed and only the paths
take the fill.

Widen the calendar columns to 41px and make the selected day an outlined cell - white, 2px
dark teal, 8px radius - rather than a filled one. Every cell reserves that border width so
selecting one does not shift the grid.

Floor the Data Setup panel at 426px. The sections share space with flex-basis 0, so each is
floored by its own min-content and the panel narrowed whenever a short station name was chosen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A selected day is an outlined cell around a filled swatch, so the number carries its own element.
Today takes the same swatch with a charcoal border and light charcoal fill.

Both of today's colours were already tokens: #545454 is $charcoal-dark-1 and the charcoal-light-4
variable is $charcoal-light-4.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
min-width alone left the panel floored by its own min-content while the sections shared space
from a zero flex-basis. Giving it a real basis and forbidding shrink states the intent directly.
Scoped to the horizontal layout, since flex-basis follows the main axis and would otherwise set a
height in the stacked one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The 12px moved outside the border: as padding it let the section's teal show through as a band
between the outline and the graph. The status line collapses when it has nothing to report,
rather than standing as a white bar under the graph.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A flex item is floored by its own min-content, so a long station name widened one field and both
controls resized. The fields now split the row evenly and the label ellipsises.

The running/loading messages never carried text-align of their own - only the estimated-time line
below them did - so they sat left once the shared padding was applied.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
lbondaryk and others added 5 commits October 7, 2026 11:05
The picker was only ever handed the committed date, so a half-typed entry had nowhere to live and
the segments the student had not touched fell back to their mm/dd placeholders. The field now
holds a working copy: it shows what is being typed as it is typed, and commits as soon as the
entry is a whole date.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Each part of the date is a spinbutton that takes digits and steps with the arrow keys, but
unstyled there was nothing to say so - the field read as one inert piece of text. The focused
segment now highlights, placeholders are distinguishable, and the digits are tabular so stepping
one part does not shuffle the others.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The segment padding added for the focus highlight pushed the fixed time onto a second line. The
date, the time and the glyph no longer wrap or shrink, and the segment padding is halved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The chart can render shorter than the box it sits in, and the leftover was all falling below it.
The 8px gap went with it: there is only ever one child.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@cypress

cypress Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

collaborative-learning    Run #20619

Run Properties:  status check passed Passed #20619  •  git commit c3908e0329: clueful: comment pass - US spelling, accuracy, de-duplication
Project collaborative-learning
Branch Review CLUE-669-waverunner-tile-date-set-up-styling
Run status status check passed Passed #20619
Run duration 03m 38s
Commit git commit c3908e0329: clueful: comment pass - US spelling, accuracy, de-duplication
Committer Leslie Bondaryk
View all properties for this run ↗︎

Test results
Tests that failed  Failures 0
Tests that were flaky  Flaky 0
Tests that did not run due to a developer annotating a test with .skip  Pending 0
Tests that did not run due to a failure in a mocha hook  Skipped 0
Tests that passed  Passing 4
View all changes introduced in this branch ↗︎

@codecov

codecov Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.93252% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 87.01%. Comparing base (2996c6b) to head (c3908e0).
⚠️ Report is 8 commits behind head on master.

Files with missing lines Patch % Lines
src/plugins/wave-runner/components/data-setup.tsx 85.18% 4 Missing ⚠️
.../plugins/wave-runner/models/wave-runner-content.ts 83.33% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #3028      +/-   ##
==========================================
+ Coverage   86.96%   87.01%   +0.05%     
==========================================
  Files        1057     1059       +2     
  Lines       60699    60823     +124     
  Branches    16207    16254      +47     
==========================================
+ Hits        52784    52926     +142     
+ Misses       7891     7875      -16     
+ Partials       24       22       -2     
Flag Coverage Δ
cypress ?
cypress-regression 70.38% <7.63%> (?)
cypress-smoke 40.56% <7.63%> (+<0.01%) ⬆️
jest 61.74% <96.93%> (+0.10%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Date clearing, stale status handling, control accessibility, and read-only height mutations require correction.

6 open findings
What changed in this PR

Updates WaveRunner’s Data Setup UI with styled custom dropdowns and React Aria date pickers.

Changes:

  • Adds bounded, buffered date selection and new default dates.
  • Restyles controls, status output, and responsive tile sizing.
  • Adds dependencies, documentation, and extensive tests.
File Description
src/​plugins/​wave-runner/​wave-runner-types.ts Defines responsive tile heights.
src/​plugins/​wave-runner/​models/​wave-runner-content.ts Updates defaults and single-day validation.
src/​plugins/​wave-runner/​models/​wave-runner-content.test.ts Tests model date behavior.
src/​plugins/​wave-runner/​components/​wave-runner-tile.tsx Requests layout-dependent height.
src/​plugins/​wave-runner/​components/​wave-runner-tile.test.tsx Tests layout and controls.
src/​plugins/​wave-runner/​components/​wave-runner-tile.scss Updates panel layout.
src/​plugins/​wave-runner/​components/​status-and-output.tsx Consolidates status messaging.
src/​plugins/​wave-runner/​components/​status-and-output.test.tsx Tests graph states.
src/​plugins/​wave-runner/​components/​status-and-output.scss Styles graph and status rows.
src/​plugins/​wave-runner/​components/​date-utils.ts Converts stored dates.
src/​plugins/​wave-runner/​components/​date-utils.test.ts Tests date conversion.
src/​plugins/​wave-runner/​components/​date-field.tsx Implements the date picker.
src/​plugins/​wave-runner/​components/​date-field.test.tsx Tests picker interactions.
src/​plugins/​wave-runner/​components/​date-field.scss Styles date controls.
src/​plugins/​wave-runner/​components/​data-setup.tsx Integrates custom controls.
src/​plugins/​wave-runner/​components/​data-setup.test.tsx Tests setup controls and bounds.
src/​plugins/​wave-runner/​components/​data-setup.scss Styles setup fields.
src/​plugins/​wave-runner/​components/​_tile-metrics.scss Centralizes layout dimensions.
src/​plugins/​wave-runner/​components/​_dropdown-appearance.scss Shares dropdown styling.
src/​plugins/​wave-runner/​assets/​calendar-icon.svg Adds the calendar icon.
package.json Adds date-picker dependencies.
package-lock.json Locks new dependencies.
docs/​superpowers/​specs/​2026-10-05-clue-669-waverunner-date-setup-styling-design.md Documents the design.
docs/​superpowers/​plans/​2026-10-05-clue-669-waverunner-date-setup-styling.md Documents implementation steps.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/plugins/wave-runner/components/data-setup.tsx
Comment thread src/plugins/wave-runner/components/data-setup.tsx
Comment thread src/plugins/wave-runner/components/date-field.tsx Outdated
Comment thread src/plugins/wave-runner/components/status-and-output.tsx
Comment thread src/plugins/wave-runner/components/wave-runner-tile.tsx Outdated
Comment thread src/plugins/wave-runner/models/wave-runner-content.test.ts Outdated
lbondaryk and others added 4 commits October 7, 2026 17:14
CustomSelect derives its accessible name from `title`, which data-setup.tsx
supplies only while nothing is selected (a non-empty title would otherwise
permanently mask the chosen value). Once a student picked a station or
model, the visible <label> was orphaned - a screen reader announced only
the chosen value ("Dexter Display Mine") and never said which field it was.

Add an optional `ariaLabel` prop to CustomSelect (additive, so its six other
call sites are unaffected): it sets aria-label on the trigger header and
takes precedence over title/titlePrefix when naming the dropdown's listbox.
Wire data-setup.tsx's Station and Model dropdowns to it.

Tests assert each dropdown is findable by its accessible name ("Station" /
"Model") after a selection has been made, which is what would have caught
the regression - asserting on the selected text alone would not have.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Clear staged `defaultValue` (a fixed constant: kDefaultStartDate/
kDefaultEndDate) with no regard for minValue/maxValue, which the calendar
itself enforces on every other interaction. Since the end field's minValue
tracks the start field's live value, moving start later than the fixed
default left Clear with nowhere valid to land: clearing the end field after
moving start to Oct 5 staged the Oct 1 default, and OK committed an end
date before the start date - exactly the invariant the picker exists to
prevent.

Clamp the cleared value into [minValue, maxValue] before staging it for OK.

Also fixes a comment in the month-navigation dropdown that had gone stale
("CustomSelect has no aria-label prop") now that the prior commit added one.

Tests reproduce the reported scenario on both bounds.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…n untested date check

Two independent Copilot findings landed in the same model file and test
file, so they're combined here.

1. loadDataError/runError masking (status-and-output.tsx consolidated
several status rows into one line with errors first in priority, but
runModel only cleared runError and loadEnvelopeData only cleared
loadDataError). A failed load left its message showing straight through a
later successful run, and vice versa, because neither action knew about
the other's error. Each action now clears both errors when it starts.
Added a model test per direction that sets one error, starts the other
operation, and asserts the stale one is gone.

2. The "does not set a run error when start and end are the same day" test
never called runModel - the only place the changed `endMs < startMs` check
lives - so it passed whether the comparison was `<` or the old, buggy `<=`.
Replaced it with a test that gets a model past the "no model"/"no
metadata"/"no station" guards (mocking the download service and model
runner, following the existing pattern in this file) and actually exercises
the date-range check at start === end.

Mutation check: reverting the production comparison to `endMs <= startMs`
made the new test fail (expected "Invalid date range..." rejected; actual
message was returned) - confirmed, then reverted back to `<`.

Also hoists the duplicated setupTileInDocument test helper to module scope
now that two describe blocks need it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The tile's useEffect called onRequestRowHeight whenever its layout flips
between stacked and side-by-side, but that callback mutates the shared row
model regardless of whether this instance is editable. The same document
can render editable and read-only at once at different widths (four-up,
a published document view), so a read-only instance could overwrite the
height the editable one had just set.

Only request the height when not read-only.

Test asserts a read-only render never calls onRequestRowHeight.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@lbondaryk

Copy link
Copy Markdown
Contributor Author

Thanks — all six addressed. Three were bugs this branch introduced, and one was a test I knew was hollow when it was written.

Dropdown labels (1 & 2). Right, and worse than it reads: title is only supplied while nothing is selected, so the moment a student picks a station the control announced the station name and never the field. Fixed by adding an optional ariaLabel to CustomSelect — deliberately touching the shared component, since the prop is additive and defaults to undefined, so label: ariaLabel || title || titlePrefix and aria-label={undefined} leave the other six call sites byte-identical. npx jest src/clue confirms. The new tests assert each control is findable by its purpose after a selection, while its visible text is the chosen value.

Clear bypassing the bounds (3). Confirmed, including your exact scenario. clear() now clamps the default into [minValue, maxValue], with a test reproducing end=Oct 6 / start moved to Oct 5 / default Oct 1 — it commits Oct 5, and a symmetric test covers the max side.

Stale error masking the status line (4). This one is ours, and it only became reachable in this branch: the panel used to have separate rows per message, and consolidating them to a single line with errors at the top of the priority chain is what let a dead loadDataError sit over a successful run. Each operation now clears the other's error as it starts.

Read-only height requests (5). Correct — onRequestRowHeight mutates the shared row model, so a four-up or published pane could overwrite what the editable pane set. Now skipped when read-only, with a test.

The single-day test (6). You're right that it proved nothing, and that was known when it was written rather than discovered now — the comparison lives only inside run, which the test never called. Replaced with one that gets past all three guards, calls runModel with start === end, and asserts the day was actually processed rather than merely that no error appeared.

Mutation-checked independently: reverting endMs < startMs to <= fails that test — and also fails the new cross-operation error test, which depends on a single-day range being accepted. Two angles of real coverage where there were none.

Full suite 5351 passing, tsc clean, lint 0 errors.

@kswenson kswenson left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

PR Review Summary

Changes: 25 files, +3442 / -131 lines (of which ~1,674 are the committed plan/spec docs and ~257 package-lock)

What it does

Restyles the WaveRunner tile's Data Setup panel to the design. Because neither a native <select>'s option list nor a datetime-local calendar can be styled, the Station/Model dropdowns move to CLUE's CustomSelect and the date fields move to a new DateField built on React Aria's DatePicker (new deps react-aria-components and @internationalized/date, lazily loaded with the WaveRunner chunk). It also bounds the dates (end ≥ start, nothing past today UTC), accepts a single-day run, consolidates the status area into one line, has runModel/loadEnvelopeData clear each other's errors, and has the tile request a fixed row height.

The approach

DateField converts at its boundary between the model's "YYYY-MM-DD" strings and CalendarDate, so the model's storage is unchanged. It keeps three pieces of local state: pending (a calendar pick staged behind Clear/Cancel/OK), draft (what the segments show while typing), and focused (the calendar's month). A change with the popover closed is treated as typing and committed straight to the model. CustomSelect gains an optional ariaLabel prop. The tile requests 164 + 78 px side by side and 2×164 + 78 stacked, skipping the request when read-only.

Assessment

The calendar path is well built and thoroughly tested. The Clear/Cancel/OK buffering, the bounds in the calendar, and the lazy-load and bundle reasoning all hold up. The 105 plugin tests pass, tsc is clean, the SCSS compiles, and lint is clean. No other CustomSelect consumer is affected.

The typed path is broken, though. I confirmed this with a throwaway jest probe using a controlled wrapper, so onChange fed back into value as the model does. The existing tests pass a fixed value and never re-render from onChange, so they cannot see it. A student who types a year loses the month and day, and the model briefly holds a malformed date. A typed date also bypasses the min/max bounds the PR says make out-of-order dates unselectable. These need fixing before merge.

There is also a real accessibility regression on Station/Model. The aria-label hides the chosen value from screen readers, which a native <select> did not do.

Issues

Requested change for issues 1–3: buffer typed edits in draft, commit them on blur or Enter after checking them against min/max, and revert to the model value if the typed date is invalid or incomplete. Please also add a test that round-trips onChange back into value, as the model does.

  1. [major, requested change] Typing a year commits the first digit as a malformed date and blanks the month and day. date-field.tsx:107-114, date-utils.ts:18-22.

    • Confirmed by a jest probe and in the branch build: start with value 2026-09-15, click the year segment and type 2024. The first keystroke sends onChange("2-09-15"), the model stores it, and the segments render mm|dd|2024. The student then has to retype the month and day.
    • Cause: React Aria fires onChange on each keystroke once the date is complete. handlePickerChange commits it immediately, and toDateString doesn't pad the year.
    • fromDateString("2-09-15") fails, so the [value] effect nulls draft, which blanks the other segments.
    • Until the student retypes them, the model holds "2-09-15". The intermediate commit also calls setStartDate/setEndDate, which runs loadData() and clearEventsDataSet().
    • Suggested fix: commit typed dates on blur (or Enter) rather than per keystroke, validate against min/max, and pad the year.
  2. [major, requested change] Typed dates bypass minValue/maxValue, silently. date-field.tsx:107-114.

    • Confirmed by probe and in the branch build. With min 2026-09-10 and max 2026-10-08, typing 12 into the month commits 2026-01-15 (below min) and then 2026-12-15 (past today).
    • React Aria's bounds are validation only and do not block onChange. The model setters don't validate either.
    • So "an end date before the start date is unselectable" and "neither field reaches past today" hold only for the calendar.
    • The invalid state shows no feedback: no field styling and no status message. Date changes go through loadData(), which sets no error. "Invalid date range…" appears only once the student presses Load Data or Run.
    • The invalid date also inverts the other field's bounds, so start's max ends up below its value and end's min above its own.
  3. [major, requested change] Clearing a segment leaves the field and model disagreeing. date-field.tsx:107-114, :87-91.

    • Confirmed by probe: backspacing the day twice and tabbing away leaves the field showing 09|dd|2026 while the model holds 2026-09-01. The first backspace committed day 1.
    • Confirmed in the branch build: opening the calendar then snaps the field back to the model's date, because handleOpenChange reseeds from value.
    • onChange(null) sets draft to null without committing, and nothing restores draft from value on blur. Run then uses a date the student can't see.
  4. [major, requested change] Station/Model announce only "Station, button" / "Model, button" and never the chosen value. custom-select.tsx:109, data-setup.tsx:121,132.

    • On a role="button", aria-label replaces the text content as the accessible name, and triggerProps doesn't override it. The test at data-setup.test.tsx:~136 asserts exactly this name.
    • This regresses from the native <select>, which exposed its value. It fails WCAG 4.1.2 and 2.5.3 (label in name).
    • The new prop's doc comment (custom-select.tsx:33-35) has it backwards: ariaLabel is what stops the value from being announced.
    • This is the other half of Copilot's comments 1 & 2. The ariaLabel fix made the field's purpose announced, but in doing so it replaced the value. The new tests assert that the name is exactly "Station", so they pin the regression rather than catch it.
    • Suggested fix: aria-labelledby pointing at the visible <label> (give it an id) plus the header text. That also fixes the orphaned labels (issue 9).
  5. [major, not blocking: follow-up to fix useDropdown in accessibility-tools] Keyboard: Enter, Enter on the month dropdown jumps the calendar back a year. date-field.tsx:176-181, shared useDropdown.

    • Confirmed by a verifier's probe. Opening the list focuses item 0, which is 12 months back, because useDropdown sets aria-selected from activeIndex rather than the chosen item, and its open effect falls back to focusItem(0).
    • So Enter then Enter selects September 2025.
    • Station/Model share the same hook behavior: focus always opens on the first item, and arrowing announces each option as "selected".
    • The root cause is in the shared hook, but this PR introduces the month dropdown where it bites hardest.
  6. [minor, not blocking: same follow-up as 5] Escape in the open month list closes the whole date picker and drops the pending pick.

    • Confirmed by probe. useDropdown's list keydown calls preventDefault but not stopPropagation, so Escape reaches React Aria's Popover dismiss handler.
  7. [minor, requested change] A wide WaveRunner sharing a row can leave the row stuck at the stacked height. wave-runner-tile.tsx:15-16,24-27.

    • On first render useResizeDetector's width is undefined, so vertical is true and the tile requests 406. Once the width is measured it requests 242.
    • tile-row.tsx:157 refuses to shrink a multi-tile row, so it stays at 406, with about 164px of extra space and no clipping.
    • Separately, the first editable open of an existing document rewrites its stored row height (320 → 242/406) without undo, which dirties the document once.
    • Suggested fix: skip the request until containerWidth is known.
  8. [minor, requested change] runModel's early returns don't clear a stale loadDataError. wave-runner-content.ts:205-220.

    • The cross-clear runs only after the "No model selected", metadata and "No station selected" guards.
    • A failed load followed by Run with no station keeps showing the old load error, because the status line shows loadDataError || runError. The comment at :225-227 promises more than the code does. This is what remains of Copilot's comment 4: the fix covers a run that gets started, but not one that bails out early.
  9. [minor, requested change (covered by 4)] Visible Station/Model <label>s are orphaned. data-setup.tsx:114,127. They have no htmlFor/id link, whereas master's <select> had htmlFor="wave-runner-station". Clicking the label does nothing, and the label isn't what names the control. The fix for issue 4 covers this.

  10. [minor, author's discretion] Contrast.

    • Placeholder date segments use $charcoal-light-1 on white: 3.03:1, against the 4.5:1 needed for text.
    • The date-field border is the same color against the teal-light-7 panel: 2.68:1, against the 3:1 needed for a non-text boundary (WCAG 1.4.11).
    • Something like #767676 passes both.
  11. [minor, author's discretion] Gaps in the test suite (the "mutation-checked" claim doesn't extend to these).

    • The typed path never round-trips onChange → value (that is how issues 1-3 escaped).
    • The status-line priority chain (error > loading > running > complete > configured > setup) is untested, apart from the setup message and the configured class.
    • date-field.test.tsx:91-95 asserts the absence of date-field-time-column, a test id that exists nowhere, so the test can never fail.
    • date-field.test.tsx:236-246 claims "pending selection intact" but never picks a day.
  12. [minor, not blocking: follow-up story] Single-day ranges give the seismogram viewport and "Timeline It!" a zero-length range. wave-runner-content.ts:82-83.

    • endTimeISO is the start of the end day.
    • This was already reachable through loadData on master. The PR makes single-day ranges an intended feature, so it is worth fixing here or in a follow-up: end-of-day, or + SECONDS_PER_DAY.
  13. [nit, author's discretion] Committed plan and spec docs.

    • docs/superpowers/plans/…-clue-669-…md is 1,466 lines and docs/superpowers/specs/… is 208, and both carry Jira keys.
    • There is precedent on master (CLUE-260, among others), so this is a preference rather than a rule.
    • The plan in particular is a task checklist that is stale the moment it ships. I'd drop the plan and keep the spec only if it says something the PR description doesn't.
  14. [nit, author's discretion] Smaller items.

    • monthOptions clips only at maxValue, so the end field offers months before the start date, which the calendar then refuses.
    • clear()'s clamp lands below min when min > max (only reachable after issue 2).
    • The new CalendarDate(2026, 9, 1) fallback duplicates kDefaultStartDate.
    • z-index: 11 on .date-field-popover is dead, because React Aria sets z-index: 100000 inline.
    • The 78 chrome constant isn't derived from anything. Its comment says the title adds height, but .title-area is absolutely positioned.
    • The default status line reads "Estimated time to complete run:" with nothing after it.
    • The status line isn't a live region (pre-existing), and errors aren't visually distinct from status text.
    • Disabled CustomSelect has no aria-disabled (pre-existing in the shared component, newly exposed on Station/Model).

Comments and docs (author's discretion)

All items were found by the comment-lens agent; I personally verified the two marked ✔︎. I left out pure wording nits.

Spelling and usage. The repo uses US spellings: gray 29 : grey 3, centered 25 : centred 0.

  • "greyed" at _dropdown-appearance.scss:45, "Centred" at status-and-output.scss:14, and "grey" at status-and-output.tsx:15 and in the test titles at status-and-output.test.tsx:33,37.
  • "ellipsises" at data-setup.scss:19.
  • "tick" for the checkmark at data-setup.scss:39 and data-setup.test.tsx:115.
  • "house dropdown/component" is used as a second name for CustomSelect at _dropdown-appearance.scss:15,28,30,44.

Inaccurate or stale

  • ✔︎ wave-runner-content.ts:225 and :241 say "loadData" where they mean loadEnvelopeData. loadData() never touches loadDataError.
  • custom-select.tsx:33-35: in CustomSelect, title always overrides the selected text. This comment describes WaveRunner's convention, not the component's, and it inverts the a11y effect (see issue 4).
  • data-setup.test.tsx:154-156 says the date picker's month select "is a native select". It is a CustomSelect.
  • date-field.test.tsx:209-211: the change-event comment dates from the native month select, and it sits on the wrong test.
  • _tile-metrics.scss:14 says "the section carries no vertical padding of its own". It has an 8px top padding (wave-runner-tile.scss:58).
  • wave-runner-types.ts:3-5: the 78px chrome doesn't add up as described, because the title is absolutely positioned.

Backward-looking. The ones that narrate the code's history:

  • data-setup.test.tsx:64: "no longer renders native datetime inputs"
  • wave-runner-content.test.ts:355,375: "vs. the old <="
  • date-field.scss:144: "no longer unstylable"
  • date-field.tsx:84-86: "Handing the picker only the committed date meant…"
  • status-and-output.tsx:19-20: "Reserving a row … left an empty one"
  • wave-runner-tile.scss:27-28,47-50
  • date-utils.ts:5-6
  • date-field.test.tsx:138: "Reproduces the reported bug exactly"

Repeated explanations. Collapse each to its canonical site and point to it from the others:

  • Title masks the selection: canonical data-setup.tsx:82. Repeated in date-field.test.tsx:201, data-setup.test.tsx:129-132 and custom-select.tsx:33.
  • Cross-clearing of errors: canonical wave-runner-content.ts:225. Repeated in wave-runner-content.test.ts:562.
  • One status line: canonical status-and-output.tsx:19. Repeated in status-and-output.scss:37 and _tile-metrics.scss:9.
  • Known-not-measured height: canonical wave-runner-types.ts. Repeated in _tile-metrics.scss:1-3 and wave-runner-tile.test.tsx:125.
  • Clear clamps to bounds: canonical date-field.tsx:123. Repeated in date-field.test.tsx:138.

Adjacent and pre-existing. These test titles sit next to the changed tests and are cheap to fix:

  • ✔︎ wave-runner-tile.test.tsx "…less than 450" / "…450 or greater". The threshold is 700.
  • wave-runner-content.test.ts "…covering the mock data range".

lbondaryk and others added 8 commits October 8, 2026 14:55
…ds dates

Typing into a date segment committed to the model on every keystroke once
react-aria considered the in-progress value "complete": typing "2024" into
a year committed year "2" on the first keystroke, and toDateString did not
pad the year, so that commit produced the unparsable string "2-09-15".
fromDateString then failed to parse it back, nulling draft and blanking
month/day to their placeholders.

Because minValue/maxValue are validation-only for react-aria (they disable
calendar days but never block onChange), the same per-keystroke commit let
a typed date silently bypass the picker's own bounds, with no field styling
or status message - contradicting the claim that out-of-order/future dates
are unselectable.

Clearing a segment to empty also left the field and the model disagreeing:
react-aria only notifies onChange for an edit that is complete and valid,
so a cleared segment just shows its own placeholder locally while the
component's `draft` keeps whatever complete value it last heard about -
nothing synced them back up on blur.

Fix: typed edits now stay in `draft` only. commitTyped runs on blur
(deferred to a microtask so react-aria's internal segment remounts aren't
mistaken for the student leaving the field) or Enter, validates against
minValue/maxValue and checks for a placeholder segment (via groupRef) to
catch the "complete-looking but actually incomplete" case, and either
commits once or reverts draft back to the model's value. toDateString now
pads the year to 4 digits.

Added a controlled-wrapper test helper (onChange feeds back into value,
exactly as the model does) since the existing fixed-value tests pass a
static `value` that never re-renders from a commit, which is exactly what
let these bugs escape. Mutation-check: reintroducing the per-keystroke
commit makes all three new controlled-wrapper tests fail, confirming they
catch the regression.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CustomSelect's trigger is a role="button" div. On that role, aria-label
REPLACES the element's text content as the accessible name rather than
supplementing it - so passing aria-label="Station" made the control
announce "Station, button" and never the chosen value once one was picked,
a regression from the native <select> it replaced (which always exposed
its value). The ariaLabel prop's own doc comment had this backwards,
describing it as what keeps the purpose announced "regardless of selection
state" when it was actually what suppressed the value.

The data-setup.test.tsx assertion `name: "Station"` asserted exactly the
regressed behavior, so it pinned the bug instead of catching it.

Separately, the visible Station/Model <label>s had no id and no htmlFor,
so they were not programmatically associated with their control at all -
orphaned labels that an assistive-tech user could not use to find the
control's purpose.

Fix: replaced the `ariaLabel` prop with `ariaLabelledBy` (an id, not text).
CustomSelect now gives its header its own id and sets
aria-labelledby="<the given id> <its own id>", so the accessible name
concatenates the field's purpose (the caller's label) with its current
value (the header's own text). data-setup.tsx gives the visible labels ids
and passes them through. (A plain htmlFor could not do this job here -
CustomSelect's header is a div, not a native labelable control, so a
browser would not forward a label click to it the way it does for <select>.)
Updated the two tests to assert the accessible name CONTAINS both the
field's purpose and the chosen value, so either one disappearing fails them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
useResizeDetector's width is undefined on first render, so `vertical`
defaulted true and the tile asked for the stacked row height before it had
ever measured its own container. Once the real width came in it asked
again for the smaller single-panel height, but tile-row.tsx refuses to
shrink a multi-tile row back down - so a WaveRunner sharing a row with
another tile got stuck at the stacked height, with ~164px of dead space
below it, even though it was wide enough to lay out side by side.

Fix: skip the height request entirely until containerWidth has a value.
Added a test asserting onRequestRowHeight is not called while width is
undefined.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The cross-clear of the other operation's error ran after the "no model
selected", metadata-load, and "no station selected" guards in runModel.
Any of those early returns left loadDataError untouched, so a failed
Load Data followed by a Run with no station selected kept showing the old
load error - status-and-output.tsx shows loadDataError || runError, so the
stale message masked the fact that this run attempt had its own (different)
problem.

Also corrected two comments that said "loadData" where they meant the
loadEnvelopeData action - loadData() is the separate, always-successful
helper that just syncs the shared seismogram and never touches
loadDataError, so attributing the cross-clear or the inclusive-end-date
convention to it was misleading.

Fix: moved `self.loadDataError = null` to the top of runModel, before any
of the early-return guards. Added a test reproducing the exact scenario
(loadEnvelopeData fails, then runModel is called with no model selected)
and verified it fails without the fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Placeholder date segments and the date-field border both used
$charcoal-light-1, which measured 3.03:1 on white (text needs 4.5:1) and
2.68:1 against the data-setup panel's teal-light-7 background (a non-text
boundary needs 3:1, WCAG 1.4.11). $charcoal, an existing token already in
vars.scss, measures 4.95:1 on white and 4.37:1 on teal-light-7, so it was
reused rather than adding a new token.

Also removed the dead z-index: 11 on .date-field-popover: React Aria sets
z-index: 100000 inline on the same element, so the rule never applied.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three assertions on this branch proved nothing:

- date-field.test.tsx asserted the absence of a "date-field-time-column"
  test id that is never produced anywhere in the codebase, so it could never
  fail. Replaced it with a real assertion: the popover contains no
  spinbutton (time-editing) controls at all.
- The "...pending selection intact" test changed the calendar's month
  without ever picking a day first, so there was no pending selection for
  it to lose. It now picks a day, changes the month, and confirms OK still
  commits the originally picked day.
- The status line's priority chain (error > loading > running > complete >
  configured > setup) had no tests besides the lowest-priority setup
  message. Added coverage for every branch, including an error correctly
  outranking in-progress and completed-run state - the chain this branch's
  cross-clearing fix (wave-runner-content.ts) depends on.

Also added tests for the two small logic fixes going into the next commit:
offering no month before the first selectable date, and clampDate's own
unit tests (including the contradictory-bounds case, which can only be
exercised at that level - the rendered Calendar hangs if minValue is ever
actually past maxValue).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- monthOptions() only clipped the month dropdown at maxValue, so the end
  field's month list offered months before the start date - which the
  calendar then refused to let you pick a day in. Now clipped at both ends.
- clear()'s clamp applied min then max, so if the two bounds were
  themselves out of order (minValue tracks the other field and can
  transiently land past maxValue - see data-setup.tsx) the result could
  land below min. Extracted the clamp into date-utils.ts's new clampDate()
  and reordered it (max first, then min) so a contradictory pair always
  resolves to at least min.
- The hardcoded `new CalendarDate(2026, 9, 1)` fallback for the focused
  month duplicated kDefaultStartDate; now derived from that constant.
- The default "configured but nothing run yet" status line read "Estimated
  time to complete run:" with nothing ever filled in after the colon. Reads
  as a complete sentence now: "Ready to run the model."

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
US spelling: greyed/grey -> grayed/gray (_dropdown-appearance.scss,
status-and-output.tsx, status-and-output.test.tsx titles); "ellipsises"
reworded to "truncates with an ellipsis" (data-setup.scss); "tick" ->
"checkmark" for the selection check icon (data-setup.scss,
data-setup.test.tsx); "house dropdown/component" renamed to CustomSelect
throughout (_dropdown-appearance.scss, data-setup.test.tsx).

Inaccurate or stale:
- custom-select.tsx's ariaLabelledBy doc comment used "Station" as its
  example, baking WaveRunner's own field name into a shared component's
  doc; reworded to describe the behavior generically.
- data-setup.test.tsx called the month-and-year chooser "a native select"
  - it is a CustomSelect, same as everything else in this tile.
- date-field.test.tsx had a comment about a real click sequence exposing
  CustomSelect's outside-click handling sitting on the wrong test (one that
  never clicks an option); moved it to the test that actually does.
- _tile-metrics.scss claimed the section "carries no vertical padding of
  its own," but its 8px top value is exactly the section's padding-top
  (wave-runner-tile.scss); reworded to say so.
- wave-runner-types.ts's chromeHeight comment summed "title bar + teal
  background + padding," but the tile's title bar is absolutely positioned
  (tile-title-area.scss) and contributes nothing to that sum; reworded to
  say plainly that the number is empirical.

Backward-looking comments (data-setup.test.tsx, wave-runner-content.test.ts,
date-field.tsx, wave-runner-tile.scss, date-utils.ts) rewritten to state the
current constraint instead of narrating the bug or "previous version" they
used to guard against.

Repeated explanations collapsed to point at a canonical site instead of
restating it: title-masking (data-setup.tsx), cross-operation error
clearing (wave-runner-content.ts), the single status line
(status-and-output.tsx), the known-not-measured tile height
(wave-runner-types.ts), and Clear's clamp (date-utils.ts's clampDate).

Stale test titles: the 450px stacking threshold is actually 700
(wave-runner-tile.test.tsx); "covering the mock data range" renamed to
describe the actual assertion (wave-runner-content.test.ts).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@lbondaryk

Copy link
Copy Markdown
Contributor Author

Thank you — this was the review this branch needed. All four requested changes are in, plus the discretionary items. Eight commits, 70dac5dee..c3908e032.

You were right that the typed path was broken, and right about why it escaped: the tests pass a fixed value and never re-render from onChange, so the component never sees what it committed. There is now a controlled-wrapper helper that feeds onChange back into value as the model does, and the new typed-path tests use it.

1–3, typed dates. The root cause was in toDateString: it padded month and day but not the year, so the first keystroke of 2024 built "2-09-15". Typed edits now stay in draft and commit once, on blur or Enter, validated against min/max, reverting to the model's value when invalid or incomplete. Mutation-checked both ways: removing the year padding fails a unit test, and restoring the per-keystroke commit fails four of the new tests.

Two things we found while implementing that your probes wouldn't have surfaced, and that shaped the fix:

  • React Aria never reports a cleared segment. onChange fires only when an edit is complete and valid; backspacing updates its display silently. So draft alone cannot distinguish "complete" from "showing placeholders" — the commit checks for [data-placeholder] in the field, which is how React Aria marks that state. Without it, your case 3 would have committed a stale draft rather than reverting.
  • React Aria's own segment auto-advance fires a real blur on the filled segment. A naive blur-to-commit would have committed mid-typing — the very bug being fixed. The commit defers and confirms focus has actually left the field.

4 and 9, the a11y regression. Correct, and worse than a miss: the test I added asserted the name was exactly "Station", so it pinned the regression. ariaLabel is replaced by ariaLabelledBy, which names the visible <label> and the header, so the accessible name now carries both purpose and value. Both tests assert both parts, so either regressing independently fails. That also reconnects the orphaned labels. No other CustomSelect caller used the old prop.

7. The request now waits until containerWidth is known. 8. The cross-clear moved ahead of the three guards, so bailing out early still clears a stale load error, and the two comments saying "loadData" where they meant loadEnvelopeData are corrected.

10, contrast. $charcoal rather than a new token — 4.95:1 on white and 4.37:1 against the panel, so both the text and the boundary thresholds pass.

11, the hollow tests. All three repaired. date-field-time-column appears nowhere in the codebase, as you say; it now asserts the popover contains no role="spinbutton", which is a real consequence of granularity="day". The "pending selection intact" test now picks a day first and asserts OK still commits it after a month change. The status-line priority chain has seven new tests, two of them aimed squarely at the bug that chain introduced.

12, 13, 14 and the comment pass. The small fixes are in — monthOptions clips at minValue too, clear()'s clamp is ordered so a contradictory pair resolves to min, the fallback derives from kDefaultStartDate, the dead z-index is gone, and the default status line now reads "Ready to run the model." The comment pass is applied in full: US spellings, the stale and inaccurate comments, the backward-looking ones rewritten to state the present constraint, and the five repeated explanations collapsed to one site each.

One finding of our own while testing clear(): React Aria's Calendar hangs in an infinite render loop if the popover is opened with minValue > maxValue. Pre-existing in the library, not something this branch introduced, and not reachable now that typed dates are bounded — but it means the contradictory-bounds case is unit-tested against the clamp helper directly rather than through the rendered field.

Left for a decision rather than actioned:

  • 12 — the zero-length range for the seismogram viewport and Timeline It!. Reachable on master already, but this branch makes single-day ranges intentional. Happy to fix here or file it.
  • 13 — the committed plan and spec docs. My inclination matches yours: drop the 1,466-line plan, keep the spec.
  • 5 and 6 — the useDropdown bugs in accessibility-tools. Real, shared, and newly exposed here. Worth their own story.

119 plugin tests, 5369 across the repo, tsc and lint clean, every stylesheet compiled directly with sass — jest does not compile SCSS, and a broken stylesheet slipped through on this branch once.

This branch was previously deployed

1 inactive deployment
development — c3908e03 Deployed Oct 8, 2026 by github-actions[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants