+ {/* Filters: kind pills + result count + dropdowns + search. Wraps on
+ narrow windows: the bar sits above the scroll container, so anything
+ past the right edge would otherwise be clipped with no way to reach it. */}
+
{kinds.map((kind) => (
diff --git a/src/components/layout/__tests__/update-dialogs.test.tsx b/src/components/layout/__tests__/update-dialogs.test.tsx
new file mode 100644
index 00000000..a94b1525
--- /dev/null
+++ b/src/components/layout/__tests__/update-dialogs.test.tsx
@@ -0,0 +1,46 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { beforeEach, describe, expect, it } from "vitest";
+import { useUpdateStore } from "@/stores/update-store";
+import { useWebUpdateStore } from "@/stores/web-update-store";
+import { UpdateDialog } from "../update-dialog";
+import { WebUpdateDialog } from "../web-update-dialog";
+
+const available = { version: "9.9.9", body: "notes" };
+
+describe.each([
+ {
+ name: "UpdateDialog",
+ Dialog: UpdateDialog,
+ open: () => useUpdateStore.setState({ showChangelog: true, available }),
+ isOpen: () => useUpdateStore.getState().showChangelog,
+ },
+ {
+ name: "WebUpdateDialog",
+ Dialog: WebUpdateDialog,
+ open: () => useWebUpdateStore.setState({ showDialog: true, available }),
+ isOpen: () => useWebUpdateStore.getState().showDialog,
+ },
+])("$name keyboard handling", ({ Dialog, open, isOpen }) => {
+ beforeEach(() => open());
+
+ it("moves focus into the dialog and keeps Tab cycling inside it", async () => {
+ const user = userEvent.setup();
+ render();
+ const dialog = screen.getByRole("dialog");
+ expect(dialog.contains(document.activeElement)).toBe(true);
+
+ const focusable = dialog.querySelectorAll("button, a[href]");
+ focusable[focusable.length - 1].focus();
+ await user.tab();
+ expect(document.activeElement).toBe(focusable[0]);
+ });
+
+ it("closes on Escape", async () => {
+ const user = userEvent.setup();
+ render();
+ await user.keyboard("{Escape}");
+ expect(isOpen()).toBe(false);
+ expect(screen.queryByRole("dialog")).toBeNull();
+ });
+});
diff --git a/src/components/layout/update-dialog.tsx b/src/components/layout/update-dialog.tsx
index 04f6cb81..d0c98954 100644
--- a/src/components/layout/update-dialog.tsx
+++ b/src/components/layout/update-dialog.tsx
@@ -1,5 +1,8 @@
import { Download, Loader2, X } from "lucide-react";
+import { useRef } from "react";
import { useTranslation } from "react-i18next";
+import { useEscape } from "@/hooks/use-escape";
+import { useFocusTrap } from "@/hooks/use-focus-trap";
import { localizeChangelog } from "@/lib/i18n/changelog";
import { useUpdateStore } from "@/stores/update-store";
import { ChangelogMarkdown } from "./changelog-markdown";
@@ -13,7 +16,12 @@ export function UpdateDialog() {
const dismissUpdate = useUpdateStore((s) => s.dismissUpdate);
const confirmUpdate = useUpdateStore((s) => s.confirmUpdate);
- if (!showChangelog || !available) return null;
+ const open = showChangelog && available !== null;
+ const dlgRef = useRef(null);
+ useFocusTrap(dlgRef, open);
+ useEscape(dismissDialog, open);
+
+ if (!open) return null;
return (
@@ -23,6 +31,7 @@ export function WebUpdateDialog() {
-
-
+ {/* The step column is narrower than the full row; `justify-center` on
+ the wrapper lets the fixed-width row overflow it evenly on both sides. */}
+
);
})}
diff --git a/src/components/shared/agent-card.tsx b/src/components/shared/agent-card.tsx
index 982f1535..461ad1dd 100644
--- a/src/components/shared/agent-card.tsx
+++ b/src/components/shared/agent-card.tsx
@@ -24,7 +24,7 @@ const CLICK_DURATIONS: Partial> = {
openclaw: 3050,
// shake and wake scatter share one 1.6s track
penguin: 1650,
- // slide together, hold, puff away, pause and fade back: 3s plus the last leg's delay
+ // the tetris replay is one 3s clock: four pieces, flash, settle, pick-up
pi: 3200,
};
@@ -47,12 +47,15 @@ export function AgentCard({ agent }: AgentCardProps) {
>
{/* dsh dives below its box on hover; clip around the mascot (with
top headroom for the leap and the spout) so the whale vanishes
- above the label instead of sliding over the text */}
+ above the label instead of sliding over the text. pi's tetris
+ pieces fall in from the card's top edge, so clip there. */}
))}
- {PUFFS.map((p) => (
- (
+
))}
+ {/* Stroked like the blocks so it covers their seam strokes; a touch
+ translucent so on a light card the row reads as washed out
+ rather than gone. */}
+
);
}
diff --git a/src/pages/audit.tsx b/src/pages/audit.tsx
index b9b49cab..45a9dc62 100644
--- a/src/pages/audit.tsx
+++ b/src/pages/audit.tsx
@@ -13,6 +13,7 @@ import {
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate, useSearchParams } from "react-router-dom";
+import { extensionDisplayName } from "@/components/extensions/extension-name";
import { Hint } from "@/components/shared/hint";
import { TrustBadge } from "@/components/shared/trust-badge";
import { useScope } from "@/hooks/use-scope";
@@ -125,19 +126,7 @@ export default function AuditPage() {
const nameMap = useMemo(() => {
const map = new Map();
for (const ext of allExtensions) {
- let name = ext.name;
- if (ext.kind === "hook") {
- const parts = name.split(":");
- if (parts.length >= 3) {
- name = parts
- .slice(2)
- .join(":")
- .split(" ")
- .map((t) => t.split("/").pop() || t)
- .join(" ");
- }
- }
- map.set(ext.id, name);
+ map.set(ext.id, extensionDisplayName(ext.kind, ext.name));
}
return map;
}, [allExtensions]);
diff --git a/src/pages/extensions.tsx b/src/pages/extensions.tsx
index bcab48b3..ea7e0cc6 100644
--- a/src/pages/extensions.tsx
+++ b/src/pages/extensions.tsx
@@ -181,14 +181,14 @@ export default function ExtensionsPage() {