summaryrefslogtreecommitdiffhomepage
path: root/src/features
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-10-21 20:32:41 +0900
committerAdam Malczewski <[email protected]>2026-10-21 20:32:41 +0900
commitbac1e1c39398ddcb0866db510c11bed75ac3a9a8 (patch)
tree5b2bde4e1db4683290ea0c1a42eb61c498a06402 /src/features
parent564b2c1d03cf477d7cfc23414b60d1ec76904ecc (diff)
downloaddispatch-web-bac1e1c39398ddcb0866db510c11bed75ac3a9a8.tar.gz
dispatch-web-bac1e1c39398ddcb0866db510c11bed75ac3a9a8.zip
feat(tabs): rotate three theme colours per group nesting level
Diffstat (limited to 'src/features')
-rw-r--r--src/features/tabs/index.ts4
-rw-r--r--src/features/tabs/tab-groups.test.ts31
-rw-r--r--src/features/tabs/tab-groups.ts16
-rw-r--r--src/features/tabs/ui.test.ts8
-rw-r--r--src/features/tabs/ui/TabGroupBox.svelte29
5 files changed, 79 insertions, 9 deletions
diff --git a/src/features/tabs/index.ts b/src/features/tabs/index.ts
index 980e8ee..86ed83f 100644
--- a/src/features/tabs/index.ts
+++ b/src/features/tabs/index.ts
@@ -1,11 +1,13 @@
-export type { TabGroupForest, TabGroupNode } from "./tab-groups";
+export type { GroupTone, TabGroupForest, TabGroupNode } from "./tab-groups";
export {
buildTabGroups,
directGroupChildren,
+ GROUP_TONES,
groupBleedPx,
groupDescendants,
groupGutterPx,
groupParentCandidates,
+ groupTone,
lastRowIsGroup,
visibleGroupParents,
} from "./tab-groups";
diff --git a/src/features/tabs/tab-groups.test.ts b/src/features/tabs/tab-groups.test.ts
index 83f273c..a09dbc8 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,
+ groupTone,
lastRowIsGroup,
visibleGroupParents,
} from "./tab-groups";
@@ -103,6 +104,36 @@ describe("groupDescendants / directGroupChildren / groupParentCandidates", () =>
});
});
+describe("groupTone", () => {
+ it("rotates three colours by level, starting at the outermost box", () => {
+ expect([1, 2, 3, 4, 5, 6, 7].map(groupTone)).toEqual([
+ "secondary",
+ "success",
+ "accent",
+ "secondary",
+ "success",
+ "accent",
+ "secondary",
+ ]);
+ });
+
+ it("never gives a box the colour of its parent or grandparent", () => {
+ for (let level = 3; level <= 20; level++) {
+ expect(groupTone(level)).not.toBe(groupTone(level - 1));
+ expect(groupTone(level)).not.toBe(groupTone(level - 2));
+ }
+ });
+
+ it("never uses primary (reserved for the active tab)", () => {
+ for (let level = 1; level <= 6; level++) expect(groupTone(level)).not.toBe("primary");
+ });
+
+ it("stays in range for out-of-range levels", () => {
+ expect(groupTone(0)).toBe("accent");
+ expect(groupTone(-1)).toBe("success");
+ });
+});
+
describe("groupBleedPx / groupGutterPx", () => {
it("is zero without groups", () => {
expect(groupGutterPx(0)).toBe(0);
diff --git a/src/features/tabs/tab-groups.ts b/src/features/tabs/tab-groups.ts
index 12870ee..c7406d8 100644
--- a/src/features/tabs/tab-groups.ts
+++ b/src/features/tabs/tab-groups.ts
@@ -153,6 +153,22 @@ export function lastRowIsGroup(children: readonly TabGroupNode[]): boolean {
}
/**
+ * The DaisyUI theme colour a group box at nesting `level` (1 = outermost) is
+ * tinted with. Three colours ROTATE by level, so a box always differs from
+ * the box around it AND from the one around that (with two colours a box
+ * would match its grandparent). `primary` is deliberately excluded — it marks
+ * the active tab, which must stay legible inside any box.
+ */
+export const GROUP_TONES = ["secondary", "success", "accent"] as const;
+export type GroupTone = (typeof GROUP_TONES)[number];
+
+export function groupTone(level: number): GroupTone {
+ const n = GROUP_TONES.length;
+ const i = (((Math.trunc(level) - 1) % n) + n) % n; // non-negative even for level < 1
+ return GROUP_TONES[i] ?? GROUP_TONES[0];
+}
+
+/**
* 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
* negative margin + equal padding), so the cards inside every box sit on
diff --git a/src/features/tabs/ui.test.ts b/src/features/tabs/ui.test.ts
index 2569438..8785ea9 100644
--- a/src/features/tabs/ui.test.ts
+++ b/src/features/tabs/ui.test.ts
@@ -383,9 +383,11 @@ describe("TabList", () => {
s?.paddingLeft,
]);
}
- // Every box paints a 15% lavender tint, so nested boxes compound per level.
- expect(outer).toHaveClass("bg-secondary/15");
- expect(inner).toHaveClass("bg-secondary/15");
+ // Each level is tinted with its own rotating theme colour.
+ expect(outer?.dataset.tone).toBe("secondary");
+ expect(inner?.dataset.tone).toBe("success");
+ expect(outer?.style.getPropertyValue("--group-tone")).toBe("var(--color-secondary)");
+ expect(inner?.style.getPropertyValue("--group-tone")).toBe("var(--color-success)");
expect(outer?.style.getPropertyValue("margin-inline")).toBe(`-${outer?.style.paddingLeft}`);
});
diff --git a/src/features/tabs/ui/TabGroupBox.svelte b/src/features/tabs/ui/TabGroupBox.svelte
index a941814..f9a3ae2 100644
--- a/src/features/tabs/ui/TabGroupBox.svelte
+++ b/src/features/tabs/ui/TabGroupBox.svelte
@@ -1,7 +1,7 @@
<script lang="ts">
import type { Snippet } from "svelte";
import type { TabGroupNode } from "../tab-groups";
- import { groupBleedPx, lastRowIsGroup } from "../tab-groups";
+ import { groupBleedPx, groupTone, lastRowIsGroup } from "../tab-groups";
import type { Tab } from "../tabs";
import TabGroupBox from "./TabGroupBox.svelte";
@@ -13,9 +13,11 @@
* inside stay on the same two columns as top-level cards: every card keeps
* identical dimensions at every depth. The padding is EQUAL on all four
* sides (= the bleed) so the box looks even — except below a nested box on
- * the last row, where it is trimmed to match that box's side gaps. Every
- * box paints a 15% secondary (lavender) tint, which compounds: each
- * nesting level reads a little stronger than the one around it.
+ * the last row, where it is trimmed to match that box's side gaps. Each
+ * box is tinted with one of three theme colours ROTATING by level
+ * (`groupTone`), painted OPAQUE (mixed into the sidebar well colour) so a
+ * nested box never blends with the box around it, plus a border in the same
+ * colour so every box edge reads at a glance.
*/
let {
node,
@@ -32,6 +34,7 @@
} = $props();
const bleed = $derived(groupBleedPx(level, maxDepth));
+ const tone = $derived(groupTone(level));
// Even spacing: when the last row is a nested box, the gap below it would
// be this box's full padding while its side gaps are only the DIFFERENCE
// in bleed (the nested box bleeds outward too) — trim the bottom to match.
@@ -41,13 +44,15 @@
</script>
<div
- class="col-span-2 rounded-lg bg-secondary/15"
+ class="tab-group-box col-span-2 rounded-lg"
+ style:--group-tone="var(--color-{tone})"
style:margin-inline="-{bleed}px"
style:padding="{bleed}px {bleed}px {padBottom}px"
role="group"
aria-label={`Subagents of ${node.tab.title}`}
data-tab-group={node.tab.conversationId}
data-level={level}
+ data-tone={tone}
>
<div class="grid grid-flow-row-dense grid-cols-2 gap-1.5">
{@render card(node.tab, true, node.children.length)}
@@ -60,3 +65,17 @@
{/each}
</div>
</div>
+
+<style>
+ /* Opaque tint: the tone mixed into the sidebar's `.inset-well` colour (the
+ surface the tab grid sits on), so nested boxes replace — never compound
+ with — the colour around them. */
+ .tab-group-box {
+ background-color: color-mix(
+ in oklab,
+ var(--group-tone) 16%,
+ var(--inset-well-bg, var(--color-base-200))
+ );
+ box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--group-tone) 55%, transparent);
+ }
+</style>