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>
357 lines
14 KiB
TypeScript
357 lines
14 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
|
|
import { ListTodo, Plus } from "lucide-react";
|
|
import { Button } from "@multica/ui/components/ui/button";
|
|
import { Skeleton } from "@multica/ui/components/ui/skeleton";
|
|
import { cn } from "@multica/ui/lib/utils";
|
|
import { useWorkspaceId } from "@multica/core/hooks";
|
|
import { ViewStoreProvider } from "@multica/core/issues/stores/view-store-context";
|
|
import { getIssueSurfaceViewStore } from "@multica/core/issues/stores/surface-view-store";
|
|
import { issueScopeKey } from "@multica/core/issues/surface/scope";
|
|
import type { Issue } from "@multica/core/types";
|
|
import { BoardView } from "../components/board-view";
|
|
import { BatchActionToolbar } from "../components/batch-action-toolbar";
|
|
import { GanttView } from "../components/gantt-view";
|
|
import { IssuesHeader } from "../components/issues-header";
|
|
import { ListView } from "../components/list-view";
|
|
import { SwimLaneView } from "../components/swimlane-view";
|
|
import { TableView } from "../components/table-view";
|
|
import { useT } from "../../i18n";
|
|
import { IssueContextMenuProvider } from "../actions";
|
|
import { IssueSurfaceActionsProvider } from "./actions-context";
|
|
import { IssueSurfaceSelectionProvider } from "./selection-context";
|
|
import type { IssueCreateDefaults, IssueSurfaceProps } from "./types";
|
|
import {
|
|
useIssueSurfaceController,
|
|
type IssueSurfaceController,
|
|
} from "./use-issue-surface-controller";
|
|
|
|
export interface IssueSurfaceRenderContext {
|
|
controller: IssueSurfaceController;
|
|
issues: Issue[];
|
|
/** The rows the agents-working filter would leave on screen, with this
|
|
* surface's `clientFilter` applied — headers feed it to the working chip
|
|
* so the chip's count is the post-click row count (MUL-4884). Undefined
|
|
* means the set is UNKNOWN (not materialized by the server-backed Table);
|
|
* the chip renders an indeterminate state instead of a number. */
|
|
workingIssues: Issue[] | undefined;
|
|
}
|
|
|
|
interface IssueSurfaceComponentProps extends IssueSurfaceProps {
|
|
renderHeader?: (context: IssueSurfaceRenderContext) => ReactNode;
|
|
renderEmpty?: (context: IssueSurfaceRenderContext) => ReactNode;
|
|
renderLoading?: (context: IssueSurfaceRenderContext) => ReactNode;
|
|
clientFilter?: (issue: Issue) => boolean;
|
|
showClientEmpty?: (context: IssueSurfaceRenderContext) => boolean;
|
|
batchToolbar?: "always" | "list" | "never";
|
|
contentClassName?: string;
|
|
}
|
|
|
|
export function IssueSurface({
|
|
scope,
|
|
modes,
|
|
surfaceKey,
|
|
createDefaults,
|
|
search,
|
|
renderHeader,
|
|
renderEmpty,
|
|
renderLoading,
|
|
clientFilter,
|
|
showClientEmpty,
|
|
batchToolbar = "always",
|
|
contentClassName,
|
|
}: IssueSurfaceComponentProps) {
|
|
const wsId = useWorkspaceId();
|
|
const resolvedSurfaceKey = surfaceKey ?? issueScopeKey(scope);
|
|
const store = useMemo(
|
|
() => getIssueSurfaceViewStore(resolvedSurfaceKey),
|
|
[resolvedSurfaceKey],
|
|
);
|
|
|
|
// Every change of this key tears down and remounts the ENTIRE surface
|
|
// (providers, DnD, all columns/cards) — by design for data-window changes,
|
|
// but expensive enough that unexpected flips are performance bugs. Dev-only
|
|
// breadcrumb so a Performance trace showing double mounts can be tied to
|
|
// the exact key transition.
|
|
const contentKey = `${wsId}:${issueScopeKey(scope)}`;
|
|
useEffect(() => {
|
|
if (process.env.NODE_ENV !== "production") {
|
|
console.warn(`[issue-surface] mount ${contentKey}`);
|
|
}
|
|
}, [contentKey]);
|
|
|
|
return (
|
|
<ViewStoreProvider store={store}>
|
|
{/* Remount on data-window change: the list queries keep the previous
|
|
key's data as a placeholder (keepPreviousData) so sort/filter
|
|
changes within ONE surface never flash a skeleton — but reusing the
|
|
mounted observer across windows made project A's cards impersonate
|
|
project B (with isLoading=false, so no skeleton either) until B's
|
|
fetch landed. A window-keyed remount gives the new window a fresh
|
|
observer: cold window → skeleton, warm window → instant cache hit.
|
|
The window identity is wsId + scope — wsId is required because the
|
|
workspace layout does not remount on workspace switch and two
|
|
workspaces share the same scope key (e.g. "workspace:all"). Keyed
|
|
by data identity, not surfaceKey (view-preference identity). */}
|
|
<IssueSurfaceContent
|
|
key={contentKey}
|
|
scope={scope}
|
|
modes={modes}
|
|
createDefaults={createDefaults}
|
|
search={search}
|
|
renderHeader={renderHeader}
|
|
renderEmpty={renderEmpty}
|
|
renderLoading={renderLoading}
|
|
clientFilter={clientFilter}
|
|
showClientEmpty={showClientEmpty}
|
|
batchToolbar={batchToolbar}
|
|
contentClassName={contentClassName}
|
|
/>
|
|
</ViewStoreProvider>
|
|
);
|
|
}
|
|
|
|
function IssueSurfaceContent({
|
|
scope,
|
|
modes,
|
|
createDefaults,
|
|
search,
|
|
renderHeader,
|
|
renderEmpty,
|
|
renderLoading,
|
|
clientFilter,
|
|
showClientEmpty,
|
|
batchToolbar,
|
|
contentClassName,
|
|
}: Omit<IssueSurfaceComponentProps, "surfaceKey">) {
|
|
const { t } = useT("projects");
|
|
const controller = useIssueSurfaceController({
|
|
scope,
|
|
modes,
|
|
createDefaults,
|
|
search,
|
|
});
|
|
const [tableLoadedIssues, setTableLoadedIssues] = useState<Issue[]>([]);
|
|
const handleTableLoadedIssuesChange = useCallback((next: Issue[]) => {
|
|
setTableLoadedIssues((current) =>
|
|
current.length === next.length &&
|
|
current.every((issue, index) => issue === next[index])
|
|
? current
|
|
: next,
|
|
);
|
|
}, []);
|
|
useEffect(() => {
|
|
if (controller.viewMode !== "table") setTableLoadedIssues([]);
|
|
}, [controller.viewMode]);
|
|
const issues = useMemo(
|
|
() =>
|
|
clientFilter
|
|
? controller.issues.filter((issue) => clientFilter(issue))
|
|
: controller.issues,
|
|
[clientFilter, controller.issues],
|
|
);
|
|
const swimlaneIssues = useMemo(
|
|
() =>
|
|
clientFilter
|
|
? controller.swimlaneIssues.filter((issue) => clientFilter(issue))
|
|
: controller.swimlaneIssues,
|
|
[clientFilter, controller.swimlaneIssues],
|
|
);
|
|
// Same clientFilter the rendered rows go through, so the chip's promise
|
|
// survives on surfaces that narrow the list locally (e.g. a search box).
|
|
// An UNKNOWN scope (undefined) passes through untouched — there is nothing
|
|
// to filter and the chip must see it as unknown.
|
|
const workingIssues = useMemo(
|
|
() =>
|
|
clientFilter && controller.workingScopeIssues
|
|
? controller.workingScopeIssues.filter((issue) => clientFilter(issue))
|
|
: controller.workingScopeIssues,
|
|
[clientFilter, controller.workingScopeIssues],
|
|
);
|
|
const renderContext = useMemo(
|
|
() => ({ controller, issues, workingIssues }),
|
|
[controller, issues, workingIssues],
|
|
);
|
|
const openCreateIssue = useCallback(
|
|
(defaults?: IssueCreateDefaults) => {
|
|
controller.openCreateIssue(defaults);
|
|
},
|
|
[controller],
|
|
);
|
|
// Stable reference for BoardView's issues: the inline flatMap allocated a
|
|
// fresh array every render, defeating BoardView's memo.
|
|
const boardIssues = useMemo(
|
|
() =>
|
|
controller.assigneeGroups
|
|
? controller.assigneeGroups.flatMap((group) => group.issues)
|
|
: issues,
|
|
[controller.assigneeGroups, issues],
|
|
);
|
|
const shouldShowClientEmpty =
|
|
!!clientFilter &&
|
|
issues.length === 0 &&
|
|
(showClientEmpty ? showClientEmpty(renderContext) : true);
|
|
const shouldShowBatchToolbar =
|
|
batchToolbar !== "never" &&
|
|
(batchToolbar === "always" ||
|
|
controller.viewMode === "list" ||
|
|
controller.viewMode === "table");
|
|
|
|
return (
|
|
<IssueSurfaceActionsProvider actions={controller.actions}>
|
|
{/* One shared right-click menu for every card/row this surface renders
|
|
— see IssueContextMenuProvider. Inside the actions provider so the
|
|
singleton's useIssueActions routes updates through surface
|
|
actions. */}
|
|
<IssueContextMenuProvider>
|
|
<IssueSurfaceSelectionProvider selection={controller.selection}>
|
|
{renderHeader ? (
|
|
renderHeader(renderContext)
|
|
) : (
|
|
<IssuesHeader
|
|
scopedIssues={controller.surfaceIssues}
|
|
allowGantt={controller.allowGantt}
|
|
isRefreshing={controller.isRefreshing}
|
|
facetCountsExact={
|
|
controller.facetCountsExact
|
|
}
|
|
tableFacetCounts={controller.tableFacetCounts}
|
|
onTableFacetChange={controller.setActiveTableFacet}
|
|
/>
|
|
)}
|
|
{controller.isLoading ? (
|
|
renderLoading ? (
|
|
renderLoading(renderContext)
|
|
) : (
|
|
<IssueSurfaceSkeleton mode={controller.viewMode} />
|
|
)
|
|
) : controller.isEmpty || shouldShowClientEmpty ? (
|
|
renderEmpty ? (
|
|
renderEmpty(renderContext)
|
|
) : (
|
|
<div className="flex flex-1 min-h-0 flex-col items-center justify-center gap-3 text-muted-foreground">
|
|
<ListTodo className="h-10 w-10 text-muted-foreground/40" />
|
|
<p className="text-body">{t(($) => $.detail.empty_issues_title)}</p>
|
|
<p className="text-caption">{t(($) => $.detail.empty_issues_hint)}</p>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="mt-1"
|
|
onClick={() => controller.openCreateIssue()}
|
|
>
|
|
<Plus className="size-3.5 mr-1.5" />
|
|
{t(($) => $.detail.empty_issues_new_button)}
|
|
</Button>
|
|
</div>
|
|
)
|
|
) : (
|
|
<div className={cn("flex flex-col flex-1 min-h-0", contentClassName)}>
|
|
{controller.viewMode === "board" && (
|
|
<BoardView
|
|
issues={boardIssues}
|
|
assigneeGroups={controller.assigneeGroups}
|
|
assigneeGroupQueryKey={controller.assigneeGroupQueryKey}
|
|
assigneeGroupFilter={controller.assigneeGroupFilter}
|
|
visibleStatuses={controller.visibleStatuses}
|
|
hiddenStatuses={controller.hiddenStatuses}
|
|
onMoveIssue={controller.moveIssue}
|
|
childProgressMap={controller.childProgressMap}
|
|
projectMap={controller.projectMap}
|
|
myIssuesScope={controller.loadMoreScope}
|
|
myIssuesFilter={controller.loadMoreFilter}
|
|
sort={controller.sort}
|
|
projectId={controller.projectId}
|
|
onCreateIssue={openCreateIssue}
|
|
statusPagination={controller.statusPagination}
|
|
groupBranches={controller.groupBranches}
|
|
/>
|
|
)}
|
|
{controller.viewMode === "list" && (
|
|
<ListView
|
|
issues={issues}
|
|
visibleStatuses={controller.visibleStatuses}
|
|
childProgressMap={controller.childProgressMap}
|
|
projectMap={controller.projectMap}
|
|
projectId={controller.projectId}
|
|
onMoveIssue={controller.moveIssue}
|
|
onCreateIssue={openCreateIssue}
|
|
statusPagination={controller.statusPagination!}
|
|
/>
|
|
)}
|
|
{controller.viewMode === "table" && (
|
|
<TableView
|
|
serverQuery={controller.tableQuerySpec}
|
|
childProgressMap={controller.childProgressMap}
|
|
search={controller.tableSearch}
|
|
onSearchChange={controller.setTableSearch}
|
|
onLoadedIssuesChange={handleTableLoadedIssuesChange}
|
|
onCreateIssue={openCreateIssue}
|
|
exportIssues={controller.exportTableIssues}
|
|
resolveExportLookups={controller.resolveTableExportLookups}
|
|
/>
|
|
)}
|
|
{controller.viewMode === "gantt" && (
|
|
<GanttView issues={controller.filteredGanttIssues} />
|
|
)}
|
|
{controller.viewMode === "swimlane" && (
|
|
<SwimLaneView
|
|
issues={issues}
|
|
unfilteredIssues={swimlaneIssues}
|
|
activeFilters={controller.activeFilters}
|
|
visibleStatuses={controller.visibleStatuses}
|
|
hiddenStatuses={controller.hiddenStatuses}
|
|
onMoveIssue={controller.moveIssue}
|
|
childProgressMap={controller.childProgressMap}
|
|
projectMap={controller.projectMap}
|
|
myIssuesScope={controller.loadMoreScope}
|
|
myIssuesFilter={controller.loadMoreFilter}
|
|
sort={controller.sort}
|
|
projectId={controller.projectId}
|
|
onCreateIssue={openCreateIssue}
|
|
groupBranches={controller.groupBranches}
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
{shouldShowBatchToolbar && (
|
|
<BatchActionToolbar
|
|
issues={
|
|
controller.viewMode === "table" ? tableLoadedIssues : issues
|
|
}
|
|
/>
|
|
)}
|
|
</IssueSurfaceSelectionProvider>
|
|
</IssueContextMenuProvider>
|
|
</IssueSurfaceActionsProvider>
|
|
);
|
|
}
|
|
|
|
// Table is deliberately absent. It owns its own placeholders, drawn as rows
|
|
// inside its real grid so the header, column widths and toolbar are up before
|
|
// any data is — a surface-level stand-in would replace all of that with bars
|
|
// of a different shape and then jump when the rows arrived.
|
|
function IssueSurfaceSkeleton({ mode }: { mode: string }) {
|
|
if (mode === "list") {
|
|
return (
|
|
<div className="flex min-h-0 flex-1 flex-col gap-1 overflow-y-auto p-2">
|
|
{Array.from({ length: 4 }).map((_, i) => (
|
|
<Skeleton key={i} className="h-10 w-full rounded-lg" />
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-1 min-h-0 gap-4 overflow-x-auto p-4">
|
|
{Array.from({ length: 5 }).map((_, i) => (
|
|
<div key={i} className="flex min-w-52 flex-1 flex-col gap-2">
|
|
<Skeleton className="h-4 w-20" />
|
|
<Skeleton className="h-24 w-full rounded-lg" />
|
|
<Skeleton className="h-24 w-full rounded-lg" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|