diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 69bbe884..c55b675c 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -353,7 +353,10 @@ export default function App() {
)}
{/* `@container/appmain`: the floating header + its search box adapt to THIS column's width
(which shrinks when the filter sidebar is open), not the viewport — a viewport breakpoint
- can't see the sidebar-driven narrowing (E5 S7). */}
+ can't see the sidebar-driven narrowing (E5 S7). NB: `container-type: inline-size` implies
+ `contain: layout`, making this column a containing block for `position: fixed` descendants
+ too — safe today because floating-ui menus render via FloatingPortal (to document.body) and
+ the toasts below are absolute; keep any new fixed overlay inside here body-portalled. */}
{channelView ? (
diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx
index 3d2498a1..f607e265 100644
--- a/frontend/src/components/Header.tsx
+++ b/frontend/src/components/Header.tsx
@@ -1,4 +1,4 @@
-import { type ReactNode } from "react";
+import { useLayoutEffect, useRef, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Youtube } from "lucide-react";
import type { Me } from "../lib/api";
@@ -46,6 +46,27 @@ export default function Header({
// Drop input focus on Go/Enter for the live-filter modules (feed's Go escalates to YouTube).
const blur = () => (document.activeElement as HTMLElement | null)?.blur?.();
+ // The header floats (absolute) over the content, which pads its top by `--hdr-h`. That default is
+ // sized for a single row; on a narrow window (esp. mobile) the flex-wrap header grows to 2–3 rows,
+ // so a FIXED pad would let the content toolbar (sort / count / filter chips) slide up under it.
+ // Measure the real header box and publish its height as `--hdr-h` on the content column, so the pad
+ // always tracks the actual (wrapped) header height at any width.
+ const headerRef = useRef(null);
+ useLayoutEffect(() => {
+ const el = headerRef.current;
+ const host = el?.parentElement; // the `relative` content column (PageShell renders a fragment)
+ if (!el || !host) return;
+ const apply = () =>
+ host.style.setProperty("--hdr-h", `${el.offsetTop + el.offsetHeight + 8}px`);
+ apply();
+ const ro = new ResizeObserver(apply);
+ ro.observe(el);
+ return () => {
+ ro.disconnect();
+ host.style.removeProperty("--hdr-h");
+ };
+ }, []);
+
// Per-page SearchBar wiring — null for modules without a search.
let search: ReactNode = null;
if (page === "feed") {
@@ -94,7 +115,10 @@ export default function Header({
// Absolutely positioned within the (relative) content column, so its left edge tracks the
// actual left zone: it sits just inside the content, reclaiming the space when the nav rail is
// slim and/or no filter sidebar is present — no hard-coded offset.
-
+
{
+ const actual = await importOriginal();
+ return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
+});
+
+describe("SearchBar responsive wrapper (E5 S7)", () => {
+ it("carries the container-query classes that make it wrap instead of collapsing", () => {
+ const { container } = render(
+ {}} placeholder="Search…" />,
+ );
+ const cls = (container.firstElementChild as HTMLElement).className;
+ // A usable min-width floor once the column has room → the box wraps via flex-wrap, never ~0:
+ expect(cls).toContain("@min-[420px]/appmain:min-w-[15rem]");
+ // On a narrow column it takes a full line of its own and sorts last (small pills share the row above):
+ expect(cls).toContain("@max-[640px]/appmain:basis-full");
+ expect(cls).toContain("@max-[640px]/appmain:order-last");
+ });
+});
diff --git a/frontend/src/components/SearchBar.tsx b/frontend/src/components/SearchBar.tsx
index 364bee6a..dd8b7a0d 100644
--- a/frontend/src/components/SearchBar.tsx
+++ b/frontend/src/components/SearchBar.tsx
@@ -55,12 +55,13 @@ export default function SearchBar({
const fire = () => {
if (onGo && !goDisabled) onGo();
};
+ // Responsive rules on the SHARED search box (every module's header, not just Plex), keyed on the
+ // content column's width via `@container/appmain` (E5 S7): `min-w-0` by default so it never forces
+ // overflow on a truly tiny column; once the column has room (≥420px) a `min-w-[15rem]` floor makes
+ // the box WRAP to its own line via the header's flex-wrap instead of shrinking the input to ~0; on
+ // a narrow column (≤640px) it also takes a full line of its own (basis-full) and sorts LAST, so the
+ // small pills (module name + Plex scope toggle / feed sync chip) share the line above it.
return (
- // `min-w-0` by default so it never forces overflow on a truly tiny column; once the content
- // column (@container/appmain) has room (≥420px) enforce a usable floor so the box WRAPS to its
- // own line via the header's flex-wrap instead of shrinking the input to ~0. On a narrow column
- // (≤640px) it also takes a full line of its own (basis-full) and sorts LAST, so the small pills
- // (module name + Plex scope toggle) share the line above it — a tidy two-row header (E5 S7).