diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 0547fb7..5745104 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -28,6 +28,7 @@ import { getOidcConfig, isOidcConfigured, setAccessToken } from "./auth";
import { fetchAppVersion } from "./api/client";
import { FRONTEND_VERSION_LABEL } from "./version";
import { usePersistentState } from "./hooks/usePersistentState";
+import { useIsMobile } from "./hooks/useIsMobile";
import { Button } from "@/components/ui/button";
import {
Tooltip,
@@ -316,16 +317,7 @@ function ShellLayout({
onToggleDarkMode: () => void;
}) {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
- const [isMobile, setIsMobile] = useState(
- () => window.matchMedia("(max-width: 768px)").matches,
- );
-
- useEffect(() => {
- const mql = window.matchMedia("(max-width: 768px)");
- const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches);
- mql.addEventListener("change", handler);
- return () => mql.removeEventListener("change", handler);
- }, []);
+ const isMobile = useIsMobile();
return (
diff --git a/frontend/src/components/HoverEditButton.tsx b/frontend/src/components/HoverEditButton.tsx
index bf693fd..324260a 100644
--- a/frontend/src/components/HoverEditButton.tsx
+++ b/frontend/src/components/HoverEditButton.tsx
@@ -4,26 +4,48 @@ import { Button } from "@/components/ui/button";
interface HoverEditButtonProps {
onClick: () => void;
label?: string;
+ /** Controls visibility below the `md:` (768px) breakpoint.
+ *
+ * - `always` (default): the button is always visible on mobile/touch.
+ * - `hover`: keep the legacy opacity-0-everywhere behavior.
+ *
+ * At `md:` and above the hover-reveal aesthetic is always preserved
+ * (`md:opacity-0 md:group-hover:opacity-100`), so desktop is not regressed.
+ * See OpenSpec change `mobile-responsive-parity`, spec R5. */
+ mobile?: "always" | "hover";
}
/**
- * Hover-to-reveal edit affordance.
+ * Hover-to-reveal edit affordance (desktop) / always-visible (mobile).
*
- * Keeps the `rail-edit` class plus the opacity-0 base + transition so the
+ * Keeps the `rail-edit` class plus the opacity base + transition so the
* existing hover-reveal rules in consuming pages (Actions, Settings) still
* target it (`&:hover .rail-edit { opacity: 1 }`) until those pages migrate.
- * MUI IconButton + EditOutlined → shadcn `Button variant="ghost" size="icon-sm"`
- * + lucide `Pencil`. Same exported props/display name.
+ *
+ * Mobile behavior (`mobile="always"`, the default): the button is visible by
+ * default below `md` because hover does not fire on touch. The hover-reveal
+ * aesthetic is layered back on at `md:` and above via `md:opacity-0
+ * md:group-hover:opacity-100`. MUI IconButton + EditOutlined → shadcn `Button
+ * variant="ghost" size="icon-sm"` + lucide `Pencil`. Same exported props/display
+ * name. See OpenSpec change `mobile-responsive-parity`, spec R5.
*/
export function HoverEditButton({
onClick,
label = "Edit",
+ mobile = "always",
}: HoverEditButtonProps) {
+ // Legacy mode: opacity-0 everywhere, revealed by group hover (the consuming
+ // row supplies `group`).
+ const hoverClasses =
+ mobile === "hover"
+ ? "opacity-0 transition-opacity duration-100 ease-out group-hover:opacity-100"
+ : "md:opacity-0 md:transition-opacity md:duration-100 md:ease-out md:group-hover:opacity-100";
+
return (