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" ? (
- setCreateOpen(true)}>
+ setCreateOpen(true)}>
New cron
) : 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"}
{saveBlockedReason ? (
-
+
{saveBlockedReason}
) : null}
diff --git a/src/components/automations/cron-detail-primitives.tsx b/src/components/automations/cron-detail-primitives.tsx
index 236fdd86b..b999a7d85 100644
--- a/src/components/automations/cron-detail-primitives.tsx
+++ b/src/components/automations/cron-detail-primitives.tsx
@@ -9,6 +9,6 @@ export function CronDetailSection({ title, description, className, children }: {
}
export function CronSummaryTile({ label, value, tone = "default" }: { label: string; value: ReactNode; tone?: "default" | "active" | "paused" | "danger" }) {
- const color = tone === "active" ? "oklch(0.75 0.1 150)" : tone === "danger" ? "var(--color-danger)" : tone === "paused" ? "var(--text-muted)" : "var(--text-primary)";
+ const color = tone === "active" ? "var(--color-success)" : tone === "danger" ? "var(--color-danger)" : tone === "paused" ? "var(--text-muted)" : "var(--text-primary)";
return
;
}
diff --git a/src/components/automations/reminder-detail-panel.tsx b/src/components/automations/reminder-detail-panel.tsx
index 498a416c3..ffbd864ed 100644
--- a/src/components/automations/reminder-detail-panel.tsx
+++ b/src/components/automations/reminder-detail-panel.tsx
@@ -247,7 +247,7 @@ export function DetailPanel({
{isDailySummary ? "Sent" : isReminder ? "Last run" : "Received"}
{item.firedAt ? relTime(item.firedAt) : isReminder ? "Never" : "—"}
@@ -378,7 +378,7 @@ export function DetailPanel({
)}
removeItem(item.id)} disabled={busy}
- className="rounded-[var(--radius-control)] p-0.5 text-[length:var(--text-xs)] text-[oklch(0.65_0.18_20)] transition-colors disabled:opacity-40 hover:underline">
+ className="rounded-[var(--radius-control)] p-0.5 text-[length:var(--text-xs)] text-[var(--color-danger)] transition-colors disabled:opacity-40 hover:underline">
Delete
@@ -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 (
onSelect(item)}>
@@ -85,7 +90,7 @@ export function RitualItemRow({ item, familiarLabel, onSelect, quiet = false }:
{inboxKindLabel(item.kind)}
{familiar ? {familiar} : null}
- {date ? relativeTime(date.toISOString()) : relativeTime(item.updatedAt)}
+ {relativeTime(shownIso)}
);
}
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.
+
+ 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