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:
@@ -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>
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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>,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user