mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-05 09:30:05 +02:00
tokens.css defined colours, radii and font families but not a single --text-* step, so font sizes had no baseline to align to and grew wherever they were needed: 51 distinct sizes across web + desktop, 370 written as arbitrary values, six at half a pixel (10.5 / 11.5 / 12.5 / 13.5 / 14.5 / 15.5px). text-xs and text-sm carried nearly all UI text while the range between them — 11, 13, 15px — could only be reached with arbitrary values. Hierarchy does not come from having more sizes; past a handful, each extra size makes the hierarchy blurrier. Add ten role-named steps, each with its own line-height so leading cannot fragment the way size did, and move every product-UI call site onto them. Steps are named for what the text is for, not for a t-shirt size, because that is what keeps the scale from drifting again. Six steps deliberately keep the exact size/line-height pairs of the Tailwind defaults they replace, so the ~1,900-call-site rename moves nothing on screen. The visible changes are confined to former arbitrary values snapping to a step: 8/9/10px -> micro (11px) on badges and overlines; 17 -> 18; 22 -> 24; 30 (text-3xl) -> 36 on headings and stat numbers; 12.8px -> label (13px) on small buttons and toggles. Half-pixel sizes are gone. This supersedes #6108, which was reverted by #6116 because the sidebar group labels rendered at the inherited 16px. The cause was not the scale but cn(): `text-<x>` is ambiguous in Tailwind, and tailwind-merge resolves it against a table listing only the default sizes, so it filed every role step under text-colour and dropped whichever of `text-caption` / `text-sidebar-foreground/70` came first. Registering the steps as a font-size class group restores the real conflict groups — size beats size, colour beats colour, the two coexist — and a test pins the list against the scale, since the failure is silent in source. Hand-written CSS is covered too. The transcript kept a 12.5px body long after every Tailwind call site was on the scale, so the "no half-pixel sizes" claim was true of the classes and false of the product; the editor's prose, code and mermaid ramps had the same blind spot, and seven of their eight values already equalled a step exactly. All now reference var(--text-*). The guard test reads raw `font-size:` declarations as well as class names, exempting only the 16px iOS input-zoom workaround in base.css and the landing pages' marketing ramp. apps/mobile (own NativeWind config) and apps/docs (fumadocs' own type system) keep Tailwind's default scale and are untouched. Landing display type (rem/clamp, 2.2-6.4rem) stays on its separate ramp, as do four decorative emoji / serif-hero sizes. Verified on a running local stack: pinned sidebar rows and group labels measure 12px/16px, nav items 14px/20px — identical to pre-migration. An audit of every rendered font size across the product surfaces finds nothing off the scale; the only exceptions are avatar initials and emoji, which actor-avatar.tsx sizes proportionally to the avatar diameter by design. Co-authored-by: Lambda <lambda@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
328 lines
10 KiB
TypeScript
328 lines
10 KiB
TypeScript
"use client";
|
||
|
||
import { useState } from "react";
|
||
import { useQuery } from "@tanstack/react-query";
|
||
import {
|
||
CheckCircle2,
|
||
Circle,
|
||
CircleDashed,
|
||
CircleSlash,
|
||
GitMerge,
|
||
GitPullRequest,
|
||
GitPullRequestArrow,
|
||
GitPullRequestClosed,
|
||
GitPullRequestDraft,
|
||
TriangleAlert,
|
||
XCircle,
|
||
} from "lucide-react";
|
||
import {
|
||
issuePullRequestsOptions,
|
||
deriveChecksStatus,
|
||
deriveMergeStatus,
|
||
shouldShowPullRequestStats,
|
||
type PullRequestChecksStatus,
|
||
type PullRequestMergeStatus,
|
||
} from "@multica/core/github";
|
||
import type { GitHubPullRequest, GitHubPullRequestState } from "@multica/core/types";
|
||
import { cn } from "@multica/ui/lib/utils";
|
||
import { useT, useTimeAgo } from "../../i18n";
|
||
|
||
type IssuesT = ReturnType<typeof useT<"issues">>["t"];
|
||
|
||
// Keep the existing sidebar density: show the first 3 PR rows inline, then
|
||
// collapse the rest once the section reaches 4 rows.
|
||
const PR_LIMIT_BEFORE_COLLAPSE = 4;
|
||
|
||
const STATE_ICON: Record<
|
||
GitHubPullRequestState,
|
||
{ icon: React.ComponentType<{ className?: string }>; className: string }
|
||
> = {
|
||
open: { icon: GitPullRequestArrow, className: "text-emerald-600 dark:text-emerald-400" },
|
||
draft: { icon: GitPullRequestDraft, className: "text-muted-foreground" },
|
||
merged: { icon: GitMerge, className: "text-violet-600 dark:text-violet-400" },
|
||
closed: { icon: GitPullRequestClosed, className: "text-rose-600 dark:text-rose-400" },
|
||
};
|
||
|
||
export function PullRequestList({ issueId }: { issueId: string }) {
|
||
const { t } = useT("issues");
|
||
const [expanded, setExpanded] = useState(false);
|
||
const { data, isLoading } = useQuery(issuePullRequestsOptions(issueId));
|
||
const prs = data?.pull_requests ?? [];
|
||
|
||
if (isLoading) {
|
||
return <p className="text-caption text-muted-foreground px-2">{t(($) => $.detail.pull_requests_loading)}</p>;
|
||
}
|
||
if (prs.length === 0) {
|
||
return (
|
||
<p className="text-caption text-muted-foreground px-2">
|
||
{t(($) => $.detail.pull_requests_empty)}
|
||
</p>
|
||
);
|
||
}
|
||
|
||
// Render rule:
|
||
// - < PR_LIMIT_BEFORE_COLLAPSE: every PR row is visible.
|
||
// - >= PR_LIMIT_BEFORE_COLLAPSE: first (LIMIT - 1) rows are visible and
|
||
// the remainder sits behind a toggle.
|
||
const useCollapse = prs.length >= PR_LIMIT_BEFORE_COLLAPSE;
|
||
const expandedHead = useCollapse ? prs.slice(0, PR_LIMIT_BEFORE_COLLAPSE - 1) : prs;
|
||
const collapsedTail = useCollapse ? prs.slice(PR_LIMIT_BEFORE_COLLAPSE - 1) : [];
|
||
|
||
return (
|
||
<div className="space-y-1">
|
||
{expandedHead.map((pr) => (
|
||
<PullRequestRow key={pr.id} pr={pr} />
|
||
))}
|
||
{useCollapse ? (
|
||
<div className="space-y-1">
|
||
{expanded
|
||
? collapsedTail.map((pr) => <PullRequestRow key={pr.id} pr={pr} />)
|
||
: null}
|
||
<button
|
||
type="button"
|
||
onClick={() => setExpanded((v) => !v)}
|
||
className="block w-[calc(100%+1rem)] -mx-2 rounded-md px-2 py-1.5 text-left text-micro text-muted-foreground hover:bg-accent/50 hover:text-foreground transition-colors"
|
||
>
|
||
{expanded
|
||
? t(($) => $.detail.pull_request_card_show_less)
|
||
: t(($) => $.detail.pull_request_card_show_more, { count: collapsedTail.length })}
|
||
</button>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function PullRequestRow({ pr }: { pr: GitHubPullRequest }) {
|
||
const { t } = useT("issues");
|
||
const cfg = STATE_ICON[pr.state] ?? { icon: GitPullRequest, className: "" };
|
||
const StateIcon = cfg.icon;
|
||
const isDraft = pr.state === "draft";
|
||
const stateLabel = getStateLabel(pr.state, t);
|
||
|
||
return (
|
||
<a
|
||
data-testid="pull-request-row"
|
||
href={pr.html_url}
|
||
target="_blank"
|
||
rel="noreferrer noopener"
|
||
className={cn(
|
||
"flex items-start gap-2 rounded-md px-2 py-1.5 -mx-2 hover:bg-accent/50 transition-colors group",
|
||
isDraft ? "opacity-80" : null,
|
||
)}
|
||
>
|
||
<StateIcon className={cn("h-3.5 w-3.5 mt-0.5 shrink-0", cfg.className)} />
|
||
<div className="min-w-0 flex-1">
|
||
<p className="text-caption font-medium leading-snug truncate group-hover:text-foreground">
|
||
{pr.title}
|
||
</p>
|
||
<p className="text-micro text-muted-foreground truncate">
|
||
{pr.repo_owner}/{pr.repo_name}#{pr.number} · {stateLabel}
|
||
{pr.author_login ? ` · @${pr.author_login}` : null}
|
||
</p>
|
||
<PullRequestRowDetails pr={pr} />
|
||
</div>
|
||
</a>
|
||
);
|
||
}
|
||
|
||
function PullRequestRowDetails({ pr }: { pr: GitHubPullRequest }) {
|
||
const { t } = useT("issues");
|
||
const timeAgo = useTimeAgo();
|
||
|
||
const showStats = shouldShowPullRequestStats({
|
||
additions: pr.additions,
|
||
deletions: pr.deletions,
|
||
changed_files: pr.changed_files,
|
||
});
|
||
|
||
// Neither status element is shown for terminal PRs — the leading state icon
|
||
// already conveys merged / closed, and CI / mergeability are no longer
|
||
// actionable there.
|
||
const isTerminal = pr.state === "merged" || pr.state === "closed";
|
||
const checksBadge = isTerminal ? null : getChecksBadge(deriveChecksStatus(pr), t);
|
||
const mergeBadge = isTerminal ? null : getMergeBadge(deriveMergeStatus(pr), t);
|
||
|
||
// A stale snapshot (GitHub outage / revoked key) greys out both elements and
|
||
// annotates them with the snapshot age instead of hiding the last-known data.
|
||
const stale = !isTerminal && pr.snapshot_stale === true;
|
||
const staleTitle = stale
|
||
? pr.snapshot_fetched_at
|
||
? t(($) => $.detail.pull_request_snapshot_stale, { time: timeAgo(pr.snapshot_fetched_at) })
|
||
: t(($) => $.detail.pull_request_snapshot_stale_unknown)
|
||
: undefined;
|
||
|
||
if (!showStats && !checksBadge && !mergeBadge) return null;
|
||
|
||
return (
|
||
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-micro text-muted-foreground">
|
||
{showStats ? <PullRequestStats pr={pr} /> : null}
|
||
{checksBadge ? <PullRequestBadge badge={checksBadge} stale={stale} title={staleTitle} /> : null}
|
||
{mergeBadge ? <PullRequestBadge badge={mergeBadge} stale={stale} title={staleTitle} /> : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function PullRequestStats({ pr }: { pr: GitHubPullRequest }) {
|
||
const { t } = useT("issues");
|
||
return (
|
||
<span className="inline-flex items-center gap-1.5 tabular-nums">
|
||
<span className="text-emerald-600 dark:text-emerald-400">+{pr.additions ?? 0}</span>
|
||
<span className="text-rose-600 dark:text-rose-400">−{pr.deletions ?? 0}</span>
|
||
<span aria-hidden="true">·</span>
|
||
<span>
|
||
{t(($) => $.detail.pull_request_card_files_count, {
|
||
count: pr.changed_files ?? 0,
|
||
})}
|
||
</span>
|
||
</span>
|
||
);
|
||
}
|
||
|
||
interface PullRequestBadgeConfig {
|
||
icon: React.ComponentType<{ className?: string }>;
|
||
label: string;
|
||
className: string;
|
||
}
|
||
|
||
function PullRequestBadge({
|
||
badge,
|
||
stale,
|
||
title,
|
||
}: {
|
||
badge: PullRequestBadgeConfig;
|
||
stale?: boolean;
|
||
title?: string;
|
||
}) {
|
||
const Icon = badge.icon;
|
||
return (
|
||
<span
|
||
className={cn("inline-flex items-center gap-1", stale ? "opacity-60" : null)}
|
||
title={title}
|
||
>
|
||
<Icon className={cn("h-3 w-3", badge.className)} />
|
||
{badge.label}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
// CI element. A current snapshot with a null rollup renders "no checks yet";
|
||
// an unavailable/disabled snapshot renders nothing.
|
||
function getChecksBadge(
|
||
status: PullRequestChecksStatus,
|
||
t: IssuesT,
|
||
): PullRequestBadgeConfig | null {
|
||
switch (status.kind) {
|
||
case "failed":
|
||
return {
|
||
icon: XCircle,
|
||
className: "text-rose-600 dark:text-rose-400",
|
||
label: checksFailedLabel(status, t),
|
||
};
|
||
case "pending":
|
||
return {
|
||
icon: CircleDashed,
|
||
className: "text-amber-600 dark:text-amber-400",
|
||
label: t(($) => $.detail.pull_request_checks_running, {
|
||
passed: status.passed,
|
||
total: status.total,
|
||
running: status.running,
|
||
}),
|
||
};
|
||
case "passed":
|
||
return {
|
||
icon: CheckCircle2,
|
||
className: "text-emerald-600 dark:text-emerald-400",
|
||
label: t(($) => $.detail.pull_request_checks_all_passed, { total: status.total }),
|
||
};
|
||
case "none":
|
||
return {
|
||
icon: Circle,
|
||
className: "text-muted-foreground",
|
||
label: t(($) => $.detail.pull_request_checks_none),
|
||
};
|
||
case "unavailable":
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function checksFailedLabel(
|
||
status: Extract<PullRequestChecksStatus, { kind: "failed" }>,
|
||
t: IssuesT,
|
||
): string {
|
||
const shown = status.names.slice(0, 2);
|
||
if (shown.length === 0) {
|
||
return t(($) => $.detail.pull_request_checks_failed_count, {
|
||
failed: status.failed,
|
||
total: status.total,
|
||
});
|
||
}
|
||
const remaining = status.names.length - shown.length;
|
||
const parts = [...shown];
|
||
if (remaining > 0) {
|
||
parts.push(t(($) => $.detail.pull_request_checks_more, { count: remaining }));
|
||
}
|
||
return t(($) => $.detail.pull_request_checks_failed_named, {
|
||
failed: status.failed,
|
||
total: status.total,
|
||
names: parts.join(", "),
|
||
});
|
||
}
|
||
|
||
// Mergeability element. Returns null for the "none" state — when GitHub has not
|
||
// decided, the card asserts neither "conflict" nor "ready".
|
||
function getMergeBadge(status: PullRequestMergeStatus, t: IssuesT): PullRequestBadgeConfig | null {
|
||
switch (status.kind) {
|
||
case "conflicting":
|
||
return {
|
||
icon: TriangleAlert,
|
||
className: "text-amber-600 dark:text-amber-400",
|
||
label: t(($) => $.detail.pull_request_merge_conflicting),
|
||
};
|
||
case "ready":
|
||
return {
|
||
icon: CheckCircle2,
|
||
className: "text-emerald-600 dark:text-emerald-400",
|
||
label: t(($) => $.detail.pull_request_merge_ready),
|
||
};
|
||
case "blocked":
|
||
return {
|
||
icon: CircleSlash,
|
||
className: "text-muted-foreground",
|
||
label: t(($) => $.detail.pull_request_merge_blocked),
|
||
};
|
||
case "behind":
|
||
return {
|
||
icon: CircleSlash,
|
||
className: "text-muted-foreground",
|
||
label: t(($) => $.detail.pull_request_merge_behind),
|
||
};
|
||
case "unstable":
|
||
return {
|
||
icon: CircleSlash,
|
||
className: "text-muted-foreground",
|
||
label: t(($) => $.detail.pull_request_merge_unstable),
|
||
};
|
||
case "has_hooks":
|
||
return {
|
||
icon: CircleSlash,
|
||
className: "text-muted-foreground",
|
||
label: t(($) => $.detail.pull_request_merge_has_hooks),
|
||
};
|
||
case "none":
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function getStateLabel(state: GitHubPullRequestState, t: IssuesT): string {
|
||
return state === "open"
|
||
? t(($) => $.detail.pull_request_state_open)
|
||
: state === "draft"
|
||
? t(($) => $.detail.pull_request_state_draft)
|
||
: state === "merged"
|
||
? t(($) => $.detail.pull_request_state_merged)
|
||
: state === "closed"
|
||
? t(($) => $.detail.pull_request_state_closed)
|
||
: state;
|
||
}
|