From 71f6640c65f09a691cfff9d1bd4dec3da9409d35 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Thu, 23 Jul 2026 13:29:48 -0500 Subject: [PATCH] fix(rituals): UI/UX debug pass across overview, calendar, and crons (cave-v1x6) Overview: - Log pane rows now show when the item fired (firedAt ?? updatedAt ?? createdAt) instead of the future fireAt, so times are monotonic with the sort order - Search icon no longer disappears when the list is empty (dead-end state) - Focus returns to the header action button after delete for reminders AND crons (previously only the New-reminder path was restored, and never for crons) Calendar: - Single-letter view shortcuts (t/d/w/m/a/n) no longer hijack Cmd/Ctrl/Alt chords like Cmd+M (minimize) or Ctrl+D - Month prev/next now clamps to the last day of the target month instead of overflowing (Jan 31 -> Mar 3 skip-month bug) - Agenda view: items always sort ascending; 'Show N past items' control is also rendered when future groups exist (was only reachable on an empty agenda) Crons: - Detail panel Last/Next run uses the same relative-time formatting as the list (was absolute date past 60m, inconsistent side by side) - Weekly cadence echo lists days in week order (Mon, Wed, Fri) rather than click order - Replaced hard-coded rgba(255,255,255,*) and raw oklch() literals with semantic tokens (--foreground color-mix, --color-success/warning/danger) across status-icon, schedule-list, cron/reminder detail panels Verified: targeted test suites + typecheck green; fixes confirmed live via Playwright against a built server (Cmd+M guard, log-pane times, agenda past-items control, cron detail badge/relative times). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../automations-detail-inputs.test.ts | 10 +++++ src/components/automations-view.test.ts | 19 +++++++++- src/components/automations-view.tsx | 22 +++++++---- .../automations/cron-detail-panel.tsx | 22 +++++------ .../automations/cron-detail-primitives.tsx | 2 +- .../automations/reminder-detail-panel.tsx | 6 +-- .../automations/ritual-overview.tsx | 9 ++++- src/components/automations/schedule-list.tsx | 2 +- src/components/automations/status-icon.tsx | 4 +- src/components/calendar-view-polish.test.ts | 29 +++++++++++++++ src/components/calendar-view.tsx | 37 ++++++++++++++----- 11 files changed, 122 insertions(+), 40 deletions(-) diff --git a/src/components/automations-detail-inputs.test.ts b/src/components/automations-detail-inputs.test.ts index 09b0ae422..835f22df0 100644 --- a/src/components/automations-detail-inputs.test.ts +++ b/src/components/automations-detail-inputs.test.ts @@ -122,3 +122,13 @@ assert.match(cronPanel, /runStatusIcon\(r\.status\)/, "run rows encode status by } console.log("automations-detail-inputs.test.ts: ok"); + +// ── Rituals UI/UX debug pass (cave-v1x6) ───────────────────────────────────── +// Run timestamps format identically in the list rows and the detail panel +// (relativeTimeSigned), and the weekly cadence echo lists days in week order, +// not click order. Status/validation colors come from theme tokens. +assert.match(cronPanel, /import \{ relativeTimeSigned \} from "@\/lib\/relative-time"/, "cron panel reuses the shared relative-time helper"); +assert.match(cronPanel, /return iso \? relativeTimeSigned\(iso\) : "—";/, "cron panel relTime matches schedule-list's formatting"); +assert.match(cronPanel, /RRULE_DAY_ORDER\.filter\(\(d\) => scheduleDays\.includes\(d\)\)\.map\(\(d\) => RRULE_DAY_LABEL\[d\]\)/, "weekly cadence echo is week-ordered"); +assert.doesNotMatch(cronPanel, /oklch\(0\.7[0-9]? 0\.1/, "cron panel active/status colors use tokens, not hardcoded oklch"); + diff --git a/src/components/automations-view.test.ts b/src/components/automations-view.test.ts index 097dac46d..5c03a710f 100644 --- a/src/components/automations-view.test.ts +++ b/src/components/automations-view.test.ts @@ -291,7 +291,9 @@ assert.match(source, /role="img" aria-label="Paused"/, "status dots carry access assert.match(source, /
\{ setCreateOpen\(false\); setTemplateInitialValues\(undefined\); \}\}/, "the create dialog closes through one reset path"); -assert.match(source, /window\.setTimeout\(\(\) => newBtnRef\.current\?\.focus\(\), 0\)/, "deletes hand focus somewhere stable instead of dropping it on "); +assert.match(source, /window\.setTimeout\(\(\) => \(newBtnRef\.current \?\? newCronBtnRef\.current\)\?\.focus\(\), 0\)/, "deletes hand focus to whichever header action is mounted instead of dropping it on "); +assert.match(source, /const deleteCodex = useCallback\(\(auto: CodexAutomation\) => \{\s*setSelectedCodex\(null\);\s*focusHeaderAction\(\)/, "cron deletes restore focus via the shared helper (newBtnRef is unmounted on the crons tab)"); +assert.match(source, /ref=\{newCronBtnRef\}/, "the crons-tab New button carries the focus-restore ref"); // ── 2026-07-03 audit batch C ────────────────────────────────────────────────── // The Activity tab opens this panel for agent/response items too — those are @@ -343,3 +345,18 @@ assert.match(source, /const hasRunningRun = automationRuns\.some\(\(r\) => r\.st assert.match(source, /\}, \[selectedCodex\?\.id, hasRunningRun, refreshRuns\]\);/, "the poll effect does not depend on the runs array identity"); assert.match(source, /setAutomationRuns\(\(prev\) => \(arrayContentEqual\(prev, runs\) \? prev : runs\)\)/, "unchanged run polls keep the array identity"); assert.doesNotMatch(source, /void refreshRuns\(id\);\s*\n\s*void refreshLastRuns\(\);/, "the hot poll loop no longer fans out per-automation requests"); + +// ── Rituals UI/UX debug pass (cave-v1x6) ───────────────────────────────────── +// Overview search must never dead-end: the input renders whenever search is +// open (items.length gating made both the input AND the toggle icon vanish +// when the list was empty), and the Log pane shows activity time (firedAt ?? +// updatedAt), not a future fireAt, so its timestamps read monotonically. +assert.match(source, /activeTab === "overview" && searchOpen && initialLoadDone \? \(/, "overview search input is not gated on items.length"); +assert.match(source, /timeMode="log"/, "the Log pane renders rows in log time mode"); +assert.match(source, /timeMode = "agenda"/, "RitualItemRow defaults to agenda time"); +assert.match(source, /item\.firedAt \?\? item\.updatedAt \?\? item\.createdAt\s*:/, "log mode shows last-activity time (same key ritualLogItems sorts by)"); + +// Status dots + schedule-mode toggle derive from theme tokens, not hardcoded +// white — rgba(255,255,255,…) was invisible-on-light-themes. +assert.doesNotMatch(source, /rgba\(255,\s*255,\s*255/, "no hardcoded white rgba left in the Rituals surface"); + diff --git a/src/components/automations-view.tsx b/src/components/automations-view.tsx index c9f1d2c11..f16efe5df 100644 --- a/src/components/automations-view.tsx +++ b/src/components/automations-view.tsx @@ -145,8 +145,14 @@ export function AutomationsView({ familiars, onOpenSession, onNewReminder, onEdi // (pause/resume/run/create/save/restore) was silent. const { announce } = useAnnouncer(); // Focus lands here after a delete unmounts the detail panel that held it — - // otherwise it falls to and keyboard users lose their place. + // otherwise it falls to and keyboard users lose their place. The + // overview "New" button and the crons "New cron" button are mounted on + // different tabs, so deletes focus whichever header action exists. const newBtnRef = useRef(null); + const newCronBtnRef = useRef(null); + const focusHeaderAction = useCallback(() => { + window.setTimeout(() => (newBtnRef.current ?? newCronBtnRef.current)?.focus(), 0); + }, []); const manageBtnRef = useRef(null); const overviewSwipeStartRef = useRef(null); const [storedActiveTab, setStoredActiveTab] = useSurfacePreference(surfacePreferenceSpecs.schedules.activeTab); @@ -405,7 +411,7 @@ export function AutomationsView({ familiars, onOpenSession, onNewReminder, onEdi if (prev?.id === id) { // The detail panel (which held focus) unmounts — hand focus somewhere // stable instead of letting it fall to . - window.setTimeout(() => newBtnRef.current?.focus(), 0); + focusHeaderAction(); return null; } return prev; @@ -419,7 +425,7 @@ export function AutomationsView({ familiars, onOpenSession, onNewReminder, onEdi setError(err instanceof Error ? err.message : "delete failed"); } finally { await reloadAfterMutation(); } }); - }, [items, scheduleDelete, reloadAfterMutation]); + }, [items, scheduleDelete, reloadAfterMutation, focusHeaderAction]); // Confirm before firing — crons and flows already do, and the identical Run // buttons on the All tab must not behave differently per type. @@ -578,7 +584,7 @@ export function AutomationsView({ familiars, onOpenSession, onNewReminder, onEdi const deleteCodex = useCallback((auto: CodexAutomation) => { setSelectedCodex(null); - window.setTimeout(() => newBtnRef.current?.focus(), 0); // panel held focus + focusHeaderAction(); // panel held focus scheduleDelete([auto.id], `automation “${auto.name}”`, async () => { setCodexAutos((prev) => prev.filter((a) => a.id !== auto.id)); try { @@ -589,7 +595,7 @@ export function AutomationsView({ familiars, onOpenSession, onNewReminder, onEdi setError(err instanceof Error ? err.message : "codex delete failed"); } finally { await reloadAfterMutation(); } }); - }, [scheduleDelete, reloadAfterMutation]); + }, [scheduleDelete, reloadAfterMutation, focusHeaderAction]); const runCodexNow = useCallback(async (auto: CodexAutomation) => { if (!(await confirm({ title: `Run “${auto.name}” now?`, body: "This executes the agent immediately.", confirmLabel: "Run now" }))) return; @@ -858,7 +864,7 @@ export function AutomationsView({ familiars, onOpenSession, onNewReminder, onEdi

)}
- {activeTab === "overview" && searchOpen && initialLoadDone && items.length > 0 ? ( + {activeTab === "overview" && searchOpen && initialLoadDone ? (
) : null} {activeTab === "crons" ? ( - ) : null} @@ -1177,7 +1183,7 @@ export function AutomationsView({ familiars, onOpenSession, onNewReminder, onEdi {overviewPane === "log" ? (
{ritualLog.length > 0 ? ritualLog.map((item) => ( - { setSelectedItem(next); setSelectedCodex(null); }} /> )) :

No quiet activity yet.

}
diff --git a/src/components/automations/cron-detail-panel.tsx b/src/components/automations/cron-detail-panel.tsx index 7e809c0ff..9a43f1728 100644 --- a/src/components/automations/cron-detail-panel.tsx +++ b/src/components/automations/cron-detail-panel.tsx @@ -12,6 +12,7 @@ import { formatTimestamp, readDateTimePrefs } from "@/lib/datetime-format"; import { Icon } from "@/lib/icon"; import { RRULE_DAY_LABEL, RRULE_DAY_ORDER, buildCodexRrule, parseCodexRrule, composeAutomationPrompt, splitAutomationPrompt } from "@/lib/codex-automation-form"; import { commaInput, listInput, parseListInput } from "@/lib/automations/list-input"; +import { relativeTimeSigned } from "@/lib/relative-time"; import { runStatusColor, runStatusIcon } from "@/lib/automations/run-status"; import { CronDetailSection, CronSummaryTile, FieldLabel } from "@/components/automations/cron-detail-primitives"; @@ -26,13 +27,10 @@ const selectClass = `${fieldBaseClass} h-8 px-2 text-[length:var(--text-sm)]`; const textareaClass = `${fieldBaseClass} resize-y px-2 py-2 text-[length:var(--text-sm)] leading-relaxed`; const monoTextareaClass = `${textareaClass} font-mono text-[length:var(--text-xs)]`; +// Same relative formatting the list rows use (schedule-list.tsx), so a run +// never reads "3d ago" in the list but "Jul 20, 2:15 PM" in this panel. function relTime(iso: string | undefined | null): string { - if (!iso) return "—"; - const now = Date.now(); - const then = new Date(iso).getTime(); - const minutes = Math.round((then - now) / 60_000); - if (Math.abs(minutes) < 60) return `${Math.abs(minutes)}m ${minutes < 0 ? "ago" : "from now"}`; - return new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }).format(new Date(iso)); + return iso ? relativeTimeSigned(iso) : "—"; } export function CodexDetailPanel({ @@ -260,7 +258,7 @@ export function CodexDetailPanel({ aria-pressed={scheduleMode === mode} className="rounded-[var(--radius-control)] px-2 py-1 text-[length:var(--text-xs)]" style={{ - background: scheduleMode === mode ? "rgba(255,255,255,0.08)" : "transparent", + background: scheduleMode === mode ? "color-mix(in oklch, var(--foreground) 8%, transparent)" : "transparent", color: scheduleMode === mode ? "var(--text-primary)" : "var(--text-muted)", }} > @@ -319,10 +317,10 @@ export function CodexDetailPanel({

{scheduleMode === "daily" ? `Runs every day at ${scheduleTime}` - : `Runs weekly on ${scheduleDays.map((d) => RRULE_DAY_LABEL[d]).join(", ")} at ${scheduleTime}`} + : `Runs weekly on ${RRULE_DAY_ORDER.filter((d) => scheduleDays.includes(d)).map((d) => RRULE_DAY_LABEL[d]).join(", ")} at ${scheduleTime}`}

) : ( -

+

{nextRrule || "RRULE required"}

)} @@ -444,10 +442,10 @@ export function CodexDetailPanel({ style={{ borderColor: isActive ? "color-mix(in oklch, var(--accent-presence) 45%, transparent)" : "var(--border-hairline)", background: isActive ? "color-mix(in oklch, var(--accent-presence) 14%, transparent)" : "var(--bg-base)", - color: isActive ? "oklch(0.75 0.1 150)" : "var(--text-muted)", + color: isActive ? "var(--color-success)" : "var(--text-muted)", }} > - + {isActive ? "Active" : "Paused"} )}
@@ -435,7 +435,7 @@ export function DetailPanel({ fullWidth disabled={busy} onClick={() => removeItem(item.id)} - className="rounded-[var(--radius-control)] py-2 text-[length:var(--text-sm)] font-medium transition-colors hover:bg-red-900/20 disabled:opacity-40 [color:oklch(0.65_0.18_20)]!" + className="rounded-[var(--radius-control)] py-2 text-[length:var(--text-sm)] font-medium transition-colors hover:bg-[color-mix(in_oklch,var(--color-danger)_12%,transparent)] disabled:opacity-40 [color:var(--color-danger)]!" > Delete diff --git a/src/components/automations/ritual-overview.tsx b/src/components/automations/ritual-overview.tsx index a85ef92d2..0c422049b 100644 --- a/src/components/automations/ritual-overview.tsx +++ b/src/components/automations/ritual-overview.tsx @@ -75,9 +75,14 @@ function RitualActions({ children }: { children: ReactNode }) { return {children}; } -export function RitualItemRow({ item, familiarLabel, onSelect, quiet = false }: { item: InboxItem; familiarLabel: (familiarId?: string | null) => string | null; onSelect: (item: InboxItem) => void; quiet?: boolean }) { +export function RitualItemRow({ item, familiarLabel, onSelect, quiet = false, timeMode = "agenda" }: { item: InboxItem; familiarLabel: (familiarId?: string | null) => string | null; onSelect: (item: InboxItem) => void; quiet?: boolean; timeMode?: "agenda" | "log" }) { const familiar = familiarLabel(item.familiarId); const date = ritualItemDate(item); + // The Log pane sorts by last activity (firedAt ?? updatedAt) — show that + // same timestamp, not a future fireAt, so its times read monotonically. + const shownIso = timeMode === "log" + ? item.firedAt ?? item.updatedAt ?? item.createdAt + : date ? date.toISOString() : item.updatedAt; return ( ); } diff --git a/src/components/automations/schedule-list.tsx b/src/components/automations/schedule-list.tsx index d0dff8087..9acb462cb 100644 --- a/src/components/automations/schedule-list.tsx +++ b/src/components/automations/schedule-list.tsx @@ -77,7 +77,7 @@ function AutomationScheduleRow({ {isActive ? ( ) : ( - + )} diff --git a/src/components/automations/status-icon.tsx b/src/components/automations/status-icon.tsx index 1dac9d6c1..d365cbdb9 100644 --- a/src/components/automations/status-icon.tsx +++ b/src/components/automations/status-icon.tsx @@ -9,7 +9,7 @@ export function StatusIcon({ item }: { item: InboxItem }) { if (paused) { return ( - + ); @@ -17,5 +17,5 @@ export function StatusIcon({ item }: { item: InboxItem }) { if (active && hasRun) { return ; } - return ; + return ; } diff --git a/src/components/calendar-view-polish.test.ts b/src/components/calendar-view-polish.test.ts index bafb9aa40..ef44a426e 100644 --- a/src/components/calendar-view-polish.test.ts +++ b/src/components/calendar-view-polish.test.ts @@ -297,4 +297,33 @@ assert.doesNotMatch( "no hand-rolled mini-month nav-arrow markup remains", ); +// ── Rituals UI/UX debug pass (cave-v1x6) ───────────────────────────────────── +// 1. View-switch shortcuts must not swallow browser/OS chords: Cmd+M, Ctrl+D +// etc. previously ALSO switched the calendar view. +assert.equal( + (source.match(/if \(e\.metaKey \|\| e\.ctrlKey \|\| e\.altKey\) break;/g) ?? []).length, + 6, + "every single-letter calendar shortcut ignores modified keypresses", +); +// 2. Month paging steps from day 1 and clamps back, so Jan 31 → Feb 28 +// instead of overflowing into March. +assert.match( + source, + /const d = new Date\(prev\.getFullYear\(\), prev\.getMonth\(\) \+ dir, 1\);\s*\n\s*const lastDay = new Date\(d\.getFullYear\(\), d\.getMonth\(\) \+ 1, 0\)\.getDate\(\);\s*\n\s*d\.setDate\(Math\.min\(prev\.getDate\(\), lastDay\)\);/, + "month navigation clamps the day instead of overflowing short months", +); +// 3. Agenda: past items stay reachable when upcoming groups exist (the toggle +// lived only in the empty state), and revealing them keeps chronological +// order instead of flipping the agenda to future-first. +assert.match( + source, + /\) : pastCount > 0 \? \([\s\S]{0,400}Show \{pastCount\} past item/, + "the Show-past toggle renders alongside populated agenda groups too", +); +assert.doesNotMatch( + source, + /\.sort\(\(a, b\) => showPast\s*\n?\s*\? b\.date\.getTime\(\) - a\.date\.getTime\(\)/, + "showPast no longer reverses the agenda sort", +); + console.log("calendar-view-polish.test.ts: month click-to-add + familiar colours + cave-4op control primitives ok"); diff --git a/src/components/calendar-view.tsx b/src/components/calendar-view.tsx index 351432f8e..bca59bf28 100644 --- a/src/components/calendar-view.tsx +++ b/src/components/calendar-view.tsx @@ -104,9 +104,9 @@ function AgendaView({ } return Array.from(map.values()) .filter((g) => showPast ? true : g.date >= startOfDay(anchor)) - .sort((a, b) => showPast - ? b.date.getTime() - a.date.getTime() - : a.date.getTime() - b.date.getTime()); + // Always chronological: revealing past prepends older groups above + // today instead of flipping the whole agenda to future-first. + .sort((a, b) => a.date.getTime() - b.date.getTime()); }, [items, deadlines, anchor, showPast]); // The single soonest still-pending item — highlighted as "Next" so the agenda @@ -166,6 +166,19 @@ function AgendaView({ Hide past
+ ) : pastCount > 0 ? ( + // Past items must stay reachable when upcoming groups exist too — not + // only from the empty state. +
+ +
) : null} {groups.map(({ date, items: groupItems, deadlines: groupDeadlines }) => { const total = groupItems.length + groupDeadlines.length; @@ -1585,12 +1598,13 @@ export function CalendarView({ items, familiars, activeFamiliarId, scopeFamiliar // out from under it. case "ArrowLeft": if (target.closest('[data-calendar-event="true"], [data-month-cell="true"]')) break; e.preventDefault(); navigate(-1); break; case "ArrowRight": if (target.closest('[data-calendar-event="true"], [data-month-cell="true"]')) break; e.preventDefault(); navigate(1); break; - case "t": case "T": setAnchor(new Date()); break; - case "d": case "D": setViewMode("day"); break; - case "w": case "W": setViewMode("week"); break; - case "m": case "M": setViewMode("month"); break; - case "a": case "A": setViewMode("agenda"); break; + case "t": case "T": if (e.metaKey || e.ctrlKey || e.altKey) break; setAnchor(new Date()); break; + case "d": case "D": if (e.metaKey || e.ctrlKey || e.altKey) break; setViewMode("day"); break; + case "w": case "W": if (e.metaKey || e.ctrlKey || e.altKey) break; setViewMode("week"); break; + case "m": case "M": if (e.metaKey || e.ctrlKey || e.altKey) break; setViewMode("month"); break; + case "a": case "A": if (e.metaKey || e.ctrlKey || e.altKey) break; setViewMode("agenda"); break; case "n": case "N": + if (e.metaKey || e.ctrlKey || e.altKey) break; if (onAddEntry) { e.preventDefault(); onAddEntry({ fireAt: defaultEntryFireAt(anchor) }); } break; } @@ -1608,8 +1622,11 @@ export function CalendarView({ items, familiars, activeFamiliarId, scopeFamiliar if (effectiveView === "day") return addDays(prev, dir); if (effectiveView === "week") return addDays(prev, dir * 7); if (effectiveView === "month") { - const d = new Date(prev); - d.setMonth(d.getMonth() + dir); + // setMonth on the raw anchor overflows short months (Jan 31 + 1 → Mar 3, + // skipping February) — step from day 1, then clamp the day back in. + const d = new Date(prev.getFullYear(), prev.getMonth() + dir, 1); + const lastDay = new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate(); + d.setDate(Math.min(prev.getDate(), lastDay)); return d; } // agenda: jump by 2 weeks