mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-29 06:28:23 +02:00
* fix(issues): anchor the working chip on issues the filter actually shows (MUL-4884)
The header chip showed three units at once: the number counted distinct
issues, the avatar stack counted agents (with a rival "+N"), and the hover
card counted tasks — under a label with no noun at all. Each figure was
self-consistent; together they read as a miscount. c4209ec7c flipped the
number from agents to issues but left the other three surfaces on their old
units.
The chip now says one thing — "N issues in progress" — where N is the number
of rows clicking it leaves.
Data layer:
- The count is no longer re-derived from the task snapshot. The surface
exposes `workingScopeIssues`: the render pipeline's own output with
`workingOnly` forced on, so "chip count === row count" holds by
construction instead of by convention. It previously counted running
issue_ids against the PRE-filter set, so any active status/assignee/label
filter — or a sub-issue hidden by the display toggle — made the chip
disagree with the list it was filtering.
- Chat/autopilot tasks carry issue_id "" (not null). They used to collapse
into one bucket and read as a phantom issue, inflating the count by exactly
one whenever any were running. They are now bucketed out and disclosed.
- The list loads one page per status (50), so running work can exist past the
window. The count stays list-anchored — counting rows a click cannot show
would break the chip's whole promise — and the gap is stated in the hover
card rather than dropped silently.
UI:
- Avatar stack is ambience, not a statistic: no "+N"; the tail fades and the
exact roster lives in the hover card.
- Hover card groups rows by issue, names both counted units in its header
("3 issues in progress · 4 tasks"), and footnotes anything excluded — only
when non-zero.
- Colour is two-step: idle activity is a brand tint, the filled state is
reserved for "filter is ON".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(issues): scope the working chip to the rows swimlane and gantt actually draw (MUL-4884)
Review found two modes where `workingScopeIssues` still came from a
different set than the one on screen — the exact drift this change set is
meant to remove.
Swimlane: scoped to the statusless `swimlaneIssues`, but SwimLaneView draws
its cards from `issues` (status filter applied) and only uses the statusless
set for LANE DISCOVERY. A status-filtered swimlane counted rows the canvas
never drew. Swimlane needs no branch at all — it renders the flat filtered
list like board and list do — so the special case is gone rather than
corrected.
Gantt: the canvas applies two rules of its own before drawing — a row needs a
date to be placed, and done/cancelled hide unless `ganttShowCompleted` is on
— so a done issue with a running agent counted toward the chip while the
canvas refused to draw it.
Those rules now live in the surface (`ganttCanvasRows`) instead of privately
inside GanttView, and both `filteredGanttIssues` and the working scope go
through them. Mirroring the rules in a second place would have reproduced the
original bug with extra steps; hoisting them means the chip narrows the same
set the canvas draws, by construction. GanttView goes back to being a
renderer: it orders rows, it does not decide which ones exist.
Regression tests cover both, and each was confirmed to fail against the code
it guards: swimlane asserts the statusless lane source still holds the wider
set, so a revert fails loudly rather than passing by luck; gantt covers the
hidden done row, the undated row, and the show-completed toggle widening both
the canvas and the scope together.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(issues): anchor the working chip on agents, and give it real colour tiers (MUL-4884)
Follows the approved final mock. Two changes, one root cause each.
SEMANTICS: the chip now counts agents, not issues.
The original bug was "+1" (agents) sitting next to "3" (issues) — two units
in one control. Anchoring on issues fixed which number was authoritative but
left the units mismatched, so the avatar stack had to lose its +N and the
label had to grow a noun to suppress the contradiction. Counting agents makes
the number and the heads beside it the same list: the mismatch is gone rather
than managed. It also settles the subject — only agents emit a runtime
signal, so "N agents working" cannot be misread as "N issues someone is
working on", which "N issues in progress" invited.
Accepted trade-off: the number no longer predicts the row count of the click.
One agent on two issues reads "1 agent working" and opens two rows. The chip
answers WHO, the list answers WHERE — different questions, so they don't
compete, and the hover card's issue grouping shows the mapping. The
workingScopeIssues pipeline stays: it still decides WHICH agents count.
The hover card drops both "not counted" footnotes. They explained an absence
the user never perceived — chat/autopilot runs leave no row, no head, no
indicator on this page — so they invented a discrepancy instead of resolving
one. The empty-issue_id guard stays: that is data correctness (those agents
aren't on any issue), independent of whether we narrate it.
Avatar overflow returns to the component's standard +N badge; the fade
variant is deleted. With an agent-anchored number, "3 shown + 1 = 4"
corroborates the text instead of competing with it.
COLOUR: three tiers, each a self-contained Button variant.
Layering brand classes over `outline` could never work in dark mode:
`outline` ships dark:bg-input/30 / dark:border-input / dark:hover:bg-input/50,
tailwind-merge keeps them (different modifier group), and they win the
cascade — `dark` compiles to `&:is(.dark *)`, so they outrank a bare
.bg-brand on specificity, not just order. Both brand tiers were dead in dark;
--brand-foreground equals --foreground there, so filter-on and filter-off
rendered pixel-identical.
New `brand` / `brandSubtle` variants own every state instead: hover, pressed,
and aria-expanded pinned to the hover value so opening the popover doesn't
read as a colour change. `brand` needs no dark: at all — the token flips per
theme. `brandSubtle` runs one notch hotter in dark, where the same alpha
reads weaker.
Verified against the compiled stylesheet rather than by asserting class
strings — a string assertion is what let the dark bug through. A cascade
check resolves each tier x {default,hover,pressed,popover-open} x
{light,dark} by specificity and source order; all 16 brand cells land on
their intended notch, identical in both themes for `brand`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(issues): stop the empty-state muted text from overriding the brand tier (MUL-4884)
Review caught the colour rule breaking itself, and the test that was supposed
to guard it being unable to.
`filter ON + 0 agents` is a real state — the filter stays on after the last
agent finishes. There the variant is `brand`, and the chip appended
`text-muted-foreground` for the empty state regardless of the filter.
tailwind-merge keeps the last class in a group and `className` is merged
after the variant, so the muted grey WON: grey text on a brand-blue fill. The
irony is exact — this change set exists because colour classes in `className`
lose to a variant's `dark:` chain, and here one beat the variant instead.
Either way the lesson is the same: a tier's colours only ever come from its
variant. `chipAppearance` now makes that a rule with one gated exception
instead of an inline ternary, and says why.
The variant assertion could not catch this: the variant IS `brand` while the
text is overridden. That is the same shape of false confidence as the class
-string assertion it replaced, so this commit brings the cascade check into
the repo instead of leaving it as something a commit message claimed.
`apps/web/app/brand-variant-cascade.test.ts` compiles the real globals.css —
web's and desktop's, since each defines its own `dark` variant — and resolves
what a browser would paint for the merged class strings: filter declarations
matching the element's classes and state, then take the winner by
specificity, then source order. It asserts both tiers across default / hover
/ pressed / popover-open x light / dark, and pins the two ways the colour has
actually been lost as executable failures: layering brand over `outline`
(dark:bg-input/30 wins by specificity) and appending a colour class (wins by
merge order).
Both new guards were confirmed to fail against the code they guard: reverting
the gate fails the chipAppearance test; giving `brand` a dark: rule fails the
cascade test in both bundles.
Also refreshes the comments still describing the retired issue-anchored
invariant ("chip count === row count"). The scope pipeline they describe is
unchanged and still load-bearing — it decides WHICH agents count — but the
chip's number is agents, so it is no longer this list's length.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
281 lines
11 KiB
TypeScript
281 lines
11 KiB
TypeScript
import { readFileSync } from "node:fs";
|
|
import { dirname, resolve } from "node:path";
|
|
import postcss from "postcss";
|
|
import tailwind from "@tailwindcss/postcss";
|
|
import { cn } from "@multica/ui/lib/utils";
|
|
import { buttonVariants } from "@multica/ui/components/ui/button";
|
|
import { beforeAll, describe, expect, it } from "vitest";
|
|
|
|
/**
|
|
* Does the brand chip actually render blue?
|
|
*
|
|
* This lives in apps/web because it tests the COMPILED STYLESHEET, not React
|
|
* behaviour: `globals.css` is the app's artifact, and the `dark` variant is
|
|
* defined there (`@custom-variant dark (&:is(.dark *))`) — that definition is
|
|
* what decides the outcome below. It follows the same pattern as
|
|
* font-fallback-order.test.ts, which also asserts on the app's CSS.
|
|
*
|
|
* Why simulate a cascade instead of asserting class names: a class name in
|
|
* the DOM proves nothing about the pixel. Twice on MUL-4884 the brand colour
|
|
* was present as a class and still lost:
|
|
*
|
|
* 1. Brand classes layered over the `outline` variant. tailwind-merge keeps
|
|
* `dark:bg-input/30` (different modifier group, no conflict to resolve),
|
|
* and `dark:` compiles to `&:is(.dark *)` — specificity (0,2,0) against
|
|
* a bare `.bg-brand`'s (0,1,0). The chip was grey in dark mode while
|
|
* every string assertion passed.
|
|
* 2. A colour class appended via `className` beat the variant that was
|
|
* supposed to own the colour (see the merged-class case below).
|
|
*
|
|
* Both are invisible to a `toContain("bg-brand")` test and both are caught by
|
|
* resolving what a browser would actually paint: filter the declarations that
|
|
* match the element's classes and state, then take the winner by specificity,
|
|
* then source order.
|
|
*/
|
|
|
|
interface FlatRule {
|
|
sel: string;
|
|
prop: string;
|
|
order: number;
|
|
}
|
|
|
|
interface ElementState {
|
|
dark?: boolean;
|
|
hover?: boolean;
|
|
active?: boolean;
|
|
expanded?: boolean;
|
|
}
|
|
|
|
const repoRoot = resolve(process.cwd(), "../..");
|
|
|
|
async function compileStylesheet(entry: string): Promise<FlatRule[]> {
|
|
const css = readFileSync(entry, "utf8");
|
|
const built = await postcss([tailwind({ base: dirname(entry) })]).process(css, {
|
|
from: entry,
|
|
});
|
|
|
|
// Flatten Tailwind's nested output. Declarations sit under a rule OR inside
|
|
// an at-rule (`hover:` is wrapped in `@media (hover:hover)`, opacity
|
|
// modifiers in `@supports`), so collect at both levels and keep document
|
|
// order — it is the tie-breaker when specificity ties.
|
|
const rules: FlatRule[] = [];
|
|
let order = 0;
|
|
const walk = (container: postcss.Container, prefix: string) => {
|
|
container.each((node) => {
|
|
if (node.type === "decl") {
|
|
if (prefix) rules.push({ sel: prefix, prop: node.prop, order: order++ });
|
|
} else if (node.type === "rule") {
|
|
const sel = node.selector.includes("&")
|
|
? node.selector.replace(/&/g, prefix)
|
|
: prefix
|
|
? prefix + node.selector
|
|
: node.selector;
|
|
walk(node, sel);
|
|
} else if (node.type === "atrule") {
|
|
walk(node, prefix);
|
|
}
|
|
});
|
|
};
|
|
walk(built.root, "");
|
|
return rules;
|
|
}
|
|
|
|
/** Selector specificity's `b` column — classes, attributes, pseudo-classes.
|
|
* `:is()` contributes its most specific argument. Nothing here reaches for
|
|
* ids or elements, so the other two columns are always 0. */
|
|
function specificity(selector: string): number {
|
|
let count = 0;
|
|
const withoutIs = selector.replace(/:is\(([^()]*)\)/g, (_, inner: string) => {
|
|
count += Math.max(
|
|
...inner.split(",").map((part) => (part.match(/\.[^.\s>+~:[]+/g) ?? []).length),
|
|
);
|
|
return "";
|
|
});
|
|
return count + (withoutIs.match(/\\?\.[A-Za-z0-9_\\/:.\-[\]%]+/g) ?? []).length;
|
|
}
|
|
|
|
// Tailwind escapes `/` and `:` in class selectors (`.bg-brand\/7`), so split
|
|
// on the matched (escaped) prefix and unescape only for comparison — slicing
|
|
// by the unescaped length silently mis-parses every opacity utility.
|
|
const CLASS_PREFIX = /^\.((?:[^.\s:[\\]|\\.)+)/;
|
|
|
|
function baseClassOf(selector: string): string | null {
|
|
const m = selector.match(CLASS_PREFIX);
|
|
return m ? m[1]!.replace(/\\/g, "") : null;
|
|
}
|
|
|
|
function matches(selector: string, classes: string[], state: ElementState) {
|
|
const m = selector.match(CLASS_PREFIX);
|
|
if (!m) return false;
|
|
const cls = m[1]!.replace(/\\/g, "");
|
|
if (!classes.includes(cls)) return false;
|
|
const rest = selector.slice(m[0].length);
|
|
if (rest.includes(":is(.dark *)") && !state.dark) return false;
|
|
if (rest.includes(":hover") && !state.hover) return false;
|
|
if (rest.includes(":active") && !state.active) return false;
|
|
if (rest.includes('[aria-expanded="true"]') && !state.expanded) return false;
|
|
// Ignore any rule whose selector we do not model, rather than guessing.
|
|
const unmodelled = rest.replace(
|
|
/:is\(\.dark \*\)|:hover|:active|\[aria-expanded="true"\]/g,
|
|
"",
|
|
);
|
|
return unmodelled.trim() === "";
|
|
}
|
|
|
|
/** The class a browser would let win for `prop` on an element carrying
|
|
* `classes` in `state`. */
|
|
function winning(
|
|
rules: FlatRule[],
|
|
classes: string[],
|
|
state: ElementState,
|
|
prop: string,
|
|
): string | null {
|
|
const candidates = rules.filter(
|
|
(r) => r.prop === prop && matches(r.sel, classes, state),
|
|
);
|
|
if (candidates.length === 0) return null;
|
|
candidates.sort(
|
|
(a, b) => specificity(a.sel) - specificity(b.sel) || a.order - b.order,
|
|
);
|
|
return baseClassOf(candidates.at(-1)!.sel);
|
|
}
|
|
|
|
const WEB_CSS = resolve(repoRoot, "apps/web/app/globals.css");
|
|
const DESKTOP_CSS = resolve(repoRoot, "apps/desktop/src/renderer/src/globals.css");
|
|
|
|
// The chip's own composition: layout classes only, colour from the variant.
|
|
const brand = cn(buttonVariants({ variant: "brand", size: "sm" }), "h-8 px-2");
|
|
const brandSubtle = cn(
|
|
buttonVariants({ variant: "brandSubtle", size: "sm" }),
|
|
"h-8 px-2",
|
|
);
|
|
|
|
describe("brand Button variants resolve to brand colour in the real stylesheet", () => {
|
|
let rules: FlatRule[];
|
|
|
|
beforeAll(async () => {
|
|
rules = await compileStylesheet(WEB_CSS);
|
|
}, 60_000);
|
|
|
|
const bg = (classes: string, state: ElementState) =>
|
|
winning(rules, classes.split(/\s+/), state, "background-color");
|
|
const text = (classes: string, state: ElementState) =>
|
|
winning(rules, classes.split(/\s+/), state, "color");
|
|
|
|
describe("brand (filter ON — the loud filled tier)", () => {
|
|
// --brand flips per theme, so one set of rules must serve both. If a
|
|
// `dark:` rule from another variant ever creeps in, these diverge.
|
|
for (const dark of [false, true]) {
|
|
const theme = dark ? "dark" : "light";
|
|
|
|
it(`fills with brand and never with the neutral input token (${theme})`, () => {
|
|
expect(bg(brand, { dark })).toBe("bg-brand");
|
|
});
|
|
|
|
it(`deepens one notch on hover, another when pressed (${theme})`, () => {
|
|
expect(bg(brand, { dark, hover: true })).toBe("hover:bg-brand/90");
|
|
expect(bg(brand, { dark, hover: true, active: true })).toBe(
|
|
"active:bg-brand/85",
|
|
);
|
|
});
|
|
|
|
it(`reads as hover, not as a colour change, while the popover is open (${theme})`, () => {
|
|
expect(bg(brand, { dark, expanded: true })).toBe(
|
|
"aria-expanded:bg-brand/90",
|
|
);
|
|
});
|
|
|
|
it(`keeps brand-foreground text (${theme})`, () => {
|
|
expect(text(brand, { dark })).toBe("text-brand-foreground");
|
|
});
|
|
}
|
|
});
|
|
|
|
describe("brandSubtle (activity, filter OFF — the tint tier)", () => {
|
|
// Dark runs one notch hotter: the same alpha reads weaker on a near-black
|
|
// surface than on white.
|
|
it("uses the light notches in light mode", () => {
|
|
expect(bg(brandSubtle, {})).toBe("bg-brand/7");
|
|
expect(bg(brandSubtle, { hover: true })).toBe("hover:bg-brand/12");
|
|
expect(bg(brandSubtle, { hover: true, active: true })).toBe(
|
|
"active:bg-brand/16",
|
|
);
|
|
expect(bg(brandSubtle, { expanded: true })).toBe("aria-expanded:bg-brand/12");
|
|
});
|
|
|
|
it("uses the hotter dark notches in dark mode", () => {
|
|
expect(bg(brandSubtle, { dark: true })).toBe("dark:bg-brand/12");
|
|
expect(bg(brandSubtle, { dark: true, hover: true })).toBe(
|
|
"dark:hover:bg-brand/18",
|
|
);
|
|
expect(bg(brandSubtle, { dark: true, hover: true, active: true })).toBe(
|
|
"dark:active:bg-brand/24",
|
|
);
|
|
expect(bg(brandSubtle, { dark: true, expanded: true })).toBe(
|
|
"dark:aria-expanded:bg-brand/18",
|
|
);
|
|
});
|
|
});
|
|
|
|
// The two ways the brand colour has actually been lost. Both are asserted
|
|
// as the FAILURE they are, so the reason for the current shape is executable
|
|
// rather than a comment someone can quietly undo.
|
|
describe("the mistakes this design prevents", () => {
|
|
it("shows why layering brand over `outline` cannot work in dark mode", () => {
|
|
const layered = cn(
|
|
buttonVariants({ variant: "outline", size: "sm" }),
|
|
"border-brand bg-brand text-brand-foreground",
|
|
);
|
|
|
|
// tailwind-merge cannot drop outline's dark: rules — different modifier
|
|
// group, so there is no conflict for it to resolve...
|
|
expect(layered).toContain("dark:bg-input/30");
|
|
// ...and `:is(.dark *)` then outranks the bare .bg-brand.
|
|
expect(bg(layered, { dark: true })).toBe("dark:bg-input/30");
|
|
// Light mode looks fine, which is exactly why this shipped unnoticed.
|
|
expect(bg(layered, {})).toBe("bg-brand");
|
|
});
|
|
|
|
it("shows why a colour class in `className` beats the variant that owns it", () => {
|
|
// `filter ON + 0 agents` used to land here: variant `brand`, muted text
|
|
// appended for the empty state → grey text on a brand-blue fill.
|
|
const overridden = cn(
|
|
buttonVariants({ variant: "brand", size: "sm" }),
|
|
"text-muted-foreground",
|
|
);
|
|
|
|
expect(text(overridden, {})).toBe("text-muted-foreground");
|
|
expect(text(overridden, { dark: true })).toBe("text-muted-foreground");
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("desktop ships the same brand cascade as web", () => {
|
|
let rules: FlatRule[];
|
|
|
|
beforeAll(async () => {
|
|
rules = await compileStylesheet(DESKTOP_CSS);
|
|
}, 60_000);
|
|
|
|
// Desktop declares its own `@custom-variant dark` and its own @source globs,
|
|
// so the guarantee has to be re-proved against its bundle rather than
|
|
// assumed from web's.
|
|
it("fills the brand tier with brand in both themes", () => {
|
|
const classes = brand.split(/\s+/);
|
|
expect(winning(rules, classes, {}, "background-color")).toBe("bg-brand");
|
|
expect(winning(rules, classes, { dark: true }, "background-color")).toBe(
|
|
"bg-brand",
|
|
);
|
|
expect(winning(rules, classes, { dark: true }, "color")).toBe(
|
|
"text-brand-foreground",
|
|
);
|
|
});
|
|
|
|
it("keeps the tint tier's dark notches", () => {
|
|
const classes = brandSubtle.split(/\s+/);
|
|
expect(winning(rules, classes, { dark: true }, "background-color")).toBe(
|
|
"dark:bg-brand/12",
|
|
);
|
|
});
|
|
});
|