fix(design): the table header takes the B9 fade utility, and every size is dvh, not vh (E28 S3d4)

The table header carried an arbitrary-value copy of fade-below; a contract refuses a hand gradient of a surface colour. 100vh is the viewport with a tablet browser's toolbar retracted, so h-screen / min-h-screen / static vh hid the last rows under it; a contract holds dvh (the Plex player stage changes with S3f, where it is played on the tablet).
This commit is contained in:
2026-09-26 00:39:10 +02:00
parent eddeca82d3
commit 4646aae320
15 changed files with 80 additions and 17 deletions
+2 -2
View File
@@ -30,7 +30,7 @@ import { LoadingState, StateMessage } from "./ui/QueryState";
* Suspense fallback after it and the first page's own loading state keep one clock (see LoadingState). */
export function BootScreen() {
return (
<div className="min-h-screen grid place-items-center">
<div className="min-h-dvh grid place-items-center">
<LoadingState since={0} delayMs={400} />
</div>
);
@@ -121,7 +121,7 @@ export default function BootGate({ children }: { children: ReactNode }) {
: null;
if (failed)
return (
<div className="min-h-screen grid place-items-center">
<div className="min-h-dvh grid place-items-center">
<StateMessage tone="error" onRetry={() => failed.refetch()}>
{t("common.somethingWrong")}
</StateMessage>
+1 -1
View File
@@ -41,7 +41,7 @@ export default class ErrorBoundary extends Component<Props, State> {
if (this.state.crashed) {
if (reloadingForNewBuild()) return null;
return (
<div className="min-h-screen grid place-items-center text-muted p-6 text-center">
<div className="min-h-dvh grid place-items-center text-muted p-6 text-center">
<div>
<div className="text-lg font-semibold text-fg mb-1">
{i18n.t("errors.boundary.title")}
+2 -2
View File
@@ -756,7 +756,7 @@ function ReviewList({
{t("recorder.composer.reviewDone")}
</Button>
</div>
<ul className="max-h-[60vh] overflow-y-auto rounded-xl border border-border bg-bg/60 font-mono text-xs">
<ul className="max-h-[60dvh] overflow-y-auto rounded-xl border border-border bg-bg/60 font-mono text-xs">
{rows.map((row) => {
const tag = LEVEL_TAG[row.level];
const off = excluded.has(row.id);
@@ -813,7 +813,7 @@ function IssuePreview({
return (
<div className="flex flex-col gap-1.5">
<p className="text-xs text-muted">{t("recorder.composer.previewLocal")}</p>
<pre className="max-h-[60vh] overflow-auto whitespace-pre-wrap rounded-xl border border-border bg-bg/60 p-3 font-mono text-xs">
<pre className="max-h-[60dvh] overflow-auto whitespace-pre-wrap rounded-xl border border-border bg-bg/60 p-3 font-mono text-xs">
{text}
</pre>
</div>
+1 -1
View File
@@ -63,7 +63,7 @@ export default function LibraryPlayer({ job, onClose }: { job: DownloadJob; onCl
controls
autoPlay
playsInline
className="w-full max-h-[70vh] rounded-lg bg-black object-contain"
className="w-full max-h-[70dvh] rounded-lg bg-black object-contain"
>
{t("downloads.player.unsupported")}
</video>
+1 -1
View File
@@ -141,7 +141,7 @@ export default function PlexSearchBox() {
id={listId}
role="listbox"
aria-label={t("plex.suggest.aria")}
className={`${SURFACE.menu} absolute left-0 right-0 top-full z-menu mt-2 max-h-[70vh] overflow-y-auto animate-[popIn_0.16s_ease]`}
className={`${SURFACE.menu} absolute left-0 right-0 top-full z-menu mt-2 max-h-[70dvh] overflow-y-auto animate-[popIn_0.16s_ease]`}
>
{suggestions.length === 0 && suggestQ.isFetching && (
<div className="px-3 py-2 text-sm text-muted">{t("plex.loading")}</div>
+1 -1
View File
@@ -305,7 +305,7 @@ export default function SetupWizard() {
}
function Centered({ children }: { children: React.ReactNode }) {
return <div className="min-h-screen bg-bg text-fg grid place-items-center p-4">{children}</div>;
return <div className="min-h-dvh bg-bg text-fg grid place-items-center p-4">{children}</div>;
}
function Section({
+1 -1
View File
@@ -395,7 +395,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
onTimeUpdate={onTimeUpdate}
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
className="w-full max-h-[42vh] mx-auto block object-contain"
className="w-full max-h-[42dvh] mx-auto block object-contain"
playsInline
/>
{cropOn && picRect && (
+2 -2
View File
@@ -70,7 +70,7 @@ export default function Welcome({ verify }: { verify: VerifyEntry }) {
const [zoomed, setZoomed] = useState<{ src: string; label: string } | null>(null);
const open = (src: string, label: string) => setZoomed({ src, label });
return (
<div className="min-h-screen bg-bg text-fg overflow-y-auto">
<div className="min-h-dvh bg-bg text-fg overflow-y-auto">
<header className="flex items-center justify-between px-5 sm:px-8 py-4">
<div className="text-xl font-bold tracking-tight">
Sift<span className="text-accent">lode</span>
@@ -324,7 +324,7 @@ function Lightbox({ src, label, onClose }: { src: string; label: string; onClose
<img
src={src}
alt={label}
className="max-h-[82vh] max-w-[92vw] w-auto h-auto rounded-xl border border-border object-contain shadow-2xl"
className="max-h-[82dvh] max-w-[92vw] w-auto h-auto rounded-xl border border-border object-contain shadow-2xl"
/>
<figcaption className="text-sm text-muted">{label}</figcaption>
</figure>
@@ -78,7 +78,7 @@ export default function LegalLayout({ title, children }: { title: string; childr
document.title = `${title} · Siftlode`;
}, [title]);
return (
<div className="min-h-screen bg-bg text-fg">
<div className="min-h-dvh bg-bg text-fg">
<div className="mx-auto w-[min(92vw,720px)] py-12">
<div className="mb-8 flex items-center justify-between gap-4">
<a href="/" className="inline-block text-2xl font-bold tracking-tight">
@@ -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) => (
<div
+1 -1
View File
@@ -97,7 +97,7 @@ export default function Modal({
drawer
? "justify-end"
: top
? "items-start justify-center p-4 pt-[8vh] sm:p-6 sm:pt-[10vh]"
? "items-start justify-center p-4 pt-[8dvh] sm:p-6 sm:pt-[10dvh]"
: "items-center justify-center p-4 sm:p-6"
}`}
onMouseDown={(e) => {
+1 -1
View File
@@ -211,7 +211,7 @@ export function ColumnHead<T>({
const [openFilter, setOpenFilter] = useState<string | null>(null);
return (
<thead className="sticky top-0 z-base after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-4 after:bg-[linear-gradient(to_bottom,var(--bg),transparent)] after:content-['']">
<thead className="sticky top-0 z-base fade-below">
<tr className="text-muted [&>th]:shadow-[inset_0_-1px_0_var(--border)]">
{columns.map((col) => {
const isSorted = sort?.key === col.key;
@@ -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([]);
});
});
@@ -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([]);
});
});
+1 -1
View File
@@ -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. */}
<ErrorBoundary>
<Suspense fallback={<div style={{ minHeight: "100vh" }} />}>{root}</Suspense>
<Suspense fallback={<div style={{ minHeight: "100dvh" }} />}>{root}</Suspense>
</ErrorBoundary>
</React.StrictMode>,
);