From ab51a8144c3faf1d545e33195f2190492219e861 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Thu, 15 Oct 2026 15:01:24 +0900 Subject: fix(tabs): 20% black compounding group boxes; trim bottom padding under a last-row nested box so spacing stays even --- src/features/tabs/index.ts | 1 + src/features/tabs/tab-groups.test.ts | 26 +++++++++++++++++++++++++ src/features/tabs/tab-groups.ts | 24 +++++++++++++++++++++++ src/features/tabs/ui.test.ts | 34 ++++++++++++++++++++++++++++++++- src/features/tabs/ui/TabGroupBox.svelte | 17 ++++++++++++----- 5 files changed, 96 insertions(+), 6 deletions(-) (limited to 'src') diff --git a/src/features/tabs/index.ts b/src/features/tabs/index.ts index a0c53f8..980e8ee 100644 --- a/src/features/tabs/index.ts +++ b/src/features/tabs/index.ts @@ -6,6 +6,7 @@ export { groupDescendants, groupGutterPx, groupParentCandidates, + lastRowIsGroup, visibleGroupParents, } from "./tab-groups"; export type { Tab, TabsState } from "./tabs"; diff --git a/src/features/tabs/tab-groups.test.ts b/src/features/tabs/tab-groups.test.ts index ca2492c..83f273c 100644 --- a/src/features/tabs/tab-groups.test.ts +++ b/src/features/tabs/tab-groups.test.ts @@ -6,6 +6,7 @@ import { groupDescendants, groupGutterPx, groupParentCandidates, + lastRowIsGroup, visibleGroupParents, } from "./tab-groups"; import type { Tab } from "./tabs"; @@ -122,3 +123,28 @@ describe("groupBleedPx / groupGutterPx", () => { expect(groupBleedPx(12, 12)).toBeGreaterThan(0); }); }); + +describe("lastRowIsGroup (mirrors grid row-dense placement)", () => { + const leaf = (id: string) => ({ tab: tab(id), children: [] }); + const group = (id: string) => ({ tab: tab(id), children: [leaf(`${id}-kid`)] }); + + it("is false with only cards", () => { + expect(lastRowIsGroup([])).toBe(false); + expect(lastRowIsGroup([leaf("a"), leaf("b"), leaf("c")])).toBe(false); + }); + + it("is true when a nested group comes last", () => { + expect(lastRowIsGroup([leaf("a"), group("g")])).toBe(true); + expect(lastRowIsGroup([group("g")])).toBe(true); + }); + + it("stays true when a later card back-fills an earlier half row", () => { + // [a, _] then [g, g]; b fills a's half row → g is still the last row. + expect(lastRowIsGroup([leaf("a"), group("g"), leaf("b")])).toBe(true); + }); + + it("is false when a later card has to open a new row below the group", () => { + expect(lastRowIsGroup([group("g"), leaf("a")])).toBe(false); + expect(lastRowIsGroup([leaf("a"), leaf("b"), group("g"), leaf("c")])).toBe(false); + }); +}); diff --git a/src/features/tabs/tab-groups.ts b/src/features/tabs/tab-groups.ts index ef16451..12870ee 100644 --- a/src/features/tabs/tab-groups.ts +++ b/src/features/tabs/tab-groups.ts @@ -128,6 +128,30 @@ export function directGroupChildren(tabs: readonly Tab[], conversationId: string .map((t) => t.conversationId); } +/** + * Whether a group box's LAST grid row is a nested group box (rather than a + * card). Mirrors the grid's `row dense` auto-placement: the group parent's + * double-width card fills row 0; a card takes the first free half-row (or + * opens a new row); a nested group (spans both columns) always opens a new + * row. Used to trim the outer box's bottom padding so the gap below a + * nested box matches the gap at its sides (an even-looking box). + */ +export function lastRowIsGroup(children: readonly TabGroupNode[]): boolean { + let openHalfRows = 0; // rows with exactly one free cell + let lastIsGroup = false; // the row-0 parent card is not a group row + for (const child of children) { + if (child.children.length > 0) { + lastIsGroup = true; // opens a new (last) row + } else if (openHalfRows > 0) { + openHalfRows -= 1; // back-fills an earlier half row; last row unchanged + } else { + openHalfRows += 1; // opens a new (last) row, half-filled + lastIsGroup = false; + } + } + return lastIsGroup; +} + /** * Horizontal bleed (px) of a group box at nesting `level` (1 = outermost) * when the deepest box is `maxDepth`. A box bleeds OUTWARD by this much (a diff --git a/src/features/tabs/ui.test.ts b/src/features/tabs/ui.test.ts index 49d70f2..645c2dd 100644 --- a/src/features/tabs/ui.test.ts +++ b/src/features/tabs/ui.test.ts @@ -382,8 +382,10 @@ describe("TabList", () => { s?.paddingLeft, s?.paddingLeft, ]); - expect(box).toHaveClass("bg-black/50"); } + // Every box paints 20% black, so nested boxes compound darker per level. + expect(outer).toHaveClass("bg-black/20"); + expect(inner).toHaveClass("bg-black/20"); expect(outer?.style.getPropertyValue("margin-inline")).toBe(`-${outer?.style.paddingLeft}`); }); @@ -430,6 +432,36 @@ describe("TabList", () => { expect(card("p")).toHaveClass("col-span-2"); }); + it("trims the bottom padding under a nested box on the last row to match its side gaps", () => { + // p's children: c2 (card) then c1 (nested group) → c1's box is the last row. + const lastIsGroup: readonly Tab[] = [ + { conversationId: "p", model: "m", title: "Parent", workspaceId: "w" }, + { conversationId: "c2", model: "m", title: "C2", workspaceId: "w", tabGroupParentId: "p" }, + { conversationId: "c1", model: "m", title: "C1", workspaceId: "w", tabGroupParentId: "p" }, + { conversationId: "g", model: "m", title: "G", workspaceId: "w", tabGroupParentId: "c1" }, + ]; + render(TabList, { + props: { + tabs: lastIsGroup, + activeConversationId: "p", + onSelect: vi.fn(), + onClose: vi.fn(), + onNewDraft: vi.fn(), + }, + }); + const outer = document.querySelector('[data-tab-group="p"]'); + const inner = document.querySelector('[data-tab-group="c1"]'); + const px = (v: string | undefined) => Number.parseFloat(v ?? ""); + const outerSide = px(outer?.style.paddingLeft); + const innerBleed = px(inner?.style.paddingLeft); + // Side gap between the two box edges = outer bleed − inner bleed; the + // bottom gap must equal it. + expect(px(outer?.style.paddingBottom)).toBe(outerSide - innerBleed); + expect(px(outer?.style.paddingTop)).toBe(outerSide); + // The inner box ends in a card → untouched, even on all sides. + expect(px(inner?.style.paddingBottom)).toBe(innerBleed); + }); + it("right-click opens the menu; 'Remove from group' ungroups the tab", async () => { const onSetTabGroup = renderGrouped(); await fireEvent.contextMenu(card("c2"), { clientX: 10, clientY: 10 }); diff --git a/src/features/tabs/ui/TabGroupBox.svelte b/src/features/tabs/ui/TabGroupBox.svelte index 0bf3ad2..289cb6d 100644 --- a/src/features/tabs/ui/TabGroupBox.svelte +++ b/src/features/tabs/ui/TabGroupBox.svelte @@ -1,7 +1,7 @@