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.
This commit is contained in:
2026-09-27 04:15:08 +02:00
parent d510c8a534
commit a58a398427
11 changed files with 275 additions and 77 deletions
+4 -2
View File
@@ -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).";
+11 -6
View File
@@ -585,11 +585,13 @@ export default function Channels() {
<PageToolbar>
{tabs}
{view === "subscribed" && (
<div className="px-4 pt-2 pb-2 max-w-384 mx-auto">
{/* 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. */}
<div className="flex items-center gap-2 min-w-0">
<div className="@container/tagrow px-4 pt-2 pb-2 max-w-384 mx-auto">
{/* 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. */}
<div className="flex flex-wrap items-center gap-2 min-w-0">
{/* 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. */}
<Tooltip hint={t("channels.tags.yourTagsHint")}>
@@ -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 && (
<div className="shrink-0 flex items-center gap-1.5">
<div
data-tag-suggestions
className="shrink-0 flex items-center gap-1.5 @max-[40rem]/tagrow:order-last @max-[40rem]/tagrow:basis-full"
>
<Tooltip hint={t("channels.tags.suggestionsHint")}>
<Badge tone="accent">
<Sparkles />
+3 -2
View File
@@ -161,9 +161,10 @@ export default function DownloadCenter() {
<div className="px-4 pb-4 pt-3 max-w-4xl w-full mx-auto">
{tab === "queue" && (
<>
<div className="flex gap-2 mb-4">
{/* The field takes the whole line when the buttons would squeeze it (at 375 it had 57px). */}
<div className="flex flex-wrap gap-2 mb-4">
<Input
className="w-full"
className="min-w-0 flex-[1_1_16rem]"
value={addUrl}
onChange={(e) => setAddUrl(e.target.value)}
onFocus={() => pasteFromClipboard()}
+21 -3
View File
@@ -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);
});
});
@@ -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");
});
});
@@ -155,3 +155,52 @@ describe("DownloadRow · keyboard", () => {
expect(container.innerHTML).not.toContain("[&amp;_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(
<DownloadRow job={{ ...withAsset("mp4"), created_at: "2026-09-20T10:00:00Z" }} />,
);
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(<DownloadRow job={withAsset("mp4")} />);
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(
<DownloadRow job={{ ...withAsset("mp4"), source_url: "https://example.com/v" }}>
<IconBtn onClick={() => {}} title="play">
x
</IconBtn>
</DownloadRow>,
);
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(<DownloadRow job={{ ...withAsset("mp4"), extra_links: [] }} />);
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
const texts: string[] = [];
while (walker.nextNode()) texts.push(walker.currentNode.textContent ?? "");
expect(texts).not.toContain("0");
});
});
+75 -43
View File
@@ -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 (
<div className="w-28 aspect-video shrink-0 rounded-lg overflow-hidden bg-surface border border-border flex items-center justify-center text-muted">
<div className={`${THUMB_BOX} flex items-center justify-center text-muted`}>
<File className="w-8 h-8" aria-hidden="true" />
</div>
);
}
return (
<div className="w-28 aspect-video shrink-0 rounded-lg overflow-hidden bg-surface border border-border">
<div className={THUMB_BOX}>
{src ? (
<img
src={src}
@@ -168,7 +176,7 @@ const nativeMenu = { onContextMenu: (e: React.MouseEvent) => e.stopPropagation()
function SourceRef({ url }: { url: string }) {
const { t } = useTranslation();
return (
<div className="flex items-center gap-1 mt-1 text-sm text-muted min-w-0">
<div className="flex items-center gap-1 text-sm text-muted min-w-0">
<Link2 className="w-3.5 h-3.5 shrink-0" />
<a
href={url}
@@ -230,57 +238,88 @@ export function DownloadRow({
<div
{...(navIndex !== undefined && navItemAttrs(navIndex, jobTitle(job)))}
{...rowProps}
className={`${SURFACE.card} group flex gap-3 p-2.5 glass-hover transition`}
className={`${SURFACE.card} @container/dlrow group p-2.5 glass-hover transition`}
>
<Thumb job={job} />
<div className="min-w-0 flex-1">
<div className="font-medium leading-snug line-clamp-1 flex items-center gap-1.5">
{job.job_kind === "edit" && (
<span className="inline-flex items-center gap-1 shrink-0 px-1.5 py-0.5 rounded-sm text-2xs font-semibold uppercase tracking-wide bg-accent/15 text-accent">
<Scissors className="w-3 h-3" /> {t("downloads.clipBadge")}
</span>
)}
<span className="truncate">{jobTitle(job)}</span>
<div
className={`grid grid-cols-[auto_minmax(0,1fr)_auto] gap-x-3 gap-y-1 [grid-template-areas:'thumb_head_act''thumb_meta_act''thumb_src_act''thumb_prog_act'] @max-[40rem]/dlrow:grid-cols-[auto_minmax(0,1fr)] @max-[40rem]/dlrow:[grid-template-areas:'thumb_head''meta_meta''foot_foot''prog_prog']`}
>
<div className="[grid-area:thumb]">
<Thumb job={job} />
</div>
<ChannelLine job={job} />
<div className="flex items-center gap-2 mt-1 text-sm">
<div className="[grid-area:head] min-w-0">
<div
className={`font-medium leading-snug break-words line-clamp-1 @max-[40rem]/dlrow:line-clamp-2`}
>
{job.job_kind === "edit" && (
<span className="mr-1.5 inline-flex items-center gap-1 px-1.5 py-0.5 align-middle rounded-sm text-2xs font-semibold uppercase tracking-wide bg-accent/15 text-accent">
<Scissors className="w-3 h-3" /> {t("downloads.clipBadge")}
</span>
)}
{jobTitle(job)}
</div>
<ChannelLine job={job} />
</div>
{/* 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. */}
<div data-dl-meta className="[grid-area:meta] min-w-0 truncate text-sm">
<StatusPill job={job} />
{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.
<span className="text-muted uppercase">· {job.asset.container}</span>
<span className="text-muted uppercase"> · {job.asset.container}</span>
) : null}
{job.asset?.size_bytes ? (
<span className="text-muted">· {formatBytes(job.asset.size_bytes)}</span>
<span className="text-muted"> · {formatBytes(job.asset.size_bytes)}</span>
) : null}
{job.asset?.duration_s ? (
<span className="text-muted">· {formatDuration(job.asset.duration_s)}</span>
<span className="text-muted"> · {formatDuration(job.asset.duration_s)}</span>
) : null}
{job.status === "done" && job.created_at ? (
<span
className="text-muted inline-flex items-center gap-1"
title={formatDateTime(job.created_at)}
>
· <CalendarClock className="w-3.5 h-3.5" /> {relativeTime(job.created_at)}
<span className="text-muted" title={formatDateTime(job.created_at)}>
{" · "}
<CalendarClock className="inline w-3.5 h-3.5 align-[-0.15em]" />{" "}
{relativeTime(job.created_at)}
</span>
) : null}
{job.status === "error" ? (
<Pressable
onClick={() => 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"))}
<span className="text-muted underline">{t("downloads.errors.fail.detailsLink")}</span>
</Pressable>
<>
{" "}
<Pressable
onClick={() => 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"))}{" "}
<span className="text-muted underline">
{t("downloads.errors.fail.detailsLink")}
</span>
</Pressable>
</>
) : null}
</div>
{job.source_url && <SourceRef url={job.source_url} />}
{job.extra_links?.map((url) => (
<SourceRef key={url} url={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?"). */}
<div className="contents @max-[40rem]/dlrow:[grid-area:foot] @max-[40rem]/dlrow:flex @max-[40rem]/dlrow:flex-wrap @max-[40rem]/dlrow:items-center @max-[40rem]/dlrow:gap-x-2">
{(!!job.source_url || !!job.extra_links?.length) && (
<div className="[grid-area:src] min-w-0 @max-[40rem]/dlrow:min-w-32 @max-[40rem]/dlrow:flex-1">
{job.source_url && <SourceRef url={job.source_url} />}
{job.extra_links?.map((url) => (
<SourceRef key={url} url={url} />
))}
</div>
)}
{/* 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. */}
<div className="[grid-area:act] flex items-start gap-1 shrink-0 @max-[40rem]/dlrow:ml-auto">
{children}
{more}
</div>
</div>
{running && (
<div className="mt-1.5">
<div className="[grid-area:prog] mt-0.5">
{job.phase && !DOWNLOAD_PHASES.includes(job.phase) ? (
// ffmpeg post-steps aren't byte-progress — show an indeterminate pulse, no %.
<div className="h-1.5 rounded-full bg-surface overflow-hidden">
@@ -304,13 +343,6 @@ export function DownloadRow({
</div>
)}
</div>
{/* 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. */}
<div className="flex items-start gap-1 shrink-0">
{children}
{more}
</div>
</div>
);
}
+45 -17
View File
@@ -69,6 +69,11 @@ export function playlistColumns({
{v.thumbnail_url && (
<img src={v.thumbnail_url} alt="" className="w-full h-full object-cover" />
)}
{/* 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). */}
<span className="pointer-events-none absolute right-0.5 bottom-0.5 @2xl:hidden">
{lengthCell(v, t, true)}
</span>
<span className="absolute inset-0 grid place-items-center bg-black/40 opacity-0 group-hover/thumb:opacity-100 group-focus-visible/thumb:opacity-100 transition">
<Play className="w-4 h-4 text-white fill-current" />
</span>
@@ -80,7 +85,16 @@ export function playlistColumns({
data-nav-open=""
className="min-h-6 min-w-0 flex-1 touch-target"
>
<span className="block text-[0.8125rem] text-fg truncate">{v.title}</span>
{/* 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. */}
<span className="break-words text-[0.8125rem] leading-snug text-fg line-clamp-2 @2xl:line-clamp-1">
{v.title}
</span>
{v.channel_title && (
<span className="block truncate text-2xs text-muted @2xl:hidden">
{v.channel_title}
</span>
)}
</Pressable>
</div>
),
@@ -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 ? (
<span className="text-2xs text-muted tabular-nums">
{formatDuration(v.duration_seconds)}
</span>
) : v.live_status === "live" || v.live_status === "upcoming" ? (
<span
className={`text-3xs font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded ${
v.live_status === "live"
? "bg-live text-live-fg" /* the broadcast red, not --danger */
: "bg-card border border-border text-muted"
}`}
>
{t(`card.${v.live_status}`)}
</span>
) : 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 ? (
<span className="block rounded-sm bg-black/80 px-1 text-3xs leading-4 font-medium text-white tabular-nums">
{formatDuration(v.duration_seconds)}
</span>
) : (
<span className="text-2xs text-muted tabular-nums">{formatDuration(v.duration_seconds)}</span>
);
if (v.live_status !== "live" && v.live_status !== "upcoming") return null;
return (
<span
className={`text-3xs font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded ${
v.live_status === "live"
? "bg-live text-live-fg" /* the broadcast red, not --danger */
: onPicture
? "bg-black/80 text-white"
: "bg-card border border-border text-muted"
}`}
>
{t(`card.${v.live_status}`)}
</span>
);
}
@@ -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(
<Pager
page={1}
size={10}
total={120}
pageSizeOptions={[10, 25]}
onPageChange={() => {}}
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");
});
});
+9 -4
View File
@@ -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. */}
<span className="max-sm:sr-only">{t("datatable.pager.prev")}</span>
</Button>
<span className="text-muted flex items-center gap-1.5">
{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")}
<span className="max-sm:sr-only">{t("datatable.pager.next")}</span>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
)}
{total > 0 && (
<div className="flex items-center gap-2 text-muted">
<span aria-hidden="true">{sizeLabel}</span>
// The label never breaks into a word per line: short of room it goes above the sizes.
<div className="flex flex-wrap items-center gap-2 text-muted">
<span aria-hidden="true" className="whitespace-nowrap">
{sizeLabel}
</span>
{/* A handful of fixed sizes plus "All": segmented, the house control for a short fixed set. */}
<SegmentedToggle
ariaLabel={sizeLabel}
@@ -25,6 +25,7 @@ describe("a hover gated on `enabled:` alone", () => {
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(