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
+ ) : pastCount > 0 ? (
+ // Past items must stay reachable when upcoming groups exist too — not
+ // only from the empty state.
+
+ setShowPast(true)}
+ className="calendar-empty-action"
+ >
+ Show {pastCount} past item{pastCount !== 1 ? "s" : ""}
+
+
) : 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