Components

Getting started

Components109

More

Cron inputNew

A schedule field people can read: cron or plain English in, the meaning in words and the next runs out, with clock changes flagged.

Used forScheduled jobsReport emailsWorkflow triggersDeveloper platforms

Install with the shadcn CLI

$ npx shadcn@latest add https://hairlineui.com/r/cron-input.json

Where it’s used

  • Scheduled jobs

    Let people choose when a sync, an export or a backup runs.

  • Report emails

    Weekly digests and reports that go out at the time each customer picks.

  • Workflow triggers

    The “run on a schedule” step in automation tools and workflow builders.

  • Developer platforms

    Cron settings for functions and workers, with the next runs shown before anything is saved.

What it handles for you

Most schedule fields are a bare text box and a link to a cron cheat sheet, so people save schedules they can’t read and find out in March that a job never ran. This field explains itself as you type.

Instead of wiring upa cron cheat sheetcron-parsercronstruetime-zone mathsa presets dropdown
  1. Every part is named

    Minute, hour, day, month and weekday sit under their parts, and the part you’re editing lights up.

  2. It says it in words

    “At 09:00, Monday to Friday”, updated with every keystroke.

  3. It shows the next runs

    The next five times it fires, in the schedule’s own time zone, before anything is saved.

  4. It reads plain English

    Type “every weekday at 9am” or “last friday of the month at 17:30” and Enter turns it into cron.

  5. It points at mistakes

    The wrong part turns red and says why: “The hour goes from 0 to 23, not 24.”

  6. It catches clock changes

    Runs that daylight saving skips or repeats are flagged, in London, New York or any other zone.

Overview

Schedules for jobs, reports and backups usually come down to a bare text box for "0 9 * * 1-5" and a link to a cron cheat sheet. Here each part of the expression has its name under it, and the part your caret is in lights up with a hairline that glides from part to part. The line below says it in words ("At 09:00, Monday to Friday") and the next five runs are listed in the schedule's time zone; as you edit, their hours and minutes roll to the new times. You can also type "every weekday at 9am": it's read as you type and Enter turns the words into cron. Presets have a highlight that slides to whichever one matches, and picking one morphs the expression into it, with the characters both share staying put. A mistake turns its part red and says what's wrong while the last good runs stay, dimmed, so nothing jumps. A run that a clock change skips or repeats says so, which is the bug nobody notices until March.

States

typing
The parts are spread with word spacing so their names (10.5px) fit under them. The name under the part your caret is in turns to the text colour and a 1px line glides to it (300ms); names follow their parts as the text changes (340ms).
meaning
The sentence under the field crossfades whole when it changes: the old one lifts out through a 4px blur in 180ms while the new one rises in (380ms, 60ms later).
runs
The next runs list day, time and how long until each. Hours and minutes roll in the direction they changed (700ms), dates and "in 3 h" morph letter by letter, and the list updates on each new minute.
english
Text with words that aren't cron names is read as English. The part names fold away (320ms), the sentence says what it understood and which cron it makes, and Enter or leaving the field morphs the words into the expression.
preset
A pill highlight slides to the preset that matches the expression (380ms). Picking one morphs the expression into the preset: characters both share glide, the rest rise in through a blur.
invalid
The part at fault gets a red line and a red name, the sentence says why ("The hour goes from 0 to 23, not 24.") and the runs stay at 45% opacity until the text is valid again. Escape morphs back to the last valid expression.
clocks
In a zone with daylight saving, a run at a time the clocks skip or repeat gets an amber mark and a line saying which.

Usage

usage.tsx
import { CronInput } from "@/components/cron-input";<CronInput  defaultValue="0 9 * * 1-5"  onValueChange={(expression) => setSchedule(expression)}/>

Works with

Schedules your users choose

Each workspace picks its own schedule and zone, so they can’t be fixed triggers in your config. Save them through the same parser, then let one route that runs every minute ask nextRuns whether this minute is due for each.

example.tsx
// app/settings/actions.ts"use server";import { parseCron } from "@/components/cron-parse";export async function saveSchedule({ cron, timeZone }: { cron: string; timeZone: string }) {  const parsed = parseCron(cron);  if (!parsed.ok) return { error: parsed.message };  await db.workspace.update({ where: { id: await workspaceId() }, data: { digestCron: parsed.schedule.expression, digestZone: timeZone } });  return { ok: true };}// app/settings/schedule.tsx"use client";import { CronInput } from "@/components/cron-input";export function DigestSchedule({ cron, timeZone }: { cron: string; timeZone: string }) {  const schedule = { cron, timeZone };  return (    <CronInput      label="Digest schedule"      defaultValue={cron}      defaultTimeZone={timeZone}      timeZones={["UTC", "Europe/London", "America/New_York", "Asia/Kolkata"]}      onValueChange={(next) => saveSchedule({ ...schedule, cron: (schedule.cron = next) })}      onTimeZoneChange={(zone) => saveSchedule({ ...schedule, timeZone: (schedule.timeZone = zone) })}    />  );}// app/api/cron/route.ts: runs every minute (vercel.json: "crons": [{ "path": "/api/cron", "schedule": "* * * * *" }])import { nextRuns, parseCron } from "@/components/cron-parse";export async function GET() {  const minute = Math.floor(Date.now() / 60_000) * 60_000;  const workspaces = await db.workspace.findMany({ where: { digestCron: { not: null } } });  for (const w of workspaces) {    const parsed = parseCron(w.digestCron!);    if (!parsed.ok) continue;    // Due when its first run after the previous minute is this minute.    const [next] = nextRuns(parsed.schedule, { from: new Date(minute - 1), count: 1, timeZone: w.digestZone });    if (next?.at.getTime() === minute) await sendDigest(w.id);  }  return Response.json({ ok: true });}

Props

PropTypeDescription
value / defaultValuestringdefault "0 9 * * 1-5"The schedule as cron. defaultValue may also be English ("every weekday at 9am"); the field starts with the words.
onValueChange(expression, schedule) => voidEach valid expression (tidied to single spaces, @macros expanded) and its parsed fields.
timeZone / defaultTimeZonestringdefault "UTC"The IANA zone the schedule runs in; the runs are listed in it. Many schedulers (Vercel, GitHub Actions) only run in UTC.
timeZonesstring[]Zones to choose from. With two or more, the zone is a dropdown (Pill select) showing each zone's current offset.
onTimeZoneChange(timeZone) => voidCalled when a zone is picked.
runsnumberdefault 5How many upcoming runs to list; 0 hides them.
presets{ label, expression }[]default CRON_PRESETSShortcuts above the field; [] hides them.
englishbooleandefault trueRead schedules typed in English.
hour12booleandefault falseWrite times as 1:30pm.
labelstringdefault "Schedule"Accessible name of the field.
namestringSubmits the expression with a form.

Accessibility and motion

  • Five-field Unix cron: minute, hour, day, month, weekday, with names (MON, JAN), lists, ranges, steps, @daily-style shortcuts, L for the last day of the month, and FRI#2 or 5L for the nth or last weekday. 0 and 7 are Sunday. When both day and weekday are set, either one matching runs it, as in Unix cron; the sentence says so.
  • English it reads: every N minutes or hours, from 9am to 5pm, weekdays, weekends, named days and ranges of them, the 1st and 15th, the last day, the second Tuesday, the last Friday, months, and times like 9, 9:30, 5pm, noon and midnight. What it can't say in cron ("9:30 and 17:00") it doesn't guess at.
  • parseCron, describeCron, nextRuns and parseSchedule come from cron-parse.ts, which has no React in it, so a server or a worker can check and describe schedules the same way.
  • The input has the field's label and is described by the sentence under it; errors mark it aria-invalid. The meaning is announced once typing pauses, never on each keystroke. Each run has its full text for screen readers; the rolling digits are hidden from them.
  • Runs are worked out in the zone with Intl, day by day for up to six years, so a schedule that never fires (31 February) says so instead of hanging.
  • Reduced motion: no glides, rolls or morphs; text and numbers change in place.

Requirements

React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style, light and dark). Icons from lucide-react.

The CLI also installs Text morph, Number roll.

Source

