Skip to content

feat: Scheduler field-control redesign + per-instance store + lazy cron-parser#28

Merged
baymac merged 34 commits into
baymac/cron-frequency-field-specfrom
baymac/scheduler-segmented-controls
May 30, 2026
Merged

feat: Scheduler field-control redesign + per-instance store + lazy cron-parser#28
baymac merged 34 commits into
baymac/cron-frequency-field-specfrom
baymac/scheduler-segmented-controls

Conversation

@baymac
Copy link
Copy Markdown
Owner

@baymac baymac commented May 29, 2026

Stacked on #27 (PR1). Base branch is baymac/cron-frequency-field-spec, not main — review/merge PR1 first.

Select a category

  • New feature
  • Bug fix
  • Demo update
  • Component style update
  • TypeScript definition update
  • Bundle size optimization
  • Performance optimization
  • Refactoring
  • Code style optimization
  • Test Case
  • README update
  • Other (about what?)

Related issue link

Follow-up to #27 (Scheduler redesign, PR1). Completes every remaining redesign TODO.

Background and solution

This stacks the full field-control redesign plus the deferred infra follow-ups onto PR1, in reviewable commits:

  1. Segmented pills + uppercase rows — At/Every (On/Every) dropdowns → SegmentedControl (ToggleButtonGroup); every row restacked to an uppercase label over wrapping controls (FieldRow).
  2. Steppers + chip-pickers + toggle chips — every-mode interval → numeric Stepper; at/on values → ChipMultiSelect (chips + add-menu, single-pick for non-admins); week/month → ToggleChipGroup with derived Any day/Every month toggles. The every N between X and Y range is preserved as the advanced RangePicker, which also DRYs the duplicated range state/effects out of Minute/Hour/DayOfMonth. All controls route through the existing atom setters, so cron serialization is unchanged; new locale keys are optional with English fallback.
  3. Per-instance jotai storeSchedulerRoot wraps each instance in its own Provider, so two schedulers on one page no longer stomp each other; removed the unmount-reset hack.
  4. Lazy-loaded cron-parsercomputeNextRuns dynamic-imports it, splitting cron-parser + luxon into an async chunk and shrinking the initial ESM entry from ~240 KB → ~61 KB gzip.

146 tests pass (unit + browser) and yarn build is green. The browser test project force-optimizes deps + dedupes React so a warm cache can't trigger the dup-React useContext failure.

Replace the per-field At/Every (On/Every) dropdowns with a segmented
ToggleButtonGroup (SegmentedControl) and restack every field row to an
uppercase label above wrapping controls (FieldRow), matching the redesign
mock's signature look. Value/range dropdowns are kept as-is, so there is
zero capability loss (the "every N between X and Y" range still works).

Also dedupe react/react-dom in the browser test project so a late MUI
subpath import can't get optimized into a second React copy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
baymac and others added 4 commits May 29, 2026 22:07
Wrap the component in a per-instance jotai Provider (SchedulerRoot) so two
<Scheduler>s on one page no longer share and stomp the module-global atoms.
The store is created on mount and discarded on unmount, which removes the
manual unmount-reset hack (and its localeRef workaround) from Scheduler.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
computeNextRuns now dynamically imports cron-parser (and its transitive
luxon) instead of importing it at module top. The bundler splits it into an
async chunk loaded only when the Next-runs panel first computes, shrinking
the initial ESM entry from ~240 KB gzip to ~61 KB gzip (cron-parser's
~179 KB gzip is deferred). computeNextRuns is now async; NextRuns consumes
it via effect+state with a cancel guard, and the unit tests await it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…hips

Completes the redesign control swap (PR3 scope):
- every-mode interval -> numeric Stepper
- at/on-mode values -> ChipMultiSelect (selected chips + add-menu, single-pick
  for non-admins)
- week/month -> ToggleChipGroup + derived Any-day / Every-month segmented
  toggles (all-selected == cron `*`, no new atom)
