diff --git a/frontend/src/components/ui/CommandPalette.tsx b/frontend/src/components/ui/CommandPalette.tsx
index b5211f36..e0786f03 100644
--- a/frontend/src/components/ui/CommandPalette.tsx
+++ b/frontend/src/components/ui/CommandPalette.tsx
@@ -173,7 +173,7 @@ function CommandPaletteBody({ onClose, groups }: { onClose: () => void; groups:
const from = Math.max(activeIndex, 0);
setPicked(flat[(from + step + flat.length) % flat.length]!.id);
} else if (e.key === "PageDown" || e.key === "PageUp") {
- // A page at a time, stopping at the ends (lib/listPaging). The list is 50vh and group headings
+ // A page at a time, stopping at the ends (lib/listPaging). The list is 50dvh and group headings
// eat into it, so the page is measured. Home/End stay the field's own (caret to the query's ends).
e.preventDefault();
const list = listRef.current;
@@ -230,7 +230,7 @@ function CommandPaletteBody({ onClose, groups }: { onClose: () => void; groups:
role="listbox"
aria-label={t("palette.title")}
hidden={flat.length === 0}
- className="mt-3 max-h-[50vh] overflow-y-auto"
+ className="mt-3 max-h-[50dvh] overflow-y-auto"
>
{visible.map((g) => (
{
diff --git a/frontend/src/components/ui/tableHeader.tsx b/frontend/src/components/ui/tableHeader.tsx
index 46012e41..485f0d80 100644
--- a/frontend/src/components/ui/tableHeader.tsx
+++ b/frontend/src/components/ui/tableHeader.tsx
@@ -211,7 +211,7 @@ export function ColumnHead({
const [openFilter, setOpenFilter] = useState(null);
return (
-
+
{columns.map((col) => {
const isSorted = sort?.key === col.key;
diff --git a/frontend/src/lib/fadeEdge.contract.test.ts b/frontend/src/lib/fadeEdge.contract.test.ts
new file mode 100644
index 00000000..6762bc49
--- /dev/null
+++ b/frontend/src/lib/fadeEdge.contract.test.ts
@@ -0,0 +1,30 @@
+import { readdirSync, readFileSync } from "node:fs";
+
+import { describe, expect, it } from "vitest";
+
+// E28 S3d4 (B9). The header + fade edge is one recipe, the `fade-below` / `fade-below-panel` /
+// `fade-above-panel` utilities in index.css: a crisp band and a 1rem gradient of the colour under
+// it. The table header carried its own arbitrary-value copy of it, which a change to the recipe (its
+// height, its colour on a panel) would have left behind.
+function sources(): Array<[string, string]> {
+ const walk = (u: URL, prefix: string): Array<[string, string]> =>
+ readdirSync(u, { withFileTypes: true }).flatMap((e) => {
+ if (e.isDirectory()) return walk(new URL(`${e.name}/`, u), `${prefix}${e.name}/`);
+ if (!/\.tsx?$/.test(e.name) || e.name.includes(".test.")) return [];
+ return [
+ [`${prefix}${e.name}`, readFileSync(new URL(e.name, u), "utf-8")] as [string, string],
+ ];
+ });
+ return walk(new URL("../", import.meta.url), "");
+}
+
+describe("the fade edge", () => {
+ it("is the utility, never an arbitrary gradient of a surface colour", () => {
+ const copies = sources()
+ .filter(([, src]) =>
+ /linear-gradient\(to_(?:bottom|top),var\(--(?:bg|surface|card)\)/.test(src),
+ )
+ .map(([name]) => name);
+ expect(copies).toEqual([]);
+ });
+});
diff --git a/frontend/src/lib/viewportUnits.contract.test.ts b/frontend/src/lib/viewportUnits.contract.test.ts
new file mode 100644
index 00000000..720b1bb5
--- /dev/null
+++ b/frontend/src/lib/viewportUnits.contract.test.ts
@@ -0,0 +1,33 @@
+import { readdirSync, readFileSync } from "node:fs";
+
+import { describe, expect, it } from "vitest";
+
+// E28 S3d4 (E18.5's `h-screen` → `dvh`). `100vh` is the viewport with a mobile browser's toolbars
+// RETRACTED: on a tablet with the toolbar showing, an `h-screen` shell runs its bottom edge (the
+// last rows, a dialog's footer) under the toolbar. `dvh` follows the toolbar. A static `vh` size is
+// the same trap at a smaller scale, so the app sizes by `dvh` everywhere.
+function sources(): Array<[string, string]> {
+ const walk = (u: URL, prefix: string): Array<[string, string]> =>
+ readdirSync(u, { withFileTypes: true }).flatMap((e) => {
+ if (e.isDirectory()) return walk(new URL(`${e.name}/`, u), `${prefix}${e.name}/`);
+ if (!/\.tsx?$/.test(e.name) || e.name.includes(".test.")) return [];
+ return [
+ [`${prefix}${e.name}`, readFileSync(new URL(e.name, u), "utf-8")] as [string, string],
+ ];
+ });
+ return walk(new URL("../", import.meta.url), "");
+}
+
+// The Plex player's stage is sized with the 80vh × 1.25 zoom trick. It changes in S3f, where the
+// players are re-fitted for touch and verified by playing them on the tablet.
+const PENDING_S3F = new Set(["components/PlexPlayer.tsx"]);
+
+describe("viewport units", () => {
+ it("are dynamic: no h-screen / min-h-screen, no static vh", () => {
+ const hits = sources()
+ .filter(([name]) => !PENDING_S3F.has(name))
+ .filter(([, src]) => /\b(?:min-|max-)?h-screen\b|\d(?:\.\d+)?vh\b/.test(src))
+ .map(([name]) => name);
+ expect(hits).toEqual([]);
+ });
+});
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index 08db0e45..fac38dd9 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -121,7 +121,7 @@ createRoot(document.getElementById("root")!).render(
{/* Around EVERY entry, the public ones too: a crash on a shared /watch link, or its chunk gone
twice over after a release (lib/staleBuild), left a blank white page with no way on. */}
- }>{root}
+ }>{root}
,
);