From a58a3984271ef9d1c3c24c480dbe676d82308d58 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 27 Sep 2026 04:15:08 +0200 Subject: [PATCH] feat(design): the three 375 rows (E28 S3f3) - Download row: answers to its own width (container query); below 40rem it stacks: picture beside a two-line title, the meta line across the row (one line, never wraps: zoomed on the iPad its pieces broke apart), then the source link and the actions on one wrapping line. The paste field takes a line of its own when the buttons would squeeze it. - YT playlist row: below 42rem the length rides on the picture and the title runs two lines over the channel. - Channels tag row: the suggestions take a line of their own below 40rem (the chip lane was 0 px and the row's menu off the screen). - Pager: on a phone the arrows drop their words (names kept), and the size label stays one line. - Lint: a class split where the interpolation is the variant itself (${NARROW}:w-24) is refused too; it had shipped with no CSS. --- frontend/eslint.config.js | 6 +- frontend/src/components/Channels.tsx | 17 ++- frontend/src/components/DownloadCenter.tsx | 5 +- .../src/components/Playlists.dom.test.tsx | 24 +++- .../channelsTagRow.contract.test.ts | 24 ++++ .../src/components/downloadRow.dom.test.tsx | 49 ++++++++ frontend/src/components/downloadRow.tsx | 118 +++++++++++------- frontend/src/components/playlistColumns.tsx | 62 ++++++--- frontend/src/components/ui/Pager.dom.test.tsx | 25 ++++ frontend/src/components/ui/Pager.tsx | 13 +- .../src/lib/controlRules.contract.test.ts | 9 ++ 11 files changed, 275 insertions(+), 77 deletions(-) create mode 100644 frontend/src/components/channelsTagRow.contract.test.ts diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 443bce18..8662f2a7 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -380,8 +380,10 @@ const EDGED_LABEL = "/^(?=.*(^|\\s)rounded-(full|md|lg|xl|2xl)(\\s|$))(?=.*(^|\\ const EDGED_LABEL_MESSAGE = "a static label with an edge reads as a pressable chip: use ui/Badge (flat, tonal) for a label that does nothing."; -// A class name split by an interpolation: the text right after `${…}` starts with a variant (`hover:`). -const SPLIT_CLASS = "/^[a-z][\\w-]*:[\\w\\[!-]/"; +// A class name split by an interpolation: the text right after `${…}` starts with a variant (`hover:`), +// or the interpolation IS the variant and the text goes on with its class (`${NARROW}:w-24`, E28 S3f3). +// A clock (`${m}:00`) and a URL (`${u}://`) are not classes. +const SPLIT_CLASS = "/^([a-z][\\w-]*:[\\w\\[!-]|:[a-z\\[!])/"; const SPLIT_CLASS_MESSAGE = "a class split by an interpolation (`${x}hover:…`): Tailwind never sees it whole and emits no CSS; write the class literally (a shared guard is the `act:` variant)."; diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index d5ea8171..b8e0c40f 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -585,11 +585,13 @@ export default function Channels() { {tabs} {view === "subscribed" && ( -
- {/* One non-wrapping row: the TAGS editor trigger (left) + the tag-filter chip lane (folds - overflow into "+N") + Clear, then the catalog "…" actions pinned right. It never wraps, so the - "…" menu always stays on the first line. Channel-name search lives in the shared top SearchBar. */} -
+
+ {/* One row: the TAGS editor trigger (left) + the tag-filter chip lane (folds overflow into + "+N") + Clear, then the catalog "…" actions pinned right, so the "…" menu always stays on the + first line. Only the suggestions block wraps: below 40rem it takes a line of its own under + the row (E28 S3f3, the owner's pick A) — at 375 it had squeezed the chip lane to 0 px and + pushed the "…" off the screen. Channel-name search lives in the shared top SearchBar. */} +
{/* The "TAGS" label IS the tags-editor trigger (pencil), replacing the separate "Manage tags" button; the tooltip explains what tags are + that it's clickable. */} @@ -617,7 +619,10 @@ export default function Channels() { one-click bulk-accept for the high-confidence ones. The per-row ghost chips handle the rest. Hidden when there's nothing to suggest. */} {visibleSuggestions.length > 0 && ( -
+
diff --git a/frontend/src/components/DownloadCenter.tsx b/frontend/src/components/DownloadCenter.tsx index 4eefc7e3..208b3d3f 100644 --- a/frontend/src/components/DownloadCenter.tsx +++ b/frontend/src/components/DownloadCenter.tsx @@ -161,9 +161,10 @@ export default function DownloadCenter() {
{tab === "queue" && ( <> -
+ {/* The field takes the whole line when the buttons would squeeze it (at 375 it had 57px). */} +
setAddUrl(e.target.value)} onFocus={() => pasteFromClipboard()} diff --git a/frontend/src/components/Playlists.dom.test.tsx b/frontend/src/components/Playlists.dom.test.tsx index ccde6e71..c0eab6d5 100644 --- a/frontend/src/components/Playlists.dom.test.tsx +++ b/frontend/src/components/Playlists.dom.test.tsx @@ -574,9 +574,27 @@ describe("Playlists · a narrow table", () => { el.classList.contains("hidden") && el.classList.contains("@2xl:table-cell"); const heads = [...document.querySelectorAll("thead th")]; const cells = [...document.querySelectorAll("tbody tr:first-child td")]; - // Columns: grip, #, title, channel, length, "…". - expect(heads.map(narrowHidden)).toEqual([false, true, false, true, false, false]); - expect(cells.map(narrowHidden)).toEqual([false, true, false, true, false, false]); + // Columns: grip, #, title, channel, length, "…". Length went too (E28 S3f3, the owner's pick A). + expect(heads.map(narrowHidden)).toEqual([false, true, false, true, true, false]); + expect(cells.map(narrowHidden)).toEqual([false, true, false, true, true, false]); + }); + + it("carries the hidden length on the picture and the channel under a two-line title", async () => { + mount({ 1: [vid("A", 0)] }); + const thumb = await screen.findByRole("button", { name: "card.play — Video A" }); + const narrowOnly = (el: Element | null): boolean => + !!el && (el.classList.contains("@2xl:hidden") || narrowOnly(el.parentElement)); + // 3000 s = 50:00, drawn on the picture only while the Length column is hidden. + const badge = + [...thumb.querySelectorAll("span")].find((e) => e.textContent === "50:00") ?? null; + expect(narrowOnly(badge)).toBe(true); + const cell = thumb.closest("td")!; + const title = [...cell.querySelectorAll("span")].find((e) => e.textContent === "Video A")!; + expect(title.className).toContain("line-clamp-2"); + expect(title.className).toContain("@2xl:line-clamp-1"); + const channel = + [...cell.querySelectorAll("span")].find((e) => e.textContent === "Channel") ?? null; + expect(narrowOnly(channel)).toBe(true); }); }); diff --git a/frontend/src/components/channelsTagRow.contract.test.ts b/frontend/src/components/channelsTagRow.contract.test.ts new file mode 100644 index 00000000..30d0ff47 --- /dev/null +++ b/frontend/src/components/channelsTagRow.contract.test.ts @@ -0,0 +1,24 @@ +// The Channels tag row at 375 (E28 S3f3): the suggestions block squeezed the tag-filter chip lane to +// 0 px and pushed the row's "…" off the screen. Below 40rem of its own container it now takes a line of +// its own under the row, which keeps the "…" on the first line. Channels.tsx needs the whole provider +// tree to render, so this holds the classes the layout rests on; the widths were measured in a browser. +import { readFileSync } from "node:fs"; +import { describe, it, expect } from "vitest"; + +const src = readFileSync(new URL("./Channels.tsx", import.meta.url), "utf8"); + +describe("the Channels tag row", () => { + it("is a size container whose row may wrap", () => { + expect(src).toContain('className="@container/tagrow '); + expect(src).toMatch(/className="flex flex-wrap items-center gap-2 min-w-0"/); + }); + + it("sends only the suggestions block to a line of its own when narrow", () => { + const block = src.slice( + src.indexOf("data-tag-suggestions"), + src.indexOf("data-tag-suggestions") + 400, + ); + expect(block).toContain("@max-[40rem]/tagrow:order-last"); + expect(block).toContain("@max-[40rem]/tagrow:basis-full"); + }); +}); diff --git a/frontend/src/components/downloadRow.dom.test.tsx b/frontend/src/components/downloadRow.dom.test.tsx index 4fb6e77a..f8bbe355 100644 --- a/frontend/src/components/downloadRow.dom.test.tsx +++ b/frontend/src/components/downloadRow.dom.test.tsx @@ -155,3 +155,52 @@ describe("DownloadRow · keyboard", () => { expect(container.innerHTML).not.toContain("[&_button]"); }); }); + +// E28 S3f3: the row answers to its own width (a container query) and its meta line never wraps — on +// the iPad, zoomed in, its "18.9 / MB" pieces broke into a jumble (the owner, 2026-09-27). jsdom lays +// nothing out, so this holds the structure the layout rests on; the widths were measured in a browser. +describe("DownloadRow · narrow", () => { + it("keeps the meta line one cut line of inline text, not a flex row that wraps its pieces", () => { + const { container } = render( + , + ); + const meta = container.querySelector("[data-dl-meta]")!; + expect(meta.className.split(/\s+/)).toContain("truncate"); + expect(meta.className).not.toMatch(/(^|\s)(inline-)?flex(\s|$)/); + for (const el of meta.querySelectorAll("*")) + expect(el.className.toString()).not.toMatch(/(^|\s)(inline-)?flex(\s|$)/); + }); + + it("is its own size container and stacks below 40rem", () => { + const { container } = render(); + const row = container.firstElementChild as HTMLElement; + expect(row.className).toContain("@container/dlrow"); + const grid = row.firstElementChild as HTMLElement; + expect(grid.className).toContain( + "@max-[40rem]/dlrow:[grid-template-areas:'thumb_head''meta_meta''foot_foot''prog_prog']", + ); + }); + + it("stacked, puts the source link and the actions on ONE wrapping line, not a line each", () => { + const { container } = render( + + {}} title="play"> + x + + , + ); + const link = container.querySelector("a[href='https://example.com/v']")!; + const foot = screen.getByTitle("play").closest(".contents")!; + expect(foot.contains(link)).toBe(true); + for (const c of ["@max-[40rem]/dlrow:[grid-area:foot]", "@max-[40rem]/dlrow:flex-wrap"]) + expect(foot.className).toContain(c); + }); + + it("draws nothing for an empty link list (a bare 0 once leaked into the row)", () => { + const { container } = render(); + const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT); + const texts: string[] = []; + while (walker.nextNode()) texts.push(walker.currentNode.textContent ?? ""); + expect(texts).not.toContain("0"); + }); +}); diff --git a/frontend/src/components/downloadRow.tsx b/frontend/src/components/downloadRow.tsx index 84558283..3fcd4e72 100644 --- a/frontend/src/components/downloadRow.tsx +++ b/frontend/src/components/downloadRow.tsx @@ -65,6 +65,14 @@ export function StatusPill({ job }: { job: DownloadJob }) { ); } +// The row answers to its OWN width (a container query), not the window's: the same list sits in a +// phone, a split-screen tablet and a desktop column. Below 40rem the actions no longer leave the title +// column room for the meta line, so the row stacks (E28 S3f3, the owner's pick A): the picture beside a +// two-line title and the channel; the meta line across the whole row; then the source link and the +// actions on one wrapping line. +const THUMB_BOX = + "w-28 @max-[40rem]/dlrow:w-24 aspect-video shrink-0 rounded-lg overflow-hidden bg-surface border border-border"; + function Thumb({ job }: { job: DownloadJob }) { // Prefer OUR self-hosted poster: it's always present on disk, never expires, and is same-origin, so // a source-CDN signature expiry (Facebook's fbcdn URLs expire within days) or an ad-blocker/Shields @@ -91,13 +99,13 @@ function Thumb({ job }: { job: DownloadJob }) { // archive, not playable media. Show a neutral file glyph instead of an (always-empty) thumbnail. if (job.asset?.media_kind === "file") { return ( -
+
); } return ( -
+
{src ? ( e.stopPropagation() function SourceRef({ url }: { url: string }) { const { t } = useTranslation(); return ( -
+
- -
-
- {job.job_kind === "edit" && ( - - {t("downloads.clipBadge")} - - )} - {jobTitle(job)} +
+
+
- -
+
+
+ {job.job_kind === "edit" && ( + + {t("downloads.clipBadge")} + + )} + {jobTitle(job)} +
+ +
+ {/* The meta line never wraps (the owner, 2026-09-27: on the iPad its "18.9 / MB" pieces broke + into a jumble). One line, cut at its end, so the least telling part (the age) goes first. */} +
{job.asset?.container ? ( // Concrete container (mp4 / m4a / webm …) — reads as the file format, and its audio-vs-video // extension is the video/audio distinction the running-only phase pill can't show at rest. - · {job.asset.container} + · {job.asset.container} ) : null} {job.asset?.size_bytes ? ( - · {formatBytes(job.asset.size_bytes)} + · {formatBytes(job.asset.size_bytes)} ) : null} {job.asset?.duration_s ? ( - · {formatDuration(job.asset.duration_s)} + · {formatDuration(job.asset.duration_s)} ) : null} {job.status === "done" && job.created_at ? ( - - · {relativeTime(job.created_at)} + + {" · "} + {" "} + {relativeTime(job.created_at)} ) : null} {job.status === "error" ? ( - onShowError?.(job)} - className="text-danger inline-flex min-h-6 items-center gap-1 truncate touch-target" - title={t("downloads.errors.fail.title")} - > - · {t(failCopyKeys(job, "short"))} - {t("downloads.errors.fail.detailsLink")} - + <> + {" "} + onShowError?.(job)} + className="text-danger inline-block leading-5 min-h-5 touch-target pointer-coarse:leading-(--touch-target)" + title={t("downloads.errors.fail.title")} + > + · {t(failCopyKeys(job, "short"))}{" "} + + {t("downloads.errors.fail.detailsLink")} + + + ) : null}
- {job.source_url && } - {job.extra_links?.map((url) => ( - - ))} + {/* Wide, this wrapper is `contents`: the source and the actions are grid items of their own + (src under the meta, act down the right). Stacked, it is ONE wrapping line: the link and + the actions side by side while they fit, the actions under the link only when they don't + (at 375). On touch each is a 44px box, so separate lines stacked 44px gaps (the owner, + 2026-09-27: "what are those huge empty parts?"). */} +
+ {(!!job.source_url || !!job.extra_links?.length) && ( +
+ {job.source_url && } + {job.extra_links?.map((url) => ( + + ))} +
+ )} + {/* The row's visible actions — its primary one and the save link — then its "…": the md + rung here (the row stands alone), the sm rung in the modals, one IconBtn for both. */} +
+ {children} + {more} +
+
{running && ( -
+
{job.phase && !DOWNLOAD_PHASES.includes(job.phase) ? ( // ffmpeg post-steps aren't byte-progress — show an indeterminate pulse, no %.
@@ -304,13 +343,6 @@ export function DownloadRow({
)}
- {/* The row's visible actions — its primary one and the save link — then its "…": the md rung - here (the row stands alone), the sm rung in the modals, one IconBtn for both. `shrink-0` + no - wrap keeps them on the title's line; the text column shrinks instead. */} -
- {children} - {more} -
); } diff --git a/frontend/src/components/playlistColumns.tsx b/frontend/src/components/playlistColumns.tsx index 7e0c09ac..93cb9f26 100644 --- a/frontend/src/components/playlistColumns.tsx +++ b/frontend/src/components/playlistColumns.tsx @@ -69,6 +69,11 @@ export function playlistColumns({ {v.thumbnail_url && ( )} + {/* Narrow, the Length column is gone and its value rides on the picture, as on the + media card (E28 S3f3, the owner's pick A). */} + + {lengthCell(v, t, true)} + @@ -80,7 +85,16 @@ export function playlistColumns({ data-nav-open="" className="min-h-6 min-w-0 flex-1 touch-target" > - {v.title} + {/* Two lines and the channel under them while the Channel column is hidden (the owner, + 2026-09-26: at 375 the one-line title kept ~40px); one line beside the columns. */} + + {v.title} + + {v.channel_title && ( + + {v.channel_title} + + )}
), @@ -103,28 +117,15 @@ export function playlistColumns({ header: t("playlists.colDuration"), align: "right", width: "5rem", + // Narrow, the length rides on the picture instead (the title cell's badge). + narrowHidden: true, nowrap: true, sortable: true, // Nulls (live/upcoming, or not yet enriched) sort last in BOTH directions in the old // comparator; sortRows is a plain numeric compare, so park them at the end of an ascending // sort with a large sentinel rather than letting them read as zero-length. sortValue: (v) => v.duration_seconds ?? Number.MAX_SAFE_INTEGER, - render: (v) => - v.duration_seconds != null ? ( - - {formatDuration(v.duration_seconds)} - - ) : v.live_status === "live" || v.live_status === "upcoming" ? ( - - {t(`card.${v.live_status}`)} - - ) : null, + render: (v) => lengthCell(v, t), }, ]; @@ -142,3 +143,30 @@ export function playlistColumns({ return cols; } + +// A video's length, or its live / upcoming state when it has none. `onPicture`: the dark badge on the +// thumbnail (the narrow row), readable on any frame, like the media card's length badge. +function lengthCell(v: Video, t: TFunction, onPicture = false): ReactNode { + if (v.duration_seconds != null) + return onPicture ? ( + + {formatDuration(v.duration_seconds)} + + ) : ( + {formatDuration(v.duration_seconds)} + ); + if (v.live_status !== "live" && v.live_status !== "upcoming") return null; + return ( + + {t(`card.${v.live_status}`)} + + ); +} diff --git a/frontend/src/components/ui/Pager.dom.test.tsx b/frontend/src/components/ui/Pager.dom.test.tsx index 92f9d550..d364a5e4 100644 --- a/frontend/src/components/ui/Pager.dom.test.tsx +++ b/frontend/src/components/ui/Pager.dom.test.tsx @@ -110,3 +110,28 @@ describe("Pager · the page box follows the page without an effect", () => { expect(box).toHaveProperty("value", "3"); }); }); + +// E28 S3f3: on a phone the row ran 18px past a 375 screen. The arrows keep their names (a screen +// reader still hears Previous / Next) and only the visible words step aside below `sm`; the size label +// stays one line rather than a word per line. +describe("Pager · on a phone", () => { + it("hides only the visible words of Previous / Next, never their names", () => { + render( + {}} + onSizeChange={() => {}} + sizeLabel="Rows per page" + />, + ); + for (const name of ["datatable.pager.prev", "datatable.pager.next"]) { + const btn = screen.getByRole("button", { name }); + const word = [...btn.querySelectorAll("span")].find((s) => s.textContent === name)!; + expect(word.className.split(/\s+/)).toContain("max-sm:sr-only"); + } + expect(screen.getByText("Rows per page").className).toContain("whitespace-nowrap"); + }); +}); diff --git a/frontend/src/components/ui/Pager.tsx b/frontend/src/components/ui/Pager.tsx index 65b642ab..c60233d0 100644 --- a/frontend/src/components/ui/Pager.tsx +++ b/frontend/src/components/ui/Pager.tsx @@ -74,7 +74,9 @@ export default function Pager({ onClick={() => goToPage(Math.max(0, safePage - 1))} disabled={safePage === 0} > - {t("datatable.pager.prev")} + {/* A phone keeps the arrows and the page box: the two words pushed the row 18px past a + 375 screen (E28 S3f3). The name stays for a screen reader. */} + {t("datatable.pager.prev")} {t("datatable.pager.pageLabel")} @@ -98,14 +100,17 @@ export default function Pager({ onClick={() => goToPage(Math.min(totalPages - 1, safePage + 1))} disabled={safePage >= totalPages - 1} > - {t("datatable.pager.next")} + {t("datatable.pager.next")}
)} {total > 0 && ( -
- + // The label never breaks into a word per line: short of room it goes above the sizes. +
+ {/* A handful of fixed sizes plus "All": segmented, the house control for a short fixed set. */} { const lint = async (cls: string) => ( await eslint.lintText( + // eslint-disable-next-line no-restricted-syntax -- probe SOURCE text handed to ESLint, not a class `export const c = "p-1 ${cls}:text-fg"; `, { @@ -49,6 +50,14 @@ describe("a class split by an interpolation", () => { expect(await split("const A = 'x';\nexport const b = `${A}" + "hover:text-fg`;\n")).toBe(1); expect(await split("const A = 'x';\nexport const c = `p-1 ${A}" + act + "hover:x`;\n")).toBe(1); }); + it("is refused when the interpolation is the variant itself", async () => { + expect( + await split("const N = '@max-[40rem]/r';\nexport const f = `w-28 ${N}" + ":w-24`;\n"), + ).toBe(1); + }); + it("leaves a clock alone", async () => { + expect(await split("const m = 1;\nexport const g = `${m}:00 ${m}:${m}`;\n")).toBe(0); + }); it("leaves a whole class, a leading variant and a plain value alone", async () => { expect(await split("const n = 1;\nexport const d = `hover:text-fg w-${n} ${n}px`;\n")).toBe(0); expect(