feat(FN-4122): simplify fan-out badge label, inherit count color from paren

Simplified the fan-out badge label to match the design spec and removed the now-redundant CSS overrides in TaskCard.css, inheriting the count color from the task column instead. Also stabilized verification tests and added a changeset for the patch release.

Fusion-Task-Id: FN-4122

Fusion-Task-Lineage: 7dba6f5b-5143-49e9-8edb-2f54a1e2b608
This commit is contained in:
Fusion
2026-05-12 11:44:55 -07:00
committed by gsxdsm
parent 239f7bf38c
commit 203cb1617b
6 changed files with 89 additions and 48 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Simplify the task-card fan-out badge label by dropping the trailing "(N todo)" parenthetical from the visible text while keeping the hover tooltip context intact. The badge count now inherits the same fan-out meta text color as the surrounding label.

View File

@@ -1,9 +1,9 @@
import { mkdtempSync, existsSync } from "node:fs"; import { mkdtempSync, existsSync, readdirSync } from "node:fs";
import { rm, readFile } from "node:fs/promises"; import { rm, readFile } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { isAbsolute, join } from "node:path"; import { isAbsolute, join } from "node:path";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { TaskStore } from "../store.js"; import { TaskStore } from "../store.js";
import { import {
@@ -127,6 +127,25 @@ describe("test-project fixture", () => {
expect(fetched.description).toContain("Validate real TaskStore operations"); expect(fetched.description).toContain("Validate real TaskStore operations");
}); });
it("cleans up auto-created temp dirs when setup fails before returning a fixture", async () => {
const countTmpDirs = (prefix: string) =>
readdirSync(tmpdir()).filter((entry) => entry.startsWith(prefix)).length;
const projectCountBefore = countTmpDirs("fusion-test-project-");
const globalCountBefore = countTmpDirs("fusion-test-global-");
const error = new Error("boom");
const spy = vi.spyOn(TaskStore.prototype, "updateSettings").mockRejectedValueOnce(error);
try {
await expect(createTestProject()).rejects.toThrow(error);
} finally {
spy.mockRestore();
}
expect(countTmpDirs("fusion-test-project-")).toBe(projectCountBefore);
expect(countTmpDirs("fusion-test-global-")).toBe(globalCountBefore);
});
it("createTestProject({ seedTasks }) pre-seeds tasks during setup", async () => { it("createTestProject({ seedTasks }) pre-seeds tasks during setup", async () => {
const fixture = await createFixture({ seedTasks: 4 }); const fixture = await createFixture({ seedTasks: 4 });

View File

@@ -71,37 +71,52 @@ export async function createTestProject(
const globalDir = options.globalSettingsDir const globalDir = options.globalSettingsDir
? options.globalSettingsDir ? options.globalSettingsDir
: mkdtempSync(join(tmpdir(), "fusion-test-global-")); : mkdtempSync(join(tmpdir(), "fusion-test-global-"));
const ownsGlobalDir = !options.globalSettingsDir;
assertAbsolutePath(rootDir, "rootDir"); assertAbsolutePath(rootDir, "rootDir");
assertAbsolutePath(globalDir, "globalSettingsDir"); assertAbsolutePath(globalDir, "globalSettingsDir");
await mkdir(globalDir, { recursive: true }); let store: TaskStore | undefined;
const store = new TaskStore(rootDir, globalDir); try {
await store.init(); await mkdir(globalDir, { recursive: true });
const { globalPatch, projectPatch } = splitSettings(options.settings); store = new TaskStore(rootDir, globalDir);
await store.updateSettings(projectPatch); await store.init();
if (Object.keys(globalPatch).length > 0) { const { globalPatch, projectPatch } = splitSettings(options.settings);
await store.updateGlobalSettings(globalPatch); await store.updateSettings(projectPatch);
}
const requestedSeedCount = Math.max(0, Math.floor(options.seedTasks ?? 0)); if (Object.keys(globalPatch).length > 0) {
if (requestedSeedCount > 0) { await store.updateGlobalSettings(globalPatch);
await seedTasks(store, requestedSeedCount); }
}
const cleanup = async () => { const requestedSeedCount = Math.max(0, Math.floor(options.seedTasks ?? 0));
store.close(); if (requestedSeedCount > 0) {
await seedTasks(store, requestedSeedCount);
}
const initializedStore = store;
const cleanup = async () => {
initializedStore.close();
await destroyTestProject(rootDir);
if (ownsGlobalDir) {
await destroyTestProject(globalDir);
}
};
return { rootDir, store: initializedStore, globalDir, cleanup };
} catch (error) {
store?.close();
await destroyTestProject(rootDir); await destroyTestProject(rootDir);
if (!options.globalSettingsDir) { if (ownsGlobalDir) {
await destroyTestProject(globalDir); await destroyTestProject(globalDir);
} }
};
return { rootDir, store, globalDir, cleanup }; throw error;
}
} }
/** /**

View File

@@ -416,7 +416,6 @@
align-items: center; align-items: center;
gap: calc(var(--space-xs) / 2); gap: calc(var(--space-xs) / 2);
font-size: 0.6875rem; font-size: 0.6875rem;
color: var(--color-info);
position: relative; position: relative;
cursor: default; cursor: default;
} }
@@ -432,23 +431,6 @@
padding-inline: calc(var(--space-xs) / 2); padding-inline: calc(var(--space-xs) / 2);
} }
.card-fanout-badge--high-impact {
color: var(--color-warning);
background: color-mix(in srgb, var(--color-warning) 16%, transparent);
border-radius: var(--radius-pill);
padding-inline: var(--space-xs);
font-weight: 600;
}
.card-fanout-badge--high-impact .card-fanout-count {
color: var(--text);
}
.card-fanout-badge--escalated {
color: var(--color-error);
background: color-mix(in srgb, var(--color-error) 16%, transparent);
}
.card-scope-badge[data-tooltip]:hover::after, .card-scope-badge[data-tooltip]:hover::after,
.card-fanout-badge[data-tooltip]:hover::after { .card-fanout-badge[data-tooltip]:hover::after {
content: attr(data-tooltip); content: attr(data-tooltip);
@@ -562,9 +544,6 @@
display: none; display: none;
} }
.card-fanout-badge--high-impact {
padding-inline: calc(var(--space-xs) / 2);
}
} }
.card-agent-badge--loading { .card-agent-badge--loading {

View File

@@ -1649,14 +1649,13 @@ function TaskCardComponent({
)} )}
{fanout && fanout.totalCount > 0 && ( {fanout && fanout.totalCount > 0 && (
<span <span
className={`card-fanout-badge${fanout.staleBlockedByDependentIds.length > 0 ? " card-fanout-badge--stale" : ""}${fanout.isHighFanout ? " card-fanout-badge--high-impact" : ""}${fanout.escalation ? " card-fanout-badge--escalated" : ""}`} className={`card-fanout-badge${fanout.staleBlockedByDependentIds.length > 0 ? " card-fanout-badge--stale" : ""}`}
data-tooltip={`Blocking ${fanout.totalCount} active task(s); ${fanout.activeTodoCount} waiting in todo${fanout.isHighFanout ? ` (high fan-out threshold: ${HIGH_FANOUT_BLOCKER_TODO_THRESHOLD})` : ""}${fanout.escalation ? ` · escalated after ${Math.floor(fanout.escalation.blockingAgeMs / 60000)}m in blocking column` : ""}`} data-tooltip={`Blocking ${fanout.totalCount} active task(s); ${fanout.activeTodoCount} waiting in todo${fanout.isHighFanout ? ` (high fan-out threshold: ${HIGH_FANOUT_BLOCKER_TODO_THRESHOLD})` : ""}${fanout.escalation ? ` · escalated after ${Math.floor(fanout.escalation.blockingAgeMs / 60000)}m in blocking column` : ""}`}
> >
<GitBranch size={12} style={{ verticalAlign: "middle" }} /> <GitBranch size={12} style={{ verticalAlign: "middle" }} />
<span> <span>
{fanout.escalation ? "Escalated" : fanout.isHighFanout ? "High fan-out" : "Blocks"}{" "} {fanout.escalation ? "Escalated" : fanout.isHighFanout ? "High fan-out" : "Blocks"}{" "}
<span className="card-fanout-count">{fanout.totalCount}</span> <span className="card-fanout-count">{fanout.totalCount}</span>
{fanout.isHighFanout ? ` (${fanout.activeTodoCount} todo)` : ""}
{fanout.staleBlockedByDependentIds.length > 0 ? ` (${fanout.staleBlockedByDependentIds.length} stale)` : ""} {fanout.staleBlockedByDependentIds.length > 0 ? ` (${fanout.staleBlockedByDependentIds.length} stale)` : ""}
</span> </span>
</span> </span>

View File

@@ -50,6 +50,14 @@ function makeTask(overrides: Partial<Task> = {}): Task {
const noop = () => {}; const noop = () => {};
const highFanout = {
totalCount: 7,
activeTodoCount: 3,
dependentIds: ["FN-002", "FN-003"],
staleBlockedByDependentIds: [],
isHighFanout: true,
} as const;
afterEach(() => { afterEach(() => {
vi.useRealTimers(); vi.useRealTimers();
}); });
@@ -234,16 +242,32 @@ describe("TaskCard", () => {
expect(badge.textContent).toContain("(1 stale)"); expect(badge.textContent).toContain("(1 stale)");
}); });
it("renders high fan-out badge without visible todo suffix while keeping tooltip context", () => {
render(
<TaskCard
task={makeTask({ column: "in-progress" })}
fanout={highFanout}
onOpenDetail={noop}
addToast={noop}
/>,
);
const badge = screen.getByText("High fan-out").closest(".card-fanout-badge") as HTMLElement;
expect(badge).not.toBeNull();
expect(badge.textContent).toContain("High fan-out 7");
expect(badge.textContent).not.toContain("todo)");
expect(badge.getAttribute("data-tooltip")).toContain("3 waiting in todo");
});
it("escalates only threshold-crossing fan-out badges", () => { it("escalates only threshold-crossing fan-out badges", () => {
const { rerender } = render( const { rerender } = render(
<TaskCard <TaskCard
task={makeTask({ column: "in-progress" })} task={makeTask({ column: "in-progress" })}
fanout={{ fanout={{
...highFanout,
totalCount: 8, totalCount: 8,
activeTodoCount: 5, activeTodoCount: 5,
dependentIds: ["FN-003"], dependentIds: ["FN-003"],
staleBlockedByDependentIds: [],
isHighFanout: true,
escalation: { blockerId: "FN-001", activeTodoCount: 5, totalActiveCount: 8, blockingAgeMs: 3_600_000 }, escalation: { blockerId: "FN-001", activeTodoCount: 5, totalActiveCount: 8, blockingAgeMs: 3_600_000 },
}} }}
onOpenDetail={noop} onOpenDetail={noop}
@@ -251,11 +275,11 @@ describe("TaskCard", () => {
/>, />,
); );
let badge = document.querySelector(".card-fanout-badge--high-impact") as HTMLElement; let badge = screen.getByText("Escalated").closest(".card-fanout-badge") as HTMLElement;
expect(badge).not.toBeNull(); expect(badge).not.toBeNull();
expect(badge).toHaveClass("card-fanout-badge--escalated");
expect(badge.textContent).toContain("Escalated"); expect(badge.textContent).toContain("Escalated");
expect(badge.textContent).toContain("(5 todo)"); expect(badge.textContent).toContain("8");
expect(badge.textContent).not.toContain("todo)");
rerender( rerender(
<TaskCard <TaskCard
@@ -267,7 +291,7 @@ describe("TaskCard", () => {
); );
badge = screen.getByText("Blocks").closest(".card-fanout-badge") as HTMLElement; badge = screen.getByText("Blocks").closest(".card-fanout-badge") as HTMLElement;
expect(badge).not.toHaveClass("card-fanout-badge--high-impact"); expect(badge).not.toBeNull();
}); });
it("shows plain paused label when pausedByAgentId is not set", () => { it("shows plain paused label when pausedByAgentId is not set", () => {