- the "every N between X and Y" range is preserved as an advanced RangePicker
  affordance, which also DRYs the duplicated range state/effects out of
  Minute/Hour/DayOfMonth

All controls route through the existing atom setters so cron serialization is
unchanged. New optional locale keys (addLabel / anyDayLabel / everyMonthLabel)
with English fallback. vitest browser project force-optimizes deps so a warm
cache predating the new MUI icon imports can't trigger a dup-React. 146 tests
pass (4 new control-pipeline tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@baymac baymac changed the title feat: Scheduler At/Every segmented pills + uppercase rows (PR2) feat: Scheduler field-control redesign + per-instance store + lazy cron-parser May 29, 2026
Per review feedback: bring back the old dropdown <select> for the At/Every
mode selector on Minute and Hour (the segmented pills only remain on the
Day-of-month On/Every selector, which was fine as-is). Also remove the newly
added Week "Any day/On" and Month "Every month/On" toggles — the old version
had no mode selector there, so those fields are now just their value chips.

Value controls (stepper, chip-pickers, toggle chips) are unchanged. Dropped
the now-unused anyDayLabel / everyMonthLabel locale keys (addLabel stays).
Tests updated; 144 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Correcting the previous change (which had it backwards): the At/Every (and
On/Every) mode selector stays a segmented 2-way toggle (PR2). The value fields
go back to the original CustomSelect dropdowns — undoing the steppers,
chip-pickers, and toggle-chip groups. Removed Stepper/ChipMultiSelect/
ToggleChipGroup/RangePicker and the addLabel/anyDayLabel/everyMonthLabel locale
keys.

Kept: per-instance jotai store, lazy-loaded cron-parser, the FieldRow uppercase
layout. 142 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Each field already shows its name in the FieldRow uppercase header (EVERY,
DAY OF THE WEEK, HOUR(S)...), so the dropdown's own floating label repeated it.
CustomSelect now renders the field name only as the input's aria-label (no
visible floating label), keeping accessibility and getByLabelText queries
intact while removing the visual duplication.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Dropdowns were MUI's default 40px tall and a fixed 300px wide, so a select
showing a short value ("week", "9") stretched most of the row and sat taller
than the 30px segmented toggle next to it.