components/cron-input.tsx
"use client";import * as React from "react";import { CRON_FIELDS, cronTokens, describeCron, nextRuns, parseCron, parseSchedule, type CronParse, type CronSchedule } from "./cron-parse";import { NumberRoll } from "./number-roll";import { PillSelect } from "./pill-select";import { TextMorph } from "./text-morph";/* ───────────────────────────────────────────────────────── * CRON INPUT: a schedule field people can read * *   type      a cron expression; each part has its name under *             it, and the part your caret is in lights up and *             gets a hairline that glides from part to part *   explain   the line below says it in words ("At 09:00, *             Monday to Friday") and crossfades as you type *   runs      the next five times it fires, in the schedule's *             time zone; hours and minutes roll as you edit *   english   "every weekday at 9am" works too: it's read as *             you type, and Enter turns the words into cron *   presets   a highlight slides to the preset that matches, *             and picking one morphs the expression into it: *             the parts both share stay where they are *   wrong     the part at fault turns red and says why; the last *             good runs stay, dimmed, so nothing jumps *   clocks    a run that a clock change skips or repeats says so * ───────────────────────────────────────────────────────── */export interface CronPreset {  label: string;  expression: string;}export interface CronInputProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "defaultValue" | "onChange"> {  /** The schedule, controlled: a cron expression. */  value?: string;  /** A cron expression, or a schedule in English ("every weekday at 9am"). */  defaultValue?: string;  /** Called with each valid expression (tidied) and what it means. */  onValueChange?: (expression: string, schedule: CronSchedule) => void;  /** The zone the schedule runs in. Many schedulers only run in UTC. */  timeZone?: string;  defaultTimeZone?: string;  /** Zones to choose from; with two or more, the zone becomes a dropdown. */  timeZones?: string[];  onTimeZoneChange?: (timeZone: string) => void;  /** How many upcoming runs to list; 0 hides the list. */  runs?: number;  /** Shortcuts above the field; [] hides them. */  presets?: CronPreset[];  /** Read schedules typed in English. */  english?: boolean;  /** 1:30pm instead of 13:30. */  hour12?: boolean;  /** Accessible name of the field. */  label?: string;  locales?: string | string[];  disabled?: boolean;  name?: string;}export const CRON_PRESETS: CronPreset[] = [  { label: "Every 15 min", expression: "*/15 * * * *" },  { label: "Hourly", expression: "0 * * * *" },  { label: "Daily 09:00", expression: "0 9 * * *" },  { label: "Weekdays 09:00", expression: "0 9 * * 1-5" },  { label: "Mondays 08:00", expression: "0 8 * * 1" },  { label: "Monthly", expression: "0 0 1 * *" },];const EASE = "cubic-bezier(0.16,1,0.3,1)";const FIELD_LABELS = ["minute", "hour", "day", "month", "weekday"];const NAMES = new Set(["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC", "SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT", "L", "W"]);/** The field's type: one size for the input, its measuring copy and its morphing copy. */const TYPE = "font-mono text-[14.5px] tracking-[0.02em] [@media(pointer:coarse)]:text-[16px]";const reducedQuery = "(prefers-reduced-motion: reduce)";const subscribeReduced = (onChange: () => void) => {  const query = window.matchMedia(reducedQuery);  query.addEventListener("change", onChange);  return () => query.removeEventListener("change", onChange);};const useReducedMotion = () =>  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);const tidy = (text: string) => text.trim().split(/\s+/).join(" ");/** English if any word isn't a cron name (MON, JAN, L). */const looksEnglish = (text: string) => (text.match(/[a-z]+/gi) ?? ([] as string[])).some((w) => !NAMES.has(w.toUpperCase()) && !/^\d*L$/i.test(w));function relative(ms: number) {  const min = Math.round(ms / 60000);  if (min < 1) return "in under a minute";  if (min < 60) return `in ${min} min`;  const h = Math.round(min / 60);  if (h < 36) return `in ${h} h`;  const d = Math.round(h / 24);  if (d < 14) return `in ${d} days`;  if (d < 60) return `in ${Math.round(d / 7)} weeks`;  const mo = Math.round(d / 30.4);  if (mo < 24) return `in ${mo} months`;  return `in ${Math.round(d / 365)} years`;}function zoneOffset(timeZone: string) {  try {    const part = new Intl.DateTimeFormat("en-US", { timeZone, timeZoneName: "shortOffset" }).formatToParts(new Date()).find((p) => p.type === "timeZoneName");    return part?.value.replace("GMT", "UTC") ?? "";  } catch {    return "";  }}const cityOf = (tz: string) => (tz === "UTC" ? "UTC" : (tz.split("/").pop() ?? tz).replace(/_/g, " "));/** A sentence that changes crossfades whole: the old one lifts away through a blur while the new one rises in. */function Line({ text, reduced }: { text: string; reduced: boolean }) {  const [shown, setShown] = React.useState({ text, key: 0 });  const [leaving, setLeaving] = React.useState<{ text: string; key: number }[]>([]);  if (text !== shown.text) {    if (shown.text && !reduced) setLeaving((l) => [...l.slice(-2), shown]);    setShown({ text, key: shown.key + 1 });  }  return (    <span className="relative block">      <span        key={shown.key}        className="block"        ref={(el) => {          if (!el || reduced || shown.key === 0 || el.dataset.in) return;          el.dataset.in = "1";          el.animate(            [              { opacity: 0, transform: "translateY(0.3em)", filter: "blur(4px)" },              { opacity: 1, transform: "none", filter: "blur(0px)" },            ],            { duration: 380, delay: 60, easing: EASE, fill: "backwards" },          );        }}      >        {shown.text || " "}      </span>      {leaving.map((l) => (        <span          key={l.key}          aria-hidden="true"          className="pointer-events-none absolute inset-x-0 top-0 block"          ref={(el) => {            if (!el || el.dataset.out) return;            el.dataset.out = "1";            el.animate(              [                { opacity: 1, transform: "none", filter: "blur(0px)" },                { opacity: 0, transform: "translateY(-0.3em)", filter: "blur(4px)" },              ],              { duration: 180, easing: EASE, fill: "forwards" },            ).onfinish = () => setLeaving((all) => all.filter((x) => x.key !== l.key));          }}        >          {l.text}        </span>      ))}    </span>  );}/** The expression on its way from one text to another: TextMorph, started from what the field showed. */function MorphOnce({ from, to, onDone }: { from: string; to: string; onDone: () => void }) {  const [text, setText] = React.useState(from);  React.useEffect(() => {    let inner = 0;    const outer = requestAnimationFrame(() => (inner = requestAnimationFrame(() => setText(to))));    const timer = window.setTimeout(onDone, 820);    return () => {      cancelAnimationFrame(outer);      cancelAnimationFrame(inner);      window.clearTimeout(timer);    };  }, [to, onDone]);  return (    <TextMorph animateWidth={false} className="whitespace-pre">      {text}    </TextMorph>  );}export function CronInput({  value,  defaultValue = "0 9 * * 1-5",  onValueChange,  timeZone,  defaultTimeZone = "UTC",  timeZones,  onTimeZoneChange,  runs: runCount = 5,  presets = CRON_PRESETS,  english = true,  hour12 = false,  label = "Schedule",  locales,  disabled = false,  name,  className = "",  ...props}: CronInputProps) {  const uid = React.useId();  const reduced = useReducedMotion();  // A default written in English starts as those words, already read as cron.  const [own, setOwn] = React.useState(() => (parseCron(defaultValue).ok || !english ? tidy(defaultValue) : (parseSchedule(defaultValue) ?? tidy(defaultValue))));  const committed = value !== undefined ? tidy(value) : own;  const [text, setText] = React.useState(() => (value !== undefined ? tidy(value) : defaultValue.trim()));  const [ownZone, setOwnZone] = React.useState(defaultTimeZone);  const zone = timeZone ?? ownZone;  const [caret, setCaret] = React.useState<number | null>(null);  const [focused, setFocused] = React.useState(false);  const [morph, setMorph] = React.useState<{ from: string; to: string; key: number } | null>(null);  const [marks, setMarks] = React.useState<{ x: number; w: number }[]>([]);  const [labelX, setLabelX] = React.useState<number[]>([]);  const [now, setNow] = React.useState<Date | null>(null);  const [announce, setAnnounce] = React.useState("");  const [presetMark, setPresetMark] = React.useState({ x: 0, w: 0, on: false, slide: false });  const inputRef = React.useRef<HTMLInputElement>(null);  const labelRefs = React.useRef<(HTMLSpanElement | null)[]>([]);  const labelRow = React.useRef<HTMLDivElement>(null);  const mirrorRef = React.useRef<HTMLSpanElement>(null);  const presetRefs = React.useRef<(HTMLButtonElement | null)[]>([]);  const morphKey = React.useRef(0);  const lastGood = React.useRef<CronSchedule | null>(null);  // What the text means: cron, English that reads as cron, or a problem with a part to point at.  const read = React.useMemo(() => {    const cron = parseCron(text);    if (cron.ok) return { kind: "cron" as const, schedule: cron.schedule };    if (english && looksEnglish(text)) {      const converted = parseSchedule(text);      const parsed = converted ? parseCron(converted) : null;      if (parsed?.ok) return { kind: "english" as const, schedule: parsed.schedule };      return { kind: "error" as const, message: "Couldn’t read that. Try “every weekday at 9am” or cron like 0 9 * * 1-5.", field: null as number | null };    }    const bad = cron as Extract<CronParse, { ok: false }>;    return { kind: "error" as const, message: bad.message, field: bad.field };  }, [text, english]);  const schedule = read.kind === "error" ? null : read.schedule;  if (schedule) lastGood.current = schedule;  const shownSchedule = schedule ?? lastGood.current;  // A new value from outside morphs in.  const [seen, setSeen] = React.useState(committed);  if (committed !== seen) {    setSeen(committed);    if (tidy(text) !== committed) {      if (!reduced) setMorph({ from: text, to: committed, key: ++morphKey.current });      setText(committed);    }  }  // Each valid cron expression is passed on as it's typed.  const report = (expression: string, s: CronSchedule) => {    if (expression === committed) return;    if (value === undefined) setOwn(expression);    setSeen(expression);    onValueChange?.(expression, s);  };  React.useEffect(() => {    if (read.kind === "cron") report(read.schedule.expression, read.schedule);    // eslint-disable-next-line react-hooks/exhaustive-deps  }, [read]);  const morphTo = (next: string) => {    if (!reduced && next !== text) setMorph((m) => ({ from: m ? m.to : text, to: next, key: ++morphKey.current }));    setText(next);  };  const morphDone = React.useCallback(() => setMorph(null), []);  // English becomes cron on Enter or when you leave the field.  const settle = () => {    if (read.kind === "english") {      morphTo(read.schedule.expression);      setAnnounce(`Set to ${read.schedule.expression}: ${describeCron(read.schedule, { hour12 })}`);    }  };  // The clock for "in 3 h", re-read on each new minute (and only after mount, so the server's render matches).  React.useEffect(() => {    let timer = 0;    const tick = () => {      setNow(new Date());      timer = window.setTimeout(tick, 60_000 - (Date.now() % 60_000) + 50);    };    tick();    return () => window.clearTimeout(timer);  }, []);  const upcoming = React.useMemo(() => (shownSchedule && now && runCount ? nextRuns(shownSchedule, { from: now, count: runCount, timeZone: zone }) : []), [shownSchedule, now, runCount, zone]);  // Where each part sits in the field, measured from a copy of the text in the same type.  const tokens = read.kind === "english" ? [] : cronTokens(text);  React.useLayoutEffect(() => {    const mirror = mirrorRef.current;    const input = inputRef.current;    const node = mirror?.firstChild;    if (!mirror || !input) return;    const base = mirror.getBoundingClientRect().left;    const next = tokens.slice(0, 6).map((t) => {      if (!node) return { x: 0, w: 0 };      const range = document.createRange();      range.setStart(node, Math.min(t.start, node.textContent!.length));      range.setEnd(node, Math.min(t.end, node.textContent!.length));      const r = range.getBoundingClientRect();      return { x: r.left - base - input.scrollLeft, w: r.width };    });    setMarks((prev) => (prev.length === next.length && prev.every((p, i) => Math.abs(p.x - next[i].x) < 0.5 && Math.abs(p.w - next[i].w) < 0.5) ? prev : next));  });  // Each name sits centred under its part, nudged along so none overlap or run past the edges.  React.useLayoutEffect(() => {    const row = labelRow.current;    if (!row) return;    const ws = FIELD_LABELS.map((_, i) => labelRefs.current[i]?.offsetWidth ?? 0);    const xs = FIELD_LABELS.map((_, i) => (marks[i] ? marks[i].x + marks[i].w / 2 - ws[i] / 2 : 0));    const n = Math.min(marks.length, FIELD_LABELS.length);    for (let i = 0; i < n; i++) xs[i] = Math.max(xs[i], i ? xs[i - 1] + ws[i - 1] + 8 : 0);    for (let i = n - 1; i >= 0; i--) xs[i] = Math.min(xs[i], i === n - 1 ? row.offsetWidth - ws[i] : xs[i + 1] - 8 - ws[i]);    setLabelX((prev) => (prev.length === xs.length && prev.every((x, i) => Math.abs(x - xs[i]) < 0.5) ? prev : xs));  }, [marks]);  const activeField = caret === null || !focused ? -1 : tokens.findIndex((t) => caret >= t.start && caret <= t.end);  const badField = read.kind === "error" ? read.field : null;  const underline = badField !== null && badField < marks.length ? badField : activeField;  // The highlight slides to the preset the schedule matches.  const presetIndex = schedule ? presets.findIndex((p) => tidy(p.expression) === schedule.expression) : -1;  React.useLayoutEffect(() => {    const el = presetRefs.current[presetIndex];    setPresetMark((m) => (el ? (m.x === el.offsetLeft && m.w === el.offsetWidth && m.on ? m : { x: el.offsetLeft, w: el.offsetWidth, on: true, slide: m.on }) : m.on ? { ...m, on: false, slide: false } : m));  }, [presetIndex, presets]);  // The meaning, said once typing settles.  const sentence = schedule ? describeCron(schedule, { hour12 }) : "";  React.useEffect(() => {    if (!focused) return;    const t = window.setTimeout(() => setAnnounce(read.kind === "error" ? read.message : sentence), 900);    return () => window.clearTimeout(t);  }, [sentence, read, focused]);  const lineText =    read.kind === "error" ? read.message : read.kind === "english" ? `${sentence}. Press Enter to use ${read.schedule.expression}` : sentence;  const dateFmt = React.useMemo(() => new Intl.DateTimeFormat(locales ?? "en-GB", { weekday: "short", day: "numeric", month: "short", timeZone: "UTC" }), [locales]);  const yearFmt = React.useMemo(() => new Intl.DateTimeFormat(locales ?? "en-GB", { weekday: "short", day: "numeric", month: "short", year: "numeric", timeZone: "UTC" }), [locales]);  const zoneOptions = React.useMemo(() => (timeZones ?? []).map((tz) => ({ value: tz, label: cityOf(tz), description: `${tz} · ${zoneOffset(tz)}` })), [timeZones]);  const nowYear = now?.getUTCFullYear();  const errorTone = "text-red-600 dark:text-red-400";  return (    <div className={`w-full text-foreground ${className}`} {...props}>      {presets.length > 0 && (        <div          role="group"          aria-label="Presets"          className="relative -mx-1 mb-2.5 flex gap-1 overflow-x-auto px-1 pb-0.5 [mask-image:linear-gradient(to_right,transparent,black_6px,black_calc(100%_-_20px),transparent)] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"        >          <span            aria-hidden="true"            className="pointer-events-none absolute left-0 top-0 h-7 rounded-full bg-accent shadow-[inset_0_0_0_1px_var(--border)]"            style={{              width: presetMark.w,              transform: `translateX(${presetMark.x}px)`,              opacity: presetMark.on ? 1 : 0,              transition: reduced ? "none" : `${presetMark.slide ? `transform 380ms ${EASE}, width 380ms ${EASE}, ` : ""}opacity 200ms ${EASE}`,            }}          />          {presets.map((p, i) => (            <button              key={p.expression}              ref={(el) => {                presetRefs.current[i] = el;              }}              type="button"              disabled={disabled}              aria-pressed={i === presetIndex}              onClick={() => {                morphTo(tidy(p.expression));                inputRef.current?.focus({ preventScroll: true });              }}              className={`relative h-7 shrink-0 cursor-pointer whitespace-nowrap rounded-full px-2.5 text-[12px] font-medium transition-colors duration-200 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 disabled:cursor-default ${                i === presetIndex ? "text-foreground" : "text-muted-foreground"              }`}            >              {p.label}            </button>          ))}        </div>      )}      <div        onMouseDown={(e) => {          if ((e.target as HTMLElement).closest("button, input")) return;          e.preventDefault();          inputRef.current?.focus();        }}        className={`rounded-[14px] border bg-background px-3 pb-1.5 pt-2 transition-[border-color] duration-200 ${disabled ? "opacity-60" : "cursor-text"}`}        style={{          borderColor:            read.kind === "error" && !focused              ? "color-mix(in oklab, #ef4444 45%, var(--border))"              : focused                ? "color-mix(in oklab, var(--ring) 55%, var(--border))"                : "var(--border)",        }}      >        <div className="flex items-center gap-2">          <div className="relative min-w-0 flex-1">            <input              ref={inputRef}              value={text}              disabled={disabled}              aria-label={label}              aria-invalid={read.kind === "error" || undefined}              aria-describedby={`${uid}-line`}              autoComplete="off"              autoCapitalize="off"              spellCheck={false}              onChange={(e) => {                setText(e.target.value);                setCaret(e.target.selectionStart);              }}              onSelect={(e) => setCaret(e.currentTarget.selectionStart)}              onFocus={() => setFocused(true)}              onBlur={() => {                setFocused(false);                settle();              }}              onKeyDown={(e) => {                if (e.key === "Enter") {                  e.preventDefault();                  settle();                } else if (e.key === "Escape" && tidy(text) !== committed) {                  e.preventDefault();                  e.stopPropagation();                  morphTo(committed);                }              }}              placeholder="0 9 * * 1-5"              className={`h-8 w-full min-w-0 bg-transparent outline-none placeholder:text-muted-foreground disabled:cursor-default ${TYPE}`}              style={{ wordSpacing: read.kind === "english" ? "normal" : "1.1em", color: morph ? "transparent" : undefined, caretColor: morph ? "var(--foreground)" : undefined }}            />            {/* A copy of the text in the same type, measured to place each part's name under it. */}            <span              ref={mirrorRef}              aria-hidden="true"              className={`pointer-events-none invisible absolute left-0 top-0 whitespace-pre ${TYPE}`}              style={{ wordSpacing: read.kind === "english" ? "normal" : "1.1em" }}            >              {text}            </span>            {morph && (              <span aria-hidden="true" className={`pointer-events-none absolute inset-y-0 left-0 flex items-center whitespace-pre ${TYPE}`} style={{ wordSpacing: "1.1em" }}>                <MorphOnce key={morph.key} from={morph.from} to={morph.to} onDone={morphDone} />              </span>            )}            {/* The part you're in, or the part at fault: a hairline that glides from part to part. */}            <span              aria-hidden="true"              className={`pointer-events-none absolute -bottom-px left-0 h-px ${badField !== null ? "bg-red-500" : "bg-primary"}`}              style={{                width: marks[underline]?.w ?? 0,                transform: `translateX(${marks[underline]?.x ?? 0}px)`,                opacity: underline >= 0 && marks[underline] && !morph ? 1 : 0,                transition: reduced ? "none" : `transform 300ms ${EASE}, width 300ms ${EASE}, opacity 200ms ${EASE}`,              }}            />          </div>          {zoneOptions.length > 1 ? (            <PillSelect              label="Time zone"              options={zoneOptions}              value={zone}              align="end"              width={250}              display={(o) => o?.label ?? cityOf(zone)}              onValueChange={(tz) => {                if (timeZone === undefined) setOwnZone(tz);                onTimeZoneChange?.(tz);              }}            />          ) : (            <span className="shrink-0 text-[12px] font-medium text-muted-foreground">{cityOf(zone)}</span>          )}        </div>        {/* The name of each part, under it; folds away while you write in English. */}        <div className="grid" style={{ gridTemplateRows: read.kind === "english" ? "0fr" : "1fr", transition: reduced ? "none" : `grid-template-rows 320ms ${EASE}` }}>          <div className="min-h-0 overflow-hidden">            <div ref={labelRow} aria-hidden="true" className="relative mt-0.5 h-4 text-[10.5px] leading-4">              {FIELD_LABELS.map((name, i) => {                const m = marks[i];                const tone = badField === i ? errorTone : activeField === i ? "text-foreground" : "text-muted-foreground";                return (                  <span                    key={name}                    ref={(el) => {                      labelRefs.current[i] = el;                    }}                    className={`absolute left-0 top-0 whitespace-nowrap transition-colors duration-200 ${tone}`}                    style={{                      transform: `translateX(${labelX[i] ?? i * 48}px)`,                      opacity: m && read.kind !== "english" ? 1 : 0,                      transition: reduced ? "none" : `transform 340ms ${EASE}, opacity 200ms ${EASE}, color 200ms`,                    }}                  >                    {name}                  </span>                );              })}            </div>          </div>        </div>      </div>      <p id={`${uid}-line`} className={`mt-2 min-h-[1.45em] pl-1 text-[12.5px] leading-[1.45] transition-colors duration-300 ${read.kind === "error" ? errorTone : "text-muted-foreground"}`}>        <Line text={lineText} reduced={reduced} />      </p>      {runCount > 0 && (        <div className="mt-3 border-t border-border pt-3">          <div className="flex items-baseline justify-between gap-3 text-[11.5px] text-muted-foreground">            <span className="font-medium">Next runs</span>            <span className="truncate">              {zone === "UTC" ? "Times in UTC" : `Times in ${cityOf(zone)} · ${zoneOffset(zone)}`}            </span>          </div>          {now && upcoming.length === 0 && shownSchedule && (            <p className={`mt-2 text-[12.5px] ${errorTone}`}>This never runs: no date in the next six years matches it.</p>          )}          <ol            aria-label="Next runs"            className="mt-1.5 transition-opacity duration-300"            style={{ opacity: schedule ? 1 : 0.45, transitionTimingFunction: EASE }}          >            {upcoming.map((r, i) => {              const w = r.wall;              const dayDate = new Date(Date.UTC(w.year, w.month - 1, w.day));              const day = (w.year === nowYear ? dateFmt : yearFmt).format(dayDate);              const h = hour12 ? w.hour % 12 || 12 : w.hour;              const note = r.skipped                ? `Clocks go forward that night: ${String(w.hour).padStart(2, "0")}:${String(w.minute).padStart(2, "0")} doesn’t happen in ${cityOf(zone)}.`                : r.repeated                  ? `Clocks go back that night: ${String(w.hour).padStart(2, "0")}:${String(w.minute).padStart(2, "0")} happens twice in ${cityOf(zone)}.`                  : null;              return (                <li key={i} className="border-b border-border/60 py-1.5 last:border-b-0">                  <span className="sr-only">                    {day}, {String(w.hour).padStart(2, "0")}:{String(w.minute).padStart(2, "0")}, {now ? relative(r.at.getTime() - now.getTime()) : ""}                    {note ? `. ${note}` : ""}                  </span>                  <span aria-hidden="true" className="flex items-baseline gap-3 text-[13px]">                    <span className="w-[6.5rem] shrink-0">                      <TextMorph>{day}</TextMorph>                    </span>                    <span className="inline-flex font-medium tabular-nums">                      <NumberRoll value={h} format={hour12 ? undefined : { minimumIntegerDigits: 2 }} duration={700} />                      <span>:</span>                      <NumberRoll value={w.minute} format={{ minimumIntegerDigits: 2 }} duration={700} />                      {hour12 && <TextMorph className="ml-0.5">{w.hour < 12 ? "am" : "pm"}</TextMorph>}                    </span>                    {note && (                      <svg viewBox="0 0 16 16" fill="none" className="size-3.5 shrink-0 self-center text-amber-600 dark:text-amber-400">                        <circle cx="8" cy="8" r="6.25" stroke="currentColor" strokeWidth="1.5" />                        <path d="M8 4.75v3.75" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" />                        <circle cx="8" cy="11" r="1" fill="currentColor" />                      </svg>                    )}                    <span className="ml-auto truncate text-[12px] text-muted-foreground">                      <TextMorph>{now ? relative(r.at.getTime() - now.getTime()) : ""}</TextMorph>                    </span>                  </span>                  {note && <span className="mt-0.5 block text-[11.5px] leading-snug text-amber-700 dark:text-amber-400">{note}</span>}                </li>              );            })}          </ol>        </div>      )}      {name && <input type="hidden" name={name} value={committed} />}      <span className="sr-only" role="status" aria-live="polite">        {announce}      </span>    </div>  );}export { describeCron, nextRuns, parseCron, parseSchedule };export type { CronSchedule };export { CRON_FIELDS };
components/cron-parse.ts
/* ───────────────────────────────────────────────────────── * CRON PARSE: read, explain and run a cron expression * *   parseCron      five fields (minute hour day month weekday), *                  @macros, names (MON, JAN), ranges, lists, *                  steps, L for the last day of the month, and *                  FRI#2 / 5L for the nth or last weekday. Errors *                  say which field and why *   describeCron   the schedule in plain words: "At 09:00, *                  Monday to Friday" *   nextRuns       the next times it fires in a time zone, *                  flagging clock changes that skip or repeat one *   parseSchedule  plain English to cron: "every weekday at 9am", *                  "every 15 minutes from 9 to 5", "last friday *                  of the month at 17:30" * * Day and weekday follow Unix cron: when both are set, either * one matching is enough; when one starts with *, the other * decides. 0 and 7 are both Sunday. * ───────────────────────────────────────────────────────── */export type CronFieldName = "minute" | "hour" | "day" | "month" | "weekday";export const CRON_FIELDS: { name: CronFieldName; min: number; max: number }[] = [  { name: "minute", min: 0, max: 59 },  { name: "hour", min: 0, max: 23 },  { name: "day", min: 1, max: 31 },  { name: "month", min: 1, max: 12 },  { name: "weekday", min: 0, max: 7 },];const MONTHS = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"];const DAYS = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"];const MONTH_NAMES = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];const DAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];const NTH = ["", "first", "second", "third", "fourth", "fifth"];const MACROS: Record<string, string> = {  "@yearly": "0 0 1 1 *",  "@annually": "0 0 1 1 *",  "@monthly": "0 0 1 * *",  "@weekly": "0 0 * * 0",  "@daily": "0 0 * * *",  "@midnight": "0 0 * * *",  "@hourly": "0 * * * *",};export interface CronSchedule {  /** The five fields, tidied: single spaces, macros expanded. */  expression: string;  minutes: number[];  hours: number[];  days: number[];  months: number[];  /** 0–6, Sunday first. */  weekdays: number[];  /** L in the day field. */  lastDay: boolean;  /** FRI#2 (nth 2) or 5L (nth -1). */  nthWeekdays: { weekday: number; nth: number }[];  /** The field started with * (or ?), so the other one decides. */  dayStar: boolean;  weekdayStar: boolean;}export type CronParse = { ok: true; schedule: CronSchedule } | { ok: false; message: string; field: number | null };/** Where each field sits in the text, for pointing at it. */export function cronTokens(text: string): { start: number; end: number; text: string }[] {  const out: { start: number; end: number; text: string }[] = [];  const re = /\S+/g;  let m: RegExpExecArray | null;  while ((m = re.exec(text))) out.push({ start: m.index, end: m.index + m[0].length, text: m[0] });  return out;}const range = (a: number, b: number, step = 1) => {  const out: number[] = [];  for (let i = a; i <= b; i += step) out.push(i);  return out;};type Field = { values: Set<number>; star: boolean; last?: boolean; nth?: { weekday: number; nth: number }[] };function value(raw: string, f: number): number | string {  const { min, max, name } = CRON_FIELDS[f];  const up = raw.toUpperCase();  if (f === 3 && MONTHS.includes(up)) return MONTHS.indexOf(up) + 1;  if (f === 4 && DAYS.includes(up)) return DAYS.indexOf(up);  if (!/^\d+$/.test(raw)) {    if (f === 3) return `“${raw}” isn’t a month. Use 1–12 or JAN–DEC.`;    if (f === 4) return `“${raw}” isn’t a weekday. Use 0–6 or SUN–SAT.`;    return `“${raw}” isn’t a number. The ${name} goes from ${min} to ${max}.`;  }  const n = Number(raw);  if (n < min || n > max) return `The ${name} goes from ${min} to ${max}${f === 4 ? " (0 and 7 are Sunday)" : ""}, not ${n}.`;  return n;}function field(token: string, f: number): Field | string {  const { min, max, name } = CRON_FIELDS[f];  const out: Field = { values: new Set(), star: token[0] === "*" || token[0] === "?" };  for (const part of token.split(",")) {    if (!part) return `There’s an empty item in the ${name} list.`;    if (f === 2 && /^L$/i.test(part)) {      out.last = true;      continue;    }    if (f === 4) {      const nth = part.match(/^(\w+)(?:#(\d)|(L))$/i);      if (nth) {        const day = value(nth[1], 4);        if (typeof day === "string") return day;        const n = nth[3] ? -1 : Number(nth[2]);        if (n === 0 || n > 5) return `#${nth[2]} can’t be: a weekday comes 1 to 5 times a month.`;        (out.nth ??= []).push({ weekday: day % 7, nth: n });        continue;      }    }    if (/W/i.test(part) && f === 2) return "W (nearest weekday) isn’t supported. Use a day number or L.";    const [base, stepText] = part.split("/");    let step = 1;    if (stepText !== undefined) {      if (!/^\d+$/.test(stepText) || Number(stepText) < 1) return `A step needs a number after the slash, like ${base || "*"}/5.`;      step = Number(stepText);      if (step > max) return `A ${name} step of ${step} is bigger than the ${name} range.`;    }    let lo: number;    let hi: number;    if (base === "*" || base === "?") {      lo = min;      hi = f === 4 ? 6 : max;    } else if (base.includes("-")) {      const [a, b, extra] = base.split("-");      if (extra !== undefined || !a || !b) return `“${base}” isn’t a range. Write it as low-high, like 1-5.`;      const va = value(a, f);      const vb = value(b, f);      if (typeof va === "string") return va;      if (typeof vb === "string") return vb;      if (va > vb && !(f === 4 && vb === 0)) return `Ranges go from low to high: ${b}-${a}, not ${base}.`;      lo = va;      hi = f === 4 && vb === 0 ? 7 : vb;    } else {      const v = value(base, f);      if (typeof v === "string") return v;      lo = v;      hi = stepText !== undefined ? (f === 4 ? 6 : max) : v;    }    for (const n of range(lo, hi, step)) out.values.add(f === 4 ? n % 7 : n);  }  return out;}export function parseCron(text: string): CronParse {  const trimmed = text.trim();  if (!trimmed) return { ok: false, message: "Type a cron expression, like 0 9 * * 1-5.", field: null };  const macro = MACROS[trimmed.toLowerCase()];  const tokens = (macro ?? trimmed).split(/\s+/);  if (trimmed.startsWith("@") && !macro) return { ok: false, message: `${trimmed} isn’t a shortcut cron knows. Try @daily, @weekly or @hourly.`, field: 0 };  if (tokens.length !== 5)    return {      ok: false,      message: tokens.length < 5 ? `Cron has five parts: minute, hour, day, month, weekday. This has ${tokens.length}.` : `Cron has five parts; this has ${tokens.length}. Seconds and years aren’t supported.`,      field: tokens.length > 5 ? 5 : null,    };  const fields: Field[] = [];  for (let i = 0; i < 5; i++) {    const r = field(tokens[i], i);    if (typeof r === "string") return { ok: false, message: r, field: i };    fields.push(r);  }  const sorted = (f: Field) => [...f.values].sort((a, b) => a - b);  return {    ok: true,    schedule: {      expression: tokens.join(" "),      minutes: sorted(fields[0]),      hours: sorted(fields[1]),      days: sorted(fields[2]),      months: sorted(fields[3]),      weekdays: sorted(fields[4]),      lastDay: Boolean(fields[2].last),      nthWeekdays: fields[4].nth ?? [],      dayStar: fields[2].star,      weekdayStar: fields[4].star,    },  };}/* ── Plain words ──────────────────────────────────────── */const pad = (n: number) => String(n).padStart(2, "0");const ordinal = (n: number) => {  const s = n % 100 >= 11 && n % 100 <= 13 ? "th" : ["th", "st", "nd", "rd"][n % 10] ?? "th";  return `${n}${s}`;};const list = (items: string[]) => (items.length <= 1 ? (items[0] ?? "") : `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`);/** [a..b] if the values run without a gap. */const run = (v: number[]) => (v.length > 1 && v.every((n, i) => i === 0 || n === v[i - 1] + 1) ? [v[0], v[v.length - 1]] : null);/** k if the values are min, min+k, min+2k… to the end of the range. */const stepOf = (v: number[], min: number, max: number) => {  if (v.length < 2 || v[0] !== min) return null;  const k = v[1] - v[0];  if (k < 2 || !v.every((n, i) => n === min + i * k)) return null;  return v[v.length - 1] + k > max ? k : null;};export function describeCron(s: CronSchedule, { hour12 = false }: { hour12?: boolean } = {}): string {  const time = (h: number, m: number) => (hour12 ? `${h % 12 || 12}:${pad(m)}${h < 12 ? "am" : "pm"}` : `${pad(h)}:${pad(m)}`);  const M = s.minutes;  const H = s.hours;  const allM = M.length === 60;  const allH = H.length === 24;  const mStep = stepOf(M, 0, 59);  const hStep = stepOf(H, 0, 23);  const hRun = run(H);  let when: string;  if (allM && allH) when = "Every minute";  else if (allM && hRun) when = `Every minute from ${time(hRun[0], 0)} to ${time(hRun[1], 59)}`;  else if (mStep && allH) when = `Every ${mStep} minutes`;  else if (mStep && hRun) when = `Every ${mStep} minutes from ${time(hRun[0], 0)} to ${time(hRun[1], M[M.length - 1])}`;  else if (M.length === 1 && allH) when = M[0] === 0 ? "Every hour" : `Every hour at :${pad(M[0])}`;  else if (M.length === 1 && hStep) when = `Every ${hStep} hours${M[0] ? ` at :${pad(M[0])}` : ""}`;  else if (M.length === 1 && hRun && H.length >= 4) when = `Every hour from ${time(hRun[0], M[0])} to ${time(hRun[1], M[0])}`;  else if (M.length * H.length <= 6 && !allH && !allM) when = `At ${list(H.flatMap((h) => M.map((m) => time(h, m))))}`;  else if (M.length <= 4 && allH) when = `At ${list(M.map((m) => `:${pad(m)}`))} past every hour`;  else {    const part = (v: number[], min: number, max: number) => {      const k = stepOf(v, min, max);      const r = run(v);      return k ? `every ${k}` : r ? `${r[0]} to ${r[1]}` : list(v.map(String));    };    when = `At minute ${allM ? "every" : part(M, 0, 59)} past ${allH ? "every hour" : `hour ${part(H, 0, 23)}`}`;  }  // Which days.  const wd = s.weekdays;  const weekdayWords = () => {    const nth = s.nthWeekdays.map((n) => `the ${n.nth === -1 ? "last" : NTH[n.nth]} ${DAY_NAMES[n.weekday]} of the month`);    let plain = "";    if (wd.length && wd.length < 7) {      const r = run(wd);      if (wd.length === 5 && wd[0] === 1 && wd[4] === 5) plain = "Monday to Friday";      else if (wd.length === 2 && wd[0] === 0 && wd[1] === 6) plain = "on weekends";      else if (r && r[1] - r[0] >= 2) plain = `${DAY_NAMES[r[0]]} to ${DAY_NAMES[r[1]]}`;      else plain = wd.length === 1 ? `every ${DAY_NAMES[wd[0]]}` : `on ${list(wd.map((d) => `${DAY_NAMES[d]}s`))}`;    }    const nthText = nth.length ? `on ${list(nth)}` : "";    return [plain, nthText].filter(Boolean).join(" and ");  };  const dayWords = () => {    const days = s.days.length === 31 ? [] : s.days;    const k = stepOf(s.days, 1, 31);    const parts: string[] = [];    if (k) parts.push(`every ${k} days of the month`);    else if (days.length) {      const r = run(days);      parts.push(r && r[1] - r[0] >= 2 ? `on days ${r[0]} to ${r[1]} of the month` : `on the ${list(days.map(ordinal))} of the month`);    }    if (s.lastDay) parts.push("on the last day of the month");    return parts.join(" and ");  };  const restrictedDay = s.lastDay || s.days.length < 31;  const restrictedWeekday = s.nthWeekdays.length > 0 || wd.length < 7;  let days = "";  // Both set: Unix cron runs on either (unless one starts with *, when both must match).  if (restrictedDay && restrictedWeekday) days = !s.dayStar && !s.weekdayStar ? `${dayWords()}, and ${weekdayWords()}` : `${dayWords()}, ${weekdayWords()}`;  else if (restrictedWeekday) days = weekdayWords();  else if (restrictedDay) days = dayWords();  // Which months; one day in one month reads as a date.  const mo = s.months;  let months = "";  if (mo.length < 12) {    const r = run(mo);    if (mo.length === 1 && s.days.length === 1 && !restrictedWeekday && !s.lastDay) {      days = `on ${s.days[0]} ${MONTH_NAMES[mo[0] - 1]}`;    } else months = r && r[1] - r[0] >= 2 ? `in ${MONTH_NAMES[r[0] - 1]} to ${MONTH_NAMES[r[1] - 1]}` : `in ${list(mo.map((m) => MONTH_NAMES[m - 1]))}`;  }  if (!days && !months) return when.startsWith("At ") && !when.includes("past") ? `Every day at ${when.slice(3)}` : when;  const joiner = (phrase: string) => (phrase.startsWith("on ") || phrase.startsWith("in ") || phrase.startsWith("every ") ? " " : ", ");  let out = when;  if (days) out += joiner(days) + days;  if (months) out += (days ? ", " : joiner(months)) + months;  return out;}/* ── Next runs ────────────────────────────────────────── */export interface CronRun {  at: Date;  /** The wall-clock time it was asked for, in the time zone. */  wall: { year: number; month: number; day: number; hour: number; minute: number; weekday: number };  /** Clocks go forward over this time: it doesn't exist that day. */  skipped?: boolean;  /** Clocks go back over this time: it happens twice (this is the first). */  repeated?: boolean;}const formatters = new Map<string, Intl.DateTimeFormat>();function wallAt(epoch: number, timeZone: string) {  let f = formatters.get(timeZone);  if (!f) {    f = new Intl.DateTimeFormat("en-US", { timeZone, hourCycle: "h23", year: "numeric", month: "numeric", day: "numeric", hour: "numeric", minute: "numeric", second: "numeric" });    formatters.set(timeZone, f);  }  const p: Record<string, number> = {};  for (const part of f.formatToParts(new Date(epoch))) if (part.type !== "literal") p[part.type] = Number(part.value);  return { year: p.year, month: p.month, day: p.day, hour: p.hour % 24, minute: p.minute, second: p.second };}/** Minutes the zone is ahead of UTC at an instant. */function offsetAt(epoch: number, timeZone: string) {  const w = wallAt(epoch, timeZone);  return Math.round((Date.UTC(w.year, w.month - 1, w.day, w.hour, w.minute, w.second) - Math.floor(epoch / 1000) * 1000) / 60000);}/** The instant a wall-clock time happens in a zone, and whether a clock change skips or repeats it. */export function zonedTime(year: number, month: number, day: number, hour: number, minute: number, timeZone: string) {  const guess = Date.UTC(year, month - 1, day, hour, minute);  const before = offsetAt(guess - 6 * 3600_000, timeZone);  const after = offsetAt(guess + 6 * 3600_000, timeZone);  const fits = (t: number) => {    const w = wallAt(t, timeZone);    return w.year === year && w.month === month && w.day === day && w.hour === hour && w.minute === minute;  };  if (before === after) return { at: guess - before * 60000, skipped: false, repeated: false };  const a = guess - before * 60000;  const b = guess - after * 60000;  const fa = fits(a);  const fb = fits(b);  if (fa && fb && a !== b) return { at: Math.min(a, b), skipped: false, repeated: true };  if (fa || fb) return { at: fa ? a : b, skipped: false, repeated: false };  // In the gap: it would land an hour (the jump) later on the clock.  return { at: a, skipped: true, repeated: false };}const daysIn = (y: number, m: number) => new Date(Date.UTC(y, m, 0)).getUTCDate();/** The next `count` times the schedule fires after `from`, in `timeZone` (UTC by default). Looks up to six years ahead. */export function nextRuns(s: CronSchedule, { from = new Date(), count = 5, timeZone = "UTC" }: { from?: Date; count?: number; timeZone?: string } = {}): CronRun[] {  const out: CronRun[] = [];  const start = wallAt(from.getTime() + 60_000, timeZone);  const months = new Set(s.months);  const days = new Set(s.days);  const weekdays = new Set(s.weekdays);  const first = Date.UTC(start.year, start.month - 1, start.day);  for (let i = 0; i < 366 * 6 && out.length < count; i++) {    const date = new Date(first + i * 86_400_000);    const y = date.getUTCFullYear();    const m = date.getUTCMonth() + 1;    const d = date.getUTCDate();    const wd = date.getUTCDay();    if (!months.has(m)) continue;    const dim = daysIn(y, m);    const dayHit = days.has(d) || (s.lastDay && d === dim);    const weekdayHit = weekdays.has(wd) || s.nthWeekdays.some((n) => n.weekday === wd && (n.nth === -1 ? d + 7 > dim : Math.ceil(d / 7) === n.nth));    // Unix cron: if either field starts with *, both must match; if both are set, either one is enough.    if (s.dayStar || s.weekdayStar ? !(dayHit && weekdayHit) : !(dayHit || weekdayHit)) continue;    for (const h of s.hours) {      if (i === 0 && h < start.hour) continue;      for (const min of s.minutes) {        if (i === 0 && h === start.hour && min < start.minute) continue;        const z = zonedTime(y, m, d, h, min, timeZone);        if (z.at <= from.getTime()) continue;        out.push({ at: new Date(z.at), wall: { year: y, month: m, day: d, hour: h, minute: min, weekday: wd }, skipped: z.skipped || undefined, repeated: z.repeated || undefined });        if (out.length === count) return out;      }    }  }  return out;}/* ── Plain English in ─────────────────────────────────── */const DAY_WORDS: Record<string, number> = {  sun: 0, sunday: 0, mon: 1, monday: 1, tue: 2, tues: 2, tuesday: 2, wed: 3, weds: 3, wednesday: 3,  thu: 4, thur: 4, thurs: 4, thursday: 4, fri: 5, friday: 5, sat: 6, saturday: 6,};const MONTH_WORDS: Record<string, number> = Object.fromEntries(  MONTH_NAMES.flatMap((name, i) => [    [name.toLowerCase(), i + 1],    [name.slice(0, 3).toLowerCase(), i + 1],  ]).concat([["sept", 9]]),);const NTH_WORDS: Record<string, number> = { first: 1, "1st": 1, second: 2, "2nd": 2, third: 3, "3rd": 3, fourth: 4, "4th": 4, last: -1 };const DAY_RE = `(?:${Object.keys(DAY_WORDS).sort((a, b) => b.length - a.length).join("|")})`;const MONTH_RE = `(?:${Object.keys(MONTH_WORDS).sort((a, b) => b.length - a.length).join("|")})`;const TIME_ANY = String.raw`(?:noon|midday|midnight|\d{1,2}(?:[:.h]\d{2})?\s?(?:am|pm)?)`;const TIME_STRICT = String.raw`(?:noon|midday|midnight|\d{1,2}[:.h]\d{2}\s?(?:am|pm)?|\d{1,2}\s?(?:am|pm))`;const FILLER = new Set(["at", "on", "the", "of", "and", "every", "each", "in", "run", "runs", "it", "please", "a", "month", "day", "days"]);function readTime(raw: string): { h: number; m: number } | null {  const t = raw.trim();  if (t === "noon" || t === "midday") return { h: 12, m: 0 };  if (t === "midnight") return { h: 0, m: 0 };  const m = t.match(/^(\d{1,2})(?:[:.h](\d{2}))?\s?(am|pm)?$/);  if (!m) return null;  let h = Number(m[1]);  const min = m[2] ? Number(m[2]) : 0;  if (m[3]) {    if (h < 1 || h > 12) return null;    h = (h % 12) + (m[3] === "pm" ? 12 : 0);  }  if (h > 23 || min > 59) return null;  return { h, m: min };}/** Turns a schedule written in English into cron, or null if it can't be read. */export function parseSchedule(text: string): string | null {  let s = ` ${text.toLowerCase().replace(/[,;]/g, " ").replace(/\b(through|thru|until|till)\b/g, "to").replace(/\s+/g, " ").trim()} `;  const take = (re: RegExp) => {    const m = s.match(re);    if (m) s = s.replace(m[0], " ");    return m;  };  let minute: string | null = null;  let hour: string | null = null;  let dom = "*";  let mon = "*";  let dow = "*";  let m: RegExpMatchArray | null;  if ((m = take(/ every (\d+ )?(?:minutes?|mins?) /))) {    const n = m[1] ? Number(m[1]) : 1;    if (n < 1 || n > 59) return null;    minute = n === 1 ? "*" : `*/${n}`;    hour = "*";  } else if ((m = take(/ every (\d+ )?(?:hours?|hrs?) /)) || (m = take(/ hourly /))) {    const n = m[1] ? Number(m[1]) : 1;    if (n < 1 || n > 23) return null;    minute = "0";    hour = n === 1 ? "*" : `*/${n}`;    const past = take(/ at :?(\d{1,2})(?: minutes)?(?: past)? /);    if (past) {      if (Number(past[1]) > 59) return null;      minute = String(Number(past[1]));    }  }  // A window for steps: "from 9am to 5pm", "between 9 and 17".  if ((m = take(new RegExp(` (?:from|between) (${TIME_ANY}) (?:to|and|-) (${TIME_ANY}) `)))) {    const a = readTime(m[1]);    const b = readTime(m[2]);    if (!a || !b || minute === null || hour !== "*") return null;    // "to 5pm" means stop before 5pm.    const last = b.m === 0 ? b.h - 1 : b.h;    if (last < a.h) return null;    hour = last === a.h ? String(a.h) : `${a.h}-${last}`;  }  // Days of the week.  if ((m = take(new RegExp(` (?:on )?(?:the )?(first|1st|second|2nd|third|3rd|fourth|4th|last) (${DAY_RE}) (?:of )?(?:the |every |each )?(?:month )?`)))) {    const n = NTH_WORDS[m[1]];    const d = DAY_WORDS[m[2]];    dow = n === -1 ? `${d}L` : `${d}#${n}`;  } else if ((m = take(/ (?:every |on )?(?:weekdays?|workdays?|business days?) /))) dow = "1-5";  else if ((m = take(/ (?:every |on )?(?:the )?weekends? /))) dow = "0,6";  else if ((m = take(new RegExp(` (?:every |on )?(${DAY_RE})s? (?:to|-) (${DAY_RE})s? `)))) {    const a = DAY_WORDS[m[1]];    const b = DAY_WORDS[m[2]];    dow = a < b ? `${a}-${b}` : `${a}-${b === 0 ? 7 : b}`;  } else if ((m = take(new RegExp(` (?:every |on |each )?((?:${DAY_RE})s?(?:(?: and | & | |/)(?:${DAY_RE})s?)*) `)))) {    const found = m[1].split(/ and | & | |\//).map((w) => DAY_WORDS[w.replace(/s$/, "")] ?? DAY_WORDS[w]);    if (found.some((d) => d === undefined)) return null;    dow = [...new Set(found)].sort((a, b) => a - b).join(",");  }  // Days of the month.  if ((m = take(/ (?:on )?(?:the )?last day (?:of )?(?:the |every |each )?(?:month )?/))) dom = "L";  else if ((m = take(new RegExp(` (?:on )?(?:the )?(${MONTH_RE}) (\\d{1,2})(?:st|nd|rd|th)? `))) || (m = take(new RegExp(` (?:on )?(?:the )?(\\d{1,2})(?:st|nd|rd|th)? (?:of )?(${MONTH_RE}) `)))) {    const monthFirst = isNaN(Number(m[1]));    const mo = MONTH_WORDS[monthFirst ? m[1] : m[2]];    const day = Number(monthFirst ? m[2] : m[1]);    if (day < 1 || day > daysIn(2024, mo)) return null;    mon = String(mo);    dom = String(day);  } else if ((m = take(/ (?:on )?(?:the )?((?:\d{1,2}(?:st|nd|rd|th))(?:(?: and | & | )(?:the )?\d{1,2}(?:st|nd|rd|th))*) (?:of )?(?:the |every |each )?(?:month )?/))) {    const nums = m[1].match(/\d{1,2}/g)!.map(Number);    if (nums.some((n) => n < 1 || n > 31)) return null;    dom = [...new Set(nums)].sort((a, b) => a - b).join(",");  } else if (take(/ (?:monthly|every month|each month|once a month) /)) dom = "1";  // Months.  if ((m = take(new RegExp(` (?:in |every |each |during )?((?:${MONTH_RE})(?:(?: and | & | |-| to )(?:${MONTH_RE}))*) `)))) {    const range = m[1].match(new RegExp(`^(${MONTH_RE})(?:-| to )(${MONTH_RE})$`));    if (range) mon = `${MONTH_WORDS[range[1]]}-${MONTH_WORDS[range[2]]}`;    else mon = [...new Set(m[1].split(/ and | & | /).map((w) => MONTH_WORDS[w]))].sort((a, b) => a - b).join(",");    if (mon.includes("undefined")) return null;  }  if (take(/ (?:yearly|annually|every year|each year|once a year) /)) {    if (dom === "*") dom = "1";    if (mon === "*") mon = "1";  }  if (take(/ (?:weekly|every week|each week|once a week) /) && dow === "*") dow = "1";  take(/ (?:daily|every ?day|each day|nightly|every night|every morning) /);  // Times of day.  if (minute === null) {    const times = take(new RegExp(` at (${TIME_ANY}(?:(?: and | & | )${TIME_ANY})*) `)) ?? take(new RegExp(` (${TIME_STRICT}(?:(?: and | & | )${TIME_STRICT})*) `));    if (times) {      const parsed = times[1].split(/ and | & | (?=\d|noon|mid)/).map(readTime);      if (parsed.some((t) => !t)) return null;      const ts = parsed as { h: number; m: number }[];      const mins = [...new Set(ts.map((t) => t.m))];      const hours = [...new Set(ts.map((t) => t.h))].sort((a, b) => a - b);      // Cron can't say "9:30 and 17:00": every time must share its minute.      if (mins.length > 1) return null;      minute = String(mins[0]);      hour = hours.join(",");    } else {      minute = "0";      hour = "0";    }  }  const left = s.trim().split(" ").filter((w) => w && !FILLER.has(w));  if (left.length) return null;  return `${minute} ${hour} ${dom} ${mon} ${dow}`;}
components/pill-select.tsx
"use client";import * as React from "react";import { createPortal } from "react-dom";import { TextMorph } from "./text-morph";/* ───────────────────────────────────────────────────────── * PILL SELECT: a select that's the same surface as its pill * *   open      the pill grows into its list (size and corners *             ease) from the edge it's lined up with, while its *             label fades into the options; it opens upward when *             there's no room below, and long lists scroll *   pick      the list folds back into the pill and the label *             morphs to the new choice; the current option *             carries a drawn check *   keys      arrows move a highlight that glides between rows, *             Home and End jump, typing a few letters finds an *             option, Enter picks, Escape folds it back * * The list lives in a portal on the page's body, placed over * the pill, so a clipping toolbar, fold or scroller never cuts * it off; it carries the theme's colours with it. Scrolling * the page closes it. * * Used wherever a component needs a choice from a list; never * the browser's own dropdown, which can't follow the theme. * ───────────────────────────────────────────────────────── */export interface PillOption {  value: string;  label: string;  /** A second line in the list, e.g. a sample value. */  description?: string;}export interface PillSelectProps {  options: PillOption[];  value: string | null | undefined;  onValueChange: (value: string) => void;  /** Accessible name, e.g. "Column for Email". */  label: string;  /** Shown while nothing is chosen. */  placeholder?: string;  /** What the pill shows for a choice; the option's label by default. */  display?: (option: PillOption | undefined) => string;  /** The pill's contents, when it needs more than text (an icon that turns). */  renderValue?: (option: PillOption | undefined, open: boolean) => React.ReactNode;  /** Monospace labels, for code-like values (event names, column headers). */  mono?: boolean;  /** Which edge of the pill the list lines up with. */  align?: "start" | "end";  /** The list's width; never narrower than the pill. */  width?: number;  /** Fill the width it's given. */  block?: boolean;  /** Dims the pill's text, e.g. for "Don't import". */  muted?: boolean;  disabled?: boolean;  className?: string;}type Phase = "closed" | "opening" | "open" | "closing";const EASE = "cubic-bezier(0.16,1,0.3,1)";const DURATION = 420;const MAX_LIST = 280;// The theme travels with the list, so it matches the pill wherever the pill's colours were set.const TOKENS = ["--background", "--foreground", "--popover", "--popover-foreground", "--border", "--accent", "--accent-foreground", "--primary", "--primary-foreground", "--muted", "--muted-foreground", "--ring"];const FOCUS = "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40";const reducedQuery = "(prefers-reduced-motion: reduce)";const subscribeReduced = (onChange: () => void) => {  const query = window.matchMedia(reducedQuery);  query.addEventListener("change", onChange);  return () => query.removeEventListener("change", onChange);};const useReducedMotion = () =>  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);function Chevron({ up, reduced }: { up: boolean; reduced: boolean }) {  return (    <svg viewBox="0 0 16 16" fill="none" aria-hidden="true" className="size-3 shrink-0 text-muted-foreground" style={{ transform: up ? "rotate(180deg)" : "none", transition: reduced ? "none" : `transform 320ms ${EASE}` }}>      <path d="M4.5 6.25 8 9.75l3.5-3.5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />    </svg>  );}export function PillSelect({  options,  value,  onValueChange,  label,  placeholder = "Choose",  display,  renderValue,  mono = false,  align = "start",  width = 240,  block = false,  muted = false,  disabled = false,  className = "",}: PillSelectProps) {  const id = React.useId();  const reduced = useReducedMotion();  const [phase, setPhase] = React.useState<Phase>("closed");  const [active, setActive] = React.useState(0);  const [pill, setPill] = React.useState({ width: 0, height: 28 });  const [panelHeight, setPanelHeight] = React.useState(0);  const [fit, setFit] = React.useState({ up: false, width, shift: 0, x: 0, y: 0, theme: {} as Record<string, string> });  const [highlight, setHighlight] = React.useState({ top: 0, height: 0, ready: false });  const [quiet, setQuiet] = React.useState(false);  const wrapRef = React.useRef<HTMLDivElement>(null);  const triggerRef = React.useRef<HTMLButtonElement>(null);  const panelRef = React.useRef<HTMLDivElement>(null);  const surfaceRef = React.useRef<HTMLDivElement>(null);  const listRef = React.useRef<HTMLDivElement>(null);  const rowRefs = React.useRef<(HTMLDivElement | null)[]>([]);  const timer = React.useRef(0);  const typed = React.useRef({ text: "", at: 0 });  const selected = options.find((o) => o.value === value);  const expanded = phase === "open";  const mounted = phase !== "closed";  const text = display ? display(selected) : (selected?.label ?? placeholder);  const measureFit = () => {    const wrap = wrapRef.current;    if (!wrap) return;    const rect = wrap.getBoundingClientRect();    const vw = document.documentElement.clientWidth;    const w = Math.min(Math.max(width, rect.width), vw - 24);    // Lined up with the pill's edge, nudged back on screen.    const shift = align === "start" ? Math.min(0, vw - 12 - (rect.left + w)) : Math.max(0, 12 - (rect.right - w));    const estimate = Math.min(MAX_LIST, options.length * (options.some((o) => o.description) ? 46 : 32)) + 12;    const vh = window.innerHeight;    const below = vh - 8 - rect.top;    const above = rect.bottom - 8;    const up = below < estimate + 8 && above > below;    // Fixed coordinates of the pill's corner the list grows from.    const x = align === "start" ? rect.left : vw - rect.right;    const y = up ? vh - rect.bottom : rect.top;    const cs = getComputedStyle(wrap);    const theme: Record<string, string> = { colorScheme: cs.colorScheme, fontFamily: cs.fontFamily };    for (const t of TOKENS) {      const v = cs.getPropertyValue(t);      if (v) theme[t] = v;    }    setFit({ up, width: w, shift, x, y, theme });  };  const open = () => {    if (disabled || phase === "open" || phase === "opening") return;    window.clearTimeout(timer.current);    measureFit();    setActive(Math.max(0, options.findIndex((o) => o.value === value)));    setPhase(reduced ? "open" : "opening");  };  const close = React.useCallback(    (refocus = true) => {      window.clearTimeout(timer.current);      if (refocus) triggerRef.current?.focus({ preventScroll: true });      if (reduced) return setPhase("closed");      setPhase("closing");      timer.current = window.setTimeout(() => setPhase("closed"), DURATION);    },    [reduced],  );  React.useEffect(() => {    if (phase !== "opening") return;    let inner = 0;    const outer = requestAnimationFrame(() => (inner = requestAnimationFrame(() => setPhase("open"))));    return () => {      cancelAnimationFrame(outer);      cancelAnimationFrame(inner);    };  }, [phase]);  React.useEffect(() => () => window.clearTimeout(timer.current), []);  React.useLayoutEffect(() => {    const trigger = triggerRef.current;    if (!trigger) return;    const measure = () => setPill((p) => (p.width === trigger.offsetWidth && p.height === trigger.offsetHeight ? p : { width: trigger.offsetWidth, height: trigger.offsetHeight }));    measure();    const observer = new ResizeObserver(measure);    observer.observe(trigger);    return () => observer.disconnect();  }, []);  React.useLayoutEffect(() => {    const panel = panelRef.current;    if (panel) setPanelHeight(panel.offsetHeight);  }, [mounted, options]);  // The highlight glides to the active row, and the list scrolls to keep it in view.  React.useLayoutEffect(() => {    const row = rowRefs.current[active];    const list = listRef.current;    if (!row || !list) return;    setHighlight((h) => (h.top === row.offsetTop && h.height === row.offsetHeight && h.ready === (h.ready || expanded) ? h : { top: row.offsetTop, height: row.offsetHeight, ready: h.ready || expanded }));    const scroller = list.parentElement;    if (scroller && scroller.scrollHeight > scroller.clientHeight) {      if (row.offsetTop < scroller.scrollTop) scroller.scrollTop = row.offsetTop - 4;      else if (row.offsetTop + row.offsetHeight > scroller.scrollTop + scroller.clientHeight) scroller.scrollTop = row.offsetTop + row.offsetHeight - scroller.clientHeight + 4;    }  }, [active, expanded, mounted]);  React.useEffect(() => {    if (!mounted) setHighlight({ top: 0, height: 0, ready: false });  }, [mounted]);  React.useEffect(() => {    if (expanded) listRef.current?.focus({ preventScroll: true });  }, [expanded]);  React.useEffect(() => {    if (!expanded) return;    const inside = (n: Node) => Boolean(wrapRef.current?.contains(n) || surfaceRef.current?.contains(n));    const onDown = (e: PointerEvent) => {      if (!inside(e.target as Node)) close(false);    };    // The list sits where the pill was; if the page moves under it, it goes at once rather than fold in the wrong place.    const gone = () => {      window.clearTimeout(timer.current);      setPhase("closed");    };    const onScroll = (e: Event) => {      if (!surfaceRef.current?.contains(e.target as Node)) gone();    };    const onResize = gone;    document.addEventListener("pointerdown", onDown);    window.addEventListener("scroll", onScroll, true);    window.addEventListener("resize", onResize);    return () => {      document.removeEventListener("pointerdown", onDown);      window.removeEventListener("scroll", onScroll, true);      window.removeEventListener("resize", onResize);    };  }, [expanded, close]);  const choose = (o: PillOption) => {    close();    if (o.value !== value) onValueChange(o.value);  };  const onListKey = (e: React.KeyboardEvent) => {    const last = options.length - 1;    const move = (to: number) => {      e.preventDefault();      setActive(Math.max(0, Math.min(last, to)));    };    if (e.key === "ArrowDown") move(active === last ? 0 : active + 1);    else if (e.key === "ArrowUp") move(active === 0 ? last : active - 1);    else if (e.key === "Home") move(0);    else if (e.key === "End") move(last);    else if (e.key === "PageDown") move(active + 6);    else if (e.key === "PageUp") move(active - 6);    else if (e.key === "Enter" || e.key === " ") {      e.preventDefault();      if (options[active]) choose(options[active]);    } else if (e.key === "Tab") close(false);    else if (e.key.length === 1 && /\S/.test(e.key)) {      // A few letters typed quickly find the option that starts with them.      const now = performance.now();      typed.current = { text: (now - typed.current.at < 700 ? typed.current.text : "") + e.key.toLowerCase(), at: now };      const hit = options.findIndex((o) => o.label.toLowerCase().startsWith(typed.current.text));      if (hit !== -1) setActive(hit);    }  };  const w = expanded ? fit.width : pill.width;  const h = expanded ? panelHeight : pill.height;  const listId = `${id}-list`;  const pillText = renderValue ? null : (    <>      <span className={`min-w-0 flex-1 truncate text-left ${mono ? "font-mono text-[11.5px]" : ""} ${muted || !selected ? "text-muted-foreground" : "text-foreground"}`}>        {block ? text : <TextMorph>{text}</TextMorph>}      </span>      <Chevron up={expanded} reduced={reduced} />    </>  );  return (    <div      ref={wrapRef}      className={`relative ${block ? "flex w-full min-w-0" : "inline-flex"} ${className}`}      onKeyDown={(e) => {        setQuiet(false);        if (e.key === "Escape" && expanded) {          e.preventDefault();          e.stopPropagation();          close();        }      }}      onBlur={(e) => {        const to = e.relatedTarget as Node | null;        if (expanded && document.hasFocus() && !(to && (wrapRef.current?.contains(to) || surfaceRef.current?.contains(to)))) close(false);      }}    >      <button        ref={triggerRef}        type="button"        aria-haspopup="listbox"        aria-expanded={expanded}        aria-controls={mounted ? listId : undefined}        aria-label={`${label}: ${selected?.label ?? placeholder}`}        disabled={disabled}        onClick={() => (expanded ? close() : open())}        onKeyDown={(e) => {          if (e.key === "ArrowDown" || e.key === "ArrowUp") {            e.preventDefault();            open();          }        }}        onPointerDown={() => setQuiet(true)}        className={`inline-flex h-7 min-w-0 cursor-pointer items-center gap-1.5 rounded-full px-2.5 text-[12px] font-medium whitespace-nowrap shadow-[inset_0_0_0_1px_var(--border)] transition-colors duration-150 hover:bg-accent disabled:cursor-default disabled:opacity-50 disabled:hover:bg-transparent ${block ? "w-full" : ""} ${quiet ? "outline-none" : FOCUS}`}      >        {renderValue ? renderValue(selected, expanded) : pillText}      </button>      {mounted &&        createPortal(        <div          ref={surfaceRef}          onKeyDown={(e) => {            if (e.key === "Escape") {              e.preventDefault();              e.stopPropagation();              close();            }          }}          onPointerDown={(e) => {            if (!(e.target as HTMLElement).closest("[data-scroll]")) e.preventDefault();            setQuiet(true);          }}          className="fixed z-[60] overflow-hidden bg-popover text-popover-foreground"          style={{            ...fit.theme,            [fit.up ? "bottom" : "top"]: fit.y,            [align === "start" ? "left" : "right"]: fit.x + (expanded ? (align === "start" ? fit.shift : -fit.shift) : 0),            width: w,            height: h,            borderRadius: expanded ? 16 : pill.height / 2,            // The pill's own hairline, the whole way: it is the same surface.            boxShadow: "inset 0 0 0 1px var(--border)",            transition: reduced ? "none" : `width ${DURATION}ms ${EASE}, height ${DURATION}ms ${EASE}, left ${DURATION}ms ${EASE}, right ${DURATION}ms ${EASE}, border-radius ${DURATION}ms ${EASE}`,          }}        >          {/* The pill's label, fading out as the surface grows and back in as it folds. */}          <span            aria-hidden="true"            className={`pointer-events-none absolute inline-flex h-7 items-center gap-1.5 px-2.5 text-[12px] font-medium whitespace-nowrap ${fit.up ? "bottom-0" : "top-0"} ${align === "start" ? "left-0" : "right-0"}`}            style={{ width: pill.width, opacity: expanded ? 0 : 1, transition: reduced ? "none" : `opacity ${expanded ? 120 : 260}ms ${EASE} ${expanded ? 0 : 120}ms` }}          >            {renderValue ? renderValue(selected, expanded) : pillText}          </span>          <div            ref={panelRef}            className={`absolute p-1 ${fit.up ? "bottom-0" : "top-0"} ${align === "start" ? "left-0" : "right-0"}`}            style={{ width: fit.width, opacity: expanded ? 1 : 0, transition: reduced ? "none" : `opacity ${expanded ? 260 : 120}ms ${EASE} ${expanded ? 90 : 0}ms` }}          >            <div data-scroll="" className="overflow-y-auto overscroll-contain" style={{ maxHeight: MAX_LIST }}>              <div                ref={listRef}                id={listId}                role="listbox"                tabIndex={-1}                aria-label={label}                aria-activedescendant={`${id}-o${active}`}                onKeyDown={onListKey}                className="relative outline-none"              >                <span                  aria-hidden="true"                  className="pointer-events-none absolute inset-x-0 top-0 rounded-[12px] bg-accent"                  style={{                    height: highlight.height,                    transform: `translateY(${highlight.top}px)`,                    opacity: highlight.ready ? 1 : 0,                    transition: highlight.ready && !reduced ? `transform 260ms ${EASE}, height 260ms ${EASE}, opacity 200ms` : "none",                  }}                />                {options.map((o, i) => {                  const on = o.value === value;                  return (                    <div                      key={o.value}                      ref={(el) => {                        rowRefs.current[i] = el;                      }}                      id={`${id}-o${i}`}                      role="option"                      aria-selected={on}                      onPointerMove={() => setActive(i)}                      onClick={() => choose(o)}                      className="relative flex cursor-pointer items-start gap-2 rounded-[12px] px-2.5 py-1.5"                    >                      <span className="min-w-0 flex-1">                        <span className={`block truncate ${mono ? "font-mono text-[12px]" : "text-[12.5px] font-medium"}`}>{o.label}</span>                        {o.description && <span className="mt-0.5 block truncate text-[11.5px] text-muted-foreground">{o.description}</span>}                      </span>                      <svg viewBox="0 0 16 16" fill="none" aria-hidden="true" className="mt-0.5 size-3.5 shrink-0">                        <path                          d="M3.5 8.5 6.5 11.5 12.5 4.5"                          stroke="currentColor"                          strokeWidth="1.8"                          strokeLinecap="round"                          strokeLinejoin="round"                          pathLength={1}                          strokeDasharray={1}                          style={{ strokeDashoffset: on && expanded ? 0 : 1, transition: reduced ? "none" : `stroke-dashoffset 380ms ${EASE} 160ms` }}                        />                      </svg>                    </div>                  );                })}              </div>            </div>          </div>        </div>,          document.body,        )}    </div>  );}

Registry item: /r/cron-input.json