commit 484f066b2bb9947d61a122f19570fa11689327bb
parent a4afb4d3178a41de274b9a29c1d8ae3a18302972
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 17 Sep 2026 20:37:29 -0400
Merge storage/locations-s0: the Advanced menu can leave a dimmed row
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
2 files changed, 94 insertions(+), 23 deletions(-)
diff --git a/editor/app/channels/components/ChannelsTable.tsx b/editor/app/channels/components/ChannelsTable.tsx
@@ -595,22 +595,27 @@ function ChannelTableRow({
// through the pinned identity column while the rest scrolls.
const stickyBg = selected ? "bg-accent" : "bg-background";
const bridge = "bg-surface w-24 min-w-20";
+ // Dimmed for the two things that take the row out of a pipeline: it is
+ // excluded from the export build, or its base tier is Paused. (The sync
+ // exclusion flag that used to dim it is now a `sync` pin, which is a
+ // per-operation fact and not a property of the whole row.)
+ //
+ // THE DIM IS PER CELL, NEVER ON THE `<tr>`. `opacity` below 1 creates a
+ // STACKING CONTEXT, and a stacking context confines every positioned
+ // descendant to it: put `opacity-60` on the row and the Tier cell's
+ // `absolute z-30` Advanced popover (ChannelTierSelect) can no longer paint
+ // above the rows that follow, however high its z-index — every later row
+ // draws over it and swallows the clicks. So the Tier cell — the one that
+ // hosts the popover — is the one cell that is NOT dimmed. Moving this back
+ // onto the row re-breaks the menu (operator report 2026-09-17).
+ const dim =
+ c.config.excludeFromBuild || c.priority.tier === "paused"
+ ? " opacity-60"
+ : "";
return (
- <tr
- className={
- "border-t border-border " +
- (selected ? "bg-accent " : "") +
- // Dimmed for the two things that take the row out of a pipeline: it is
- // excluded from the export build, or its base tier is Paused. (The sync
- // exclusion flag that used to dim it is now a `sync` pin, which is a
- // per-operation fact and not a property of the whole row.)
- (c.config.excludeFromBuild || c.priority.tier === "paused"
- ? "opacity-60"
- : "")
- }
- >
+ <tr className={"border-t border-border " + (selected ? "bg-accent " : "")}>
<td
- className={`sticky left-0 z-10 w-9 px-2 py-1.5 align-middle ${stickyBg}`}
+ className={`sticky left-0 z-10 w-9 px-2 py-1.5 align-middle ${stickyBg}${dim}`}
>
<input
type="checkbox"
@@ -621,7 +626,7 @@ function ChannelTableRow({
/>
</td>
<Td
- className={`sticky left-8 z-20 whitespace-nowrap font-mono shadow-[1px_0_0_var(--color-border)] ${stickyBg}`}
+ className={`sticky left-8 z-20 whitespace-nowrap font-mono shadow-[1px_0_0_var(--color-border)] ${stickyBg}${dim}`}
>
<span className="inline-flex items-center gap-1.5">
<Link
@@ -633,11 +638,11 @@ function ChannelTableRow({
<MediaLocationBadge media={c.media} compact />
</span>
</Td>
- <Td className="max-w-48 truncate">{c.config.name ?? ""}</Td>
- <Td className="font-mono text-[11px] text-muted-foreground">
+ <Td className={`max-w-48 truncate${dim}`}>{c.config.name ?? ""}</Td>
+ <Td className={`font-mono text-[11px] text-muted-foreground${dim}`}>
{c.config.handling}
</Td>
- <Td>
+ <Td className={dim}>
<ChannelBuildToggle
slug={c.slug}
excluded={c.config.excludeFromBuild === true}
@@ -653,19 +658,21 @@ function ChannelTableRow({
/>
</Td>
<Td
- className="text-right tabular-nums"
+ className={`text-right tabular-nums${dim}`}
ariaLabel={`playlist count for ${c.slug}`}
>
{c.playlistCount ?? "—"}
</Td>
- <Td className="whitespace-nowrap text-xs tabular-nums text-muted-foreground">
+ <Td
+ className={`whitespace-nowrap text-xs tabular-nums text-muted-foreground${dim}`}
+ >
{formatStamp(c.config.lastSyncedAt)}
</Td>
<Td
ariaLabel={`report age for ${c.slug}`}
className={`whitespace-nowrap text-xs tabular-nums ${
c.report.state === "current" ? "text-muted-foreground" : "text-warning"
- }`}
+ }${dim}`}
>
{c.report.state === "current"
? formatStamp(c.report.generatedAt)
@@ -680,7 +687,8 @@ function ChannelTableRow({
className={
bridge +
(i === 0 ? " border-l border-border" : "") +
- (i === columns.length - 1 ? " border-r border-border" : "")
+ (i === columns.length - 1 ? " border-r border-border" : "") +
+ dim
}
count={
col.id === "download"
@@ -691,7 +699,7 @@ function ChannelTableRow({
}
/>
))}
- <Td className="whitespace-nowrap">
+ <Td className={`whitespace-nowrap${dim}`}>
<div className="flex items-center gap-2">
<ChannelSyncButton slug={c.slug} disabled={!c.config.url} />
<ChannelAvailabilityButton slug={c.slug} disabled={!c.config.url} />
diff --git a/editor/e2e/channel-priority.spec.ts b/editor/e2e/channel-priority.spec.ts
@@ -145,6 +145,69 @@ test("the Sync only preset is paused everywhere with sync pinned back", async ({
}
});
+// THE ADVANCED MENU HAS TO ESCAPE ITS OWN ROW.
+//
+// A row is dimmed when it is excluded from the build or its base tier is
+// Paused, and the dim used to sit on the `<tr>` as `opacity-60`. Opacity below
+// 1 makes the row a stacking context painted as one group, so the panel's
+// `absolute z-30` no longer lifts it out of the row: the NEXT dimmed row is a
+// later group at the same level and paints straight over the menu (operator
+// report 2026-09-17, on a corpus where paused rows sit next to each other).
+// Two paused rows in a row is the smallest shape that reproduces it; one alone
+// does not, because an opacity group still paints above the plain rows below.
+test("a paused row's Advanced menu draws above the paused row below it", async ({
+ page,
+}) => {
+ await resetData("two-slow-channels");
+ await page.goto(ALL);
+
+ // slow-a is the FIRST row and slow-b the one its panel hangs over. The
+ // assertion is meaningless on the last row of the table, which has nothing
+ // after it to be covered by.
+ const slugs = await page
+ .locator('[aria-label^="select slow-"]')
+ .evaluateAll((els) =>
+ els.map((el) => el.getAttribute("aria-label")!.replace("select ", "")),
+ );
+ expect(slugs).toEqual(["slow-a", "slow-b"]);
+
+ await page.getByLabel("select all channels").check();
+ await page.getByLabel("bulk tier").selectOption("paused");
+ await page
+ .getByLabel("channel priority bulk")
+ .getByRole("button", { name: "Apply tier" })
+ .click();
+ await expect
+ .poll(async () => (await settings()).channelPriority?.channels)
+ .toEqual({ "slow-a": { tier: "paused" }, "slow-b": { tier: "paused" } });
+ // Reloaded, so both rows render dimmed from the server's own tiers rather
+ // than from the controls' optimistic state.
+ await page.reload();
+
+ await page.getByLabel("advanced priority for slow-a").click();
+ // `sync` is the first control in the panel, the part that hangs over slow-b.
+ const override = page.getByLabel("sync override for slow-a");
+ await expect(override).toBeInViewport();
+
+ // THE ASSERTION. `selectOption` never hit-tests, so it passes straight
+ // through a row painted on top of the panel; this asks the page directly who
+ // owns the pixel. With `opacity-60` back on the `<tr>` the answer is a cell
+ // of the paused slow-b row below.
+ const owner = await override.evaluate((el) => {
+ const r = el.getBoundingClientRect();
+ const top = document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2);
+ return top === el || el.contains(top)
+ ? "the override"
+ : `${top?.tagName ?? "nothing"} ${top?.getAttribute("aria-label") ?? top?.textContent?.trim().slice(0, 40) ?? ""}`;
+ });
+ expect(owner).toBe("the override");
+
+ await override.selectOption("normal");
+ await expect
+ .poll(async () => (await settings()).channelPriority?.channels["slow-a"])
+ .toEqual({ tier: "paused", overrides: { sync: "normal" } });
+});
+
// MOVED HERE FROM channel-sync-toggle.spec.ts, which this slice deletes with the
// control it drove. The assertion is the same one — a pool sweep skips the
// excluded channel and names it in the tooltip — restated against the tier the