- Compact the Autocomplete input to 30px (minHeight + reduced padding, using
  MUI's own selector specificity so the override wins) — matches the toggle.
- Shrink size widths (sm 100->110 only where needed, md 160->140, lg 300->190).
- Period uses the small width; Hour/Minute/Day-of-month value selects use the
  narrow width when single (every-mode / non-admin) and the wider one only when
  showing multiple chips (at/on-mode).
- Drop the between/and connector height 40->30 to stay aligned.

142 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bring back elements from the original design while keeping the current folding
and the 2-way toggles:
- Section (FieldRow) headers are the connector word: every / in / on-every /
  on / at-every / at-every (top to bottom for a yearly cron).
- The select inside each section regains its own field-name label (Period,
  Month(s), Days, Week Days, Hour(s), Minute(s)) — complementary to the section
  word, not redundant.
- Restore the original ~40px select height (the compact 30px looked odd); the
  segmented toggle is bumped to 40px so the row stays aligned.

On/Every and At/Every remain 2-way toggle buttons. 142 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
For the toggle fields (Day-of-month, Hour, Minute) the segmented toggle now
sits in the section header slot (replacing the redundant ON/EVERY / AT/EVERY
text) instead of in the controls row, and is shrunk to a compact 28px height.
Non-toggle sections (Period/Month/Week) keep their connector-word text header.
Added breathing room (header bottom margin 8->14) between the header and the
select. 142 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
In every-mode (and non-admin), the value select holds a single value but was
still a multi-select rendering it as a chip; at the narrow width the chip
wrapped and the field ballooned to ~68px tall. Now a single-value select shows
its value as plain inline text on one line, so it's a normal ~40px single-line
height like the other selects. Multi-select (at/on-mode) still renders chips.

142 tests pass (incl. the multi-select chips test).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Match the uppercase section headers the toggle sits among. The button's
lowercase aria-label still drives the accessible name, so role/name queries
are unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wrap the every-mode range controls (between / start / and / end) in a nowrap
RangeGroup so they never break apart across lines. The group can still wrap as
a whole relative to the value select on a narrow card, but the range itself
stays on a single line. Used by Minute/Hour/DayOfMonth.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Hour-range labels are on-the-hour only, so "12:00 AM" -> "12 AM". Shorter
labels let the start/end selects drop from md to sm width. Labels are also the
option values, but the cron logic is index-based so the change is safe; updated
the #16 test to the new labels.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sections with only one possible connector (Period=Every, Month=in, Week=on)
now show that word as a single blue SectionTag pill mirroring the selected
segment of the 2-way toggles — so every section header reads as a toggle
button (single-value ones are just one marked button). FieldRow gained a
headerSlot for this.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On a narrow (mobile) card the cron field pushed the copy/reset icons off the
right edge (clipped). The cron field + copy + reset are now grouped in an
Actions wrapper that wraps to a second row together while the cron field
shrinks, so the action icons always stay on-screen and side by side.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Toggling at/on -> every changed the mode immediately but only fixed the value
(e.g. minute 0, or day "L") in a follow-up effect, so the derived cron briefly
became `*/0` / `*/L` for one render — flashing an "Invalid ... cron part" error
before self-correcting. The mode toggle now sets a valid non-zero interval in
the SAME update (Minute/Hour/DayOfMonth), so the cron never passes through an
invalid state. Added a regression test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
SectionTag used a hardcoded 9px radius while the segmented toggle's visible
corners come from MUI ToggleButton (theme.shape.borderRadius, 4px by default),
so the standalone EVERY/IN/ON pill looked more rounded. SectionTag now uses the
same theme.shape.borderRadius, so both match (and adapt to the host theme).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drop the fixed 40px height on the between/and labels — it inflated the
wrapped mobile line with dead whitespace — and rely on the parents'
alignItems for inline centering. Give Controls/RangeGroup an explicit
14px rowGap (matching the header rhythm) while keeping the 10px column
gap, so toggle -> select -> between -> range are evenly spaced.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On a narrow card the cron field + copy/reset wrap to their own row.
marginLeft:auto right-shoved that group on the wrapped line, so the
input box no longer lined up with the calendar icon above it. Push the
group right via a growing Title instead (flex-grow only redistributes
space on a non-wrapped line, so the wrap point is unchanged), and drop
the auto margin so the wrapped row sits at the header's left padding.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`title` overrides the header text next to the calendar icon (taking
precedence over the locale's scheduleTitle). `color` sets the card's
accent — it overrides palette.primary in a scoped ThemeProvider so the
header bar, the selected toggle segment, and the section pills all
recolor together; contrastText is recomputed from the color via
augmentColor (falling back to a default theme when there's no parent
ThemeProvider). Both default to current behavior when omitted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…isabled text

Demo: move the ThemeProvider/CssBaseline into DemoPage so a dark-mode
icon toggle in the app bar can flip palette.mode, and add a
Desktop/Mobile ToggleButtonGroup that constrains the Scheduler to a
phone-width device frame (tripping its container queries — stacked
layout, wrapped header). Page/code-block backgrounds now use
background.default / action.hover so they follow the theme.

Lib: CustomSelect disabled-input text was hardcoded `white` (invisible
on the light card; only correct in dark). Use theme.palette.text.primary
so disabled values stay legible at full contrast in both modes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ur-range times

Validation errors were internal tokens wrapped in a redundant prefix
(e.g. "Invalid day of week cron part: Invalid single all"). Each field
validator now returns a plain reason clause and validateCronExp prepends
the field name, so messages read as sentences: "Day of week is invalid",
"Minute must be between 0 and 59", "Hour range must be low to high",
"A cron expression must have 5 parts". Also fixes the month validator's
out-of-range message (said 0–6, now 1–12).

Hour-range time labels drop the leading zero ("6 AM", not "06 AM"); the
range maps by array index so this is cosmetic only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rols

The dark-mode toggle lived in the website header and recolored the whole
page. Move it into the controls row beside Admin/Locale/View, keep the
demo site itself on a fixed light theme (PAGE_THEME), and wrap only the
Scheduler preview (and its mobile frame) in the light/dark theme via a
nested ThemeProvider — so you preview the component in dark mode without
darkening the surrounding page.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…even

The between/and labels are default body1 Typography (line-height 1.5),
so on a wrapped mobile line the flex item ran ~8px taller than the glyph
and padded extra space above and below "between" — making the gaps
around it larger than the toggle->select gap. lineHeight 1 hugs the
glyph, so toggle->select, select->between, and between->range all land
at the same 14px rhythm.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the outdated hero image (old label-left layout + indigo cron
pill) with a fresh capture from the demo dev server in the fully
expanded "every year" view, showing the redesign: Schedule header with
the cron + human-readable summary, the Next-runs panel, the EVERY/IN/ON
section pills, the On/Every & At/Every segmented toggles, multi-select
day chips, and the hour between-range.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…pp-wide dark

The scoped dark-mode ThemeProvider had no baseline, so text with an
inherited color (the between/and labels and other color:inherit
Typography) picked up the light page's near-black color and rendered
dark-on-dark — the preview "felt off" vs the old app-wide dark mode,
which had a global CssBaseline. ScopedCssBaseline applies the same
baseline (background.default, text.primary, color-scheme) scoped to the
preview wrapper, matching app-wide dark without darkening the page.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ScopedCssBaseline paints background.default; in desktop it sits flush as
a square behind the card, so its dark fill peeked through the card's
rounded corners (radius 12 + overflow hidden) as a dark halo. Make the
desktop wrapper background transparent — the card is its own dark
surface and the light page now shows behind the rounded corners. Mobile
keeps the dark background (it's the phone frame).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Upgrade @mui/* to ^9, Vitest/browser to ^4, Vite to 8, Biome to 2.4
- Switch tsconfig to esnext modules + bundler resolution
- CustomSelect: migrate renderTags → renderValue, pin input box-sizing
- SchedulerHeader: fix field height under ScopedCssBaseline box-sizing
- Scheduler: memoize accent theme via useTheme to avoid no-parent warning
- FieldRow: even out stacked gap under segmented-toggle headers
- gitignore .vitest-attachments/

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@baymac baymac merged commit 1200f3b into baymac/cron-frequency-field-spec May 30, 2026
1 check passed
baymac added a commit that referenced this pull request May 30, 2026
* feat: redesign Scheduler shell + add Next-runs panel (PR1)

Phase 1 of the Scheduler UI redesign: a new card shell around the existing
field controls (kept as dropdowns; PR2 swaps them to pills/steppers/chips).

- SchedulerHeader: theme-aware (primary.main) header carrying the cron
  expression, copy (open to all) and reset (admin-gated); folds in the old
  CronExp, preserving its debounce + the two-way prop-sync (#20 fix intact).
- NextRuns: live occurrence preview via cron-parser, gated behind the existing
  validateCronExp and guarded against throws; component-local useMemo + 30s
  tick (no Jotai/wall-clock impurity); Intl.* formatting (locale-aware, no new
  date strings); invalid -> "Enter a valid schedule", never-fires -> "No
  upcoming runs"; renders 5 rows, CSS-hides 4-5 on narrow widths.
- Two-column card with a container query (auto/split/stacked via `layout`
  prop); form column gets min-width:0 + overflow-x:auto so wide "every
  between X and Y" rows scroll inside the column instead of spilling off-page.
- CronReader moved to the top as the theme-tokened summary line.
- SchedulerProps gains timezone/layout/slotProps; Locale gains OPTIONAL panel
  strings with English fallback (non-breaking for customLocale consumers).
- Tests: nextRuns unit + contract suite (114 unit), 6 new browser tests
  (header, panel states, dark+RTL, admin copy/reset) — 24 browser green.

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

* feat: Scheduler field-control redesign + per-instance store + lazy cron-parser (#28)

* feat: swap At/Every selectors to segmented pills + uppercase rows (PR2)

Replace the per-field At/Every (On/Every) dropdowns with a segmented
ToggleButtonGroup (SegmentedControl) and restack every field row to an
uppercase label above wrapping controls (FieldRow), matching the redesign
mock's signature look. Value/range dropdowns are kept as-is, so there is
zero capability loss (the "every N between X and Y" range still works).

Also dedupe react/react-dom in the browser test project so a late MUI
subpath import can't get optimized into a second React copy.

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

* feat: scope jotai store per Scheduler instance

Wrap the component in a per-instance jotai Provider (SchedulerRoot) so two
<Scheduler>s on one page no longer share and stomp the module-global atoms.
The store is created on mount and discarded on unmount, which removes the
manual unmount-reset hack (and its localeRef workaround) from Scheduler.

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

* perf: lazy-load cron-parser into a separate chunk

computeNextRuns now dynamically imports cron-parser (and its transitive
luxon) instead of importing it at module top. The bundler splits it into an
async chunk loaded only when the Next-runs panel first computes, shrinking
the initial ESM entry from ~240 KB gzip to ~61 KB gzip (cron-parser's
~179 KB gzip is deferred). computeNextRuns is now async; NextRuns consumes
it via effect+state with a cancel guard, and the unit tests await it.

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

* feat: swap field value controls to steppers + chip-pickers + toggle chips

Completes the redesign control swap (PR3 scope):
- every-mode interval -> numeric Stepper
- at/on-mode values -> ChipMultiSelect (selected chips + add-menu, single-pick
  for non-admins)
- week/month -> ToggleChipGroup + derived Any-day / Every-month segmented
  toggles (all-selected == cron `*`, no new atom)
- the "every N between X and Y" range is preserved as an advanced RangePicker
  affordance, which also DRYs the duplicated range state/effects out of
  Minute/Hour/DayOfMonth

All controls route through the existing atom setters so cron serialization is
unchanged. New optional locale keys (addLabel / anyDayLabel / everyMonthLabel)
with English fallback. vitest browser project force-optimizes deps so a warm
cache predating the new MUI icon imports can't trigger a dup-React. 146 tests
pass (4 new control-pipeline tests).

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

* docs: mark all redesign follow-up TODOs as done

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

* feat: revert At/Every to dropdown; drop Week/Month mode toggles

Per review feedback: bring back the old dropdown <select> for the At/Every
mode selector on Minute and Hour (the segmented pills only remain on the
Day-of-month On/Every selector, which was fine as-is). Also remove the newly
added Week "Any day/On" and Month "Every month/On" toggles — the old version
had no mode selector there, so those fields are now just their value chips.

Value controls (stepper, chip-pickers, toggle chips) are unchanged. Dropped
the now-unused anyDayLabel / everyMonthLabel locale keys (addLabel stays).
Tests updated; 144 pass.

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

* revert: keep mode selector as 2-way toggle, restore value dropdowns

Correcting the previous change (which had it backwards): the At/Every (and
On/Every) mode selector stays a segmented 2-way toggle (PR2). The value fields
go back to the original CustomSelect dropdowns — undoing the steppers,
chip-pickers, and toggle-chip groups. Removed Stepper/ChipMultiSelect/
ToggleChipGroup/RangePicker and the addLabel/anyDayLabel/everyMonthLabel locale
keys.

Kept: per-instance jotai store, lazy-loaded cron-parser, the FieldRow uppercase
layout. 142 tests pass.

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

* fix: drop redundant floating label on field dropdowns

Each field already shows its name in the FieldRow uppercase header (EVERY,
DAY OF THE WEEK, HOUR(S)...), so the dropdown's own floating label repeated it.
CustomSelect now renders the field name only as the input's aria-label (no
visible floating label), keeping accessibility and getByLabelText queries
intact while removing the visual duplication.

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

* style: match dropdown height to the toggle and tighten widths

Dropdowns were MUI's default 40px tall and a fixed 300px wide, so a select
showing a short value ("week", "9") stretched most of the row and sat taller
than the 30px segmented toggle next to it.

- Compact the Autocomplete input to 30px (minHeight + reduced padding, using
  MUI's own selector specificity so the override wins) — matches the toggle.
- Shrink size widths (sm 100->110 only where needed, md 160->140, lg 300->190).
- Period uses the small width; Hour/Minute/Day-of-month value selects use the
  narrow width when single (every-mode / non-admin) and the wider one only when
  showing multiple chips (at/on-mode).
- Drop the between/and connector height 40->30 to stay aligned.

142 tests pass.

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

* style: name sections by connector word, restore select labels + height

Bring back elements from the original design while keeping the current folding
and the 2-way toggles:
- Section (FieldRow) headers are the connector word: every / in / on-every /
  on / at-every / at-every (top to bottom for a yearly cron).
- The select inside each section regains its own field-name label (Period,
  Month(s), Days, Week Days, Hour(s), Minute(s)) — complementary to the section
  word, not redundant.
- Restore the original ~40px select height (the compact 30px looked odd); the
  segmented toggle is bumped to 40px so the row stays aligned.

On/Every and At/Every remain 2-way toggle buttons. 142 tests pass.

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

* style: move on/every & at/every toggle into the section header

For the toggle fields (Day-of-month, Hour, Minute) the segmented toggle now
sits in the section header slot (replacing the redundant ON/EVERY / AT/EVERY
text) instead of in the controls row, and is shrunk to a compact 28px height.
Non-toggle sections (Period/Month/Week) keep their connector-word text header.
Added breathing room (header bottom margin 8->14) between the header and the
select. 142 tests pass.

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

* fix: single-value select renders as text, not a wrapping chip

In every-mode (and non-admin), the value select holds a single value but was
still a multi-select rendering it as a chip; at the narrow width the chip
wrapped and the field ballooned to ~68px tall. Now a single-value select shows
its value as plain inline text on one line, so it's a normal ~40px single-line
height like the other selects. Multi-select (at/on-mode) still renders chips.

142 tests pass (incl. the multi-select chips test).

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

* style: uppercase the on/every/at toggle labels

Match the uppercase section headers the toggle sits among. The button's
lowercase aria-label still drives the accessible name, so role/name queries
are unaffected.

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

* style: keep "between X and Y" range on one line

Wrap the every-mode range controls (between / start / and / end) in a nowrap
RangeGroup so they never break apart across lines. The group can still wrap as
a whole relative to the value select on a narrow card, but the range itself
stays on a single line. Used by Minute/Hour/DayOfMonth.

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

* style: drop ":00" from hour-range time labels, narrow the selects

Hour-range labels are on-the-hour only, so "12:00 AM" -> "12 AM". Shorter
labels let the start/end selects drop from md to sm width. Labels are also the
option values, but the cron logic is index-based so the change is safe; updated
the #16 test to the new labels.

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

* style: render single-value section labels as a blue "marked" pill

Sections with only one possible connector (Period=Every, Month=in, Week=on)
now show that word as a single blue SectionTag pill mirroring the selected
segment of the 2-way toggles — so every section header reads as a toggle
button (single-value ones are just one marked button). FieldRow gained a
headerSlot for this.

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

* style: wrap range on narrow cards; narrow day-of-month range selects

- RangeGroup stays on one line on a normal-width card but wraps below
  @container (max-width: 480px) so the range flows below "between" on mobile
  instead of clipping off the right edge.
- Day-of-month range start/end selects md -> sm (ordinals like "1st"/"31st"
  are short).

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

* fix: cap multi-select chip area height so it scrolls instead of ballooning

When many/all options are selected (e.g. every day of the month), the chip
area grew unbounded and the field became enormous while open. Multi-selects
now cap the input at maxHeight 96 with overflow-y auto, so the chips scroll
within a bounded field. Single-value selects are unaffected (one line).

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

* style: keep the two range selects together when the range wraps on mobile

Glue start/and/end into a nowrap RangePair inside RangeGroup. On a narrow card
"between" drops to its own line and "0 and 59" stay together on the next line,
instead of the end select wrapping alone onto a third line.

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

* fix: cap visible chips to 3 + "+N" so multi-selects stay bounded

The maxHeight+scroll cap made scrolled chips slide under the floating label
(the "Week Days" strikethrough) and pushed the overflow indicator outside the
box. Instead, the custom renderTags now always shows at most 3 chips plus a
"+N" indicator — even when focused/open — so the field is a bounded, stable
height in every state with no scroll and no label overlap. Removed the now-
redundant limitTags prop from the field selects (it conflicted with the custom
renderTags).

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

* fix: wrap header so copy/reset stay visible on a narrow card

On a narrow (mobile) card the cron field pushed the copy/reset icons off the
right edge (clipped). The cron field + copy + reset are now grouped in an
Actions wrapper that wraps to a second row together while the cron field
shrinks, so the action icons always stay on-screen and side by side.

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

* fix: no transient invalid cron when toggling a field to "every"

Toggling at/on -> every changed the mode immediately but only fixed the value
(e.g. minute 0, or day "L") in a follow-up effect, so the derived cron briefly
became `*/0` / `*/L` for one render — flashing an "Invalid ... cron part" error
before self-correcting. The mode toggle now sets a valid non-zero interval in
the SAME update (Minute/Hour/DayOfMonth), so the cron never passes through an
invalid state. Added a regression test.

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

* style: match standalone section pill radius to the toggle

SectionTag used a hardcoded 9px radius while the segmented toggle's visible
corners come from MUI ToggleButton (theme.shape.borderRadius, 4px by default),
so the standalone EVERY/IN/ON pill looked more rounded. SectionTag now uses the
same theme.shape.borderRadius, so both match (and adapt to the host theme).

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

* style: even out vertical spacing in stacked (mobile) every-mode rows

Drop the fixed 40px height on the between/and labels — it inflated the
wrapped mobile line with dead whitespace — and rely on the parents'
alignItems for inline centering. Give Controls/RangeGroup an explicit
14px rowGap (matching the header rhythm) while keeping the 10px column
gap, so toggle -> select -> between -> range are evenly spaced.

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

* style: align wrapped header cron field with the title's calendar icon

On a narrow card the cron field + copy/reset wrap to their own row.
marginLeft:auto right-shoved that group on the wrapped line, so the
input box no longer lined up with the calendar icon above it. Push the
group right via a growing Title instead (flex-grow only redistributes
space on a non-wrapped line, so the wrap point is unchanged), and drop
the auto margin so the wrapped row sits at the header's left padding.

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

* feat: add `title` and `color` props to the Scheduler

`title` overrides the header text next to the calendar icon (taking
precedence over the locale's scheduleTitle). `color` sets the card's
accent — it overrides palette.primary in a scoped ThemeProvider so the
header bar, the selected toggle segment, and the section pills all
recolor together; contrastText is recomputed from the color via
augmentColor (falling back to a default theme when there's no parent
ThemeProvider). Both default to current behavior when omitted.

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

* feat(demo): dark-mode + mobile/desktop preview toggles; theme-aware disabled text

Demo: move the ThemeProvider/CssBaseline into DemoPage so a dark-mode
icon toggle in the app bar can flip palette.mode, and add a
Desktop/Mobile ToggleButtonGroup that constrains the Scheduler to a
phone-width device frame (tripping its container queries — stacked
layout, wrapped header). Page/code-block backgrounds now use
background.default / action.hover so they follow the theme.

Lib: CustomSelect disabled-input text was hardcoded `white` (invisible
on the light card; only correct in dark). Use theme.palette.text.primary
so disabled values stay legible at full contrast in both modes.

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

* fix: human-readable cron validation messages; drop leading zero in hour-range times

Validation errors were internal tokens wrapped in a redundant prefix
(e.g. "Invalid day of week cron part: Invalid single all"). Each field
validator now returns a plain reason clause and validateCronExp prepends
the field name, so messages read as sentences: "Day of week is invalid",
"Minute must be between 0 and 59", "Hour range must be low to high",
"A cron expression must have 5 parts". Also fixes the month validator's
out-of-range message (said 0–6, now 1–12).

Hour-range time labels drop the leading zero ("6 AM", not "06 AM"); the
range maps by array index so this is cosmetic only.

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

* demo: scope dark mode to the scheduler preview, move toggle into controls

The dark-mode toggle lived in the website header and recolored the whole
page. Move it into the controls row beside Admin/Locale/View, keep the
demo site itself on a fixed light theme (PAGE_THEME), and wrap only the
Scheduler preview (and its mobile frame) in the light/dark theme via a
nested ThemeProvider — so you preview the component in dark mode without
darkening the surrounding page.

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

* style: tighten the "between" line box so mobile every-range gaps are even

The between/and labels are default body1 Typography (line-height 1.5),
so on a wrapped mobile line the flex item ran ~8px taller than the glyph
and padded extra space above and below "between" — making the gaps
around it larger than the toggle->select gap. lineHeight 1 hugs the
glyph, so toggle->select, select->between, and between->range all land
at the same 14px rhythm.

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

* docs: refresh demo screenshot to the redesigned component (year view)

Replace the outdated hero image (old label-left layout + indigo cron
pill) with a fresh capture from the demo dev server in the fully
expanded "every year" view, showing the redesign: Schedule header with
the cron + human-readable summary, the Next-runs panel, the EVERY/IN/ON
section pills, the On/Every & At/Every segmented toggles, multi-select
day chips, and the hour between-range.

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

* demo: wrap scheduler preview in ScopedCssBaseline so scoped dark == app-wide dark

The scoped dark-mode ThemeProvider had no baseline, so text with an
inherited color (the between/and labels and other color:inherit
Typography) picked up the light page's near-black color and rendered
dark-on-dark — the preview "felt off" vs the old app-wide dark mode,
which had a global CssBaseline. ScopedCssBaseline applies the same
baseline (background.default, text.primary, color-scheme) scoped to the
preview wrapper, matching app-wide dark without darkening the page.

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

* demo: fix dark-mode corner halo around the scheduler card (desktop)

ScopedCssBaseline paints background.default; in desktop it sits flush as
a square behind the card, so its dark fill peeked through the card's
rounded corners (radius 12 + overflow hidden) as a dark halo. Make the
desktop wrapper background transparent — the card is its own dark
surface and the light page now shows behind the rounded corners. Mobile
keeps the dark background (it's the phone frame).

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

* chore: bump deps (MUI 9, Vitest 4, Biome 2.4) and adapt scheduler

- Upgrade @mui/* to ^9, Vitest/browser to ^4, Vite to 8, Biome to 2.4
- Switch tsconfig to esnext modules + bundler resolution
- CustomSelect: migrate renderTags → renderValue, pin input box-sizing
- SchedulerHeader: fix field height under ScopedCssBaseline box-sizing
- Scheduler: memoize accent theme via useTheme to avoid no-parent warning
- FieldRow: even out stacked gap under segmented-toggle headers
- gitignore .vitest-attachments/

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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