Files
multica/packages/views/issues/components/pickers/label-picker.tsx
Jiayuan Zhang 7803a5b9ea feat(ui): establish a role-named type scale and migrate ad-hoc font sizes (MUL-5451) (#6136)
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>
2026-07-30 13:42:33 +08:00

295 lines
10 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Tag, Plus, Settings2 } from "lucide-react";
import { toast } from "sonner";
import type { Label } from "@multica/core/types";
import { useWorkspaceId } from "@multica/core/hooks";
import { useWorkspacePaths } from "@multica/core/paths";
import {
labelListOptions,
issueLabelsOptions,
useAttachLabel,
useDetachLabel,
useCreateLabel,
} from "@multica/core/labels";
import { LabelChip } from "../../../labels/label-chip";
import { useNavigation } from "../../../navigation";
import {
PropertyPicker,
PickerItem,
PickerEmpty,
} from "./property-picker";
import { useT } from "../../../i18n";
interface LabelPickerProps {
/**
* The issue whose labels are edited. Omit for **draft mode** (e.g. the
* create-issue dialog, where the issue doesn't exist yet): pass
* `selectedIds` + `onSelectedIdsChange` instead and attach the labels to the
* issue once it's created.
*/
issueId?: string;
/** Draft-mode selection. Ignored when `issueId` is set. */
selectedIds?: string[];
/** Draft-mode change handler. Ignored when `issueId` is set. */
onSelectedIdsChange?: (ids: string[]) => void;
/** Optional controlled open state (for tests / cmd+k integration). */
open?: boolean;
onOpenChange?: (open: boolean) => void;
align?: "start" | "center" | "end";
/** Open the picker on first mount. Used by progressive-disclosure
* sidebars so a newly-added field immediately enters edit state. */
defaultOpen?: boolean;
/** Custom trigger element (e.g. a `PillButton` for the create toolbar).
* When set, the attached-label chips render inside it without their own
* × affordance — a remove <button> can't nest inside a trigger <button>,
* so removal happens by toggling the label off in the open picker. */
triggerRender?: React.ReactElement;
}
/**
* Palette of colors used when creating a label inline from the picker.
* We cycle by hash(name) so the same name always gets the same color,
* and a color can still be changed afterwards from the Manage dialog.
*/
const INLINE_COLORS = [
"#ef4444", "#f97316", "#eab308", "#22c55e", "#14b8a6",
"#3b82f6", "#6366f1", "#a855f7", "#ec4899", "#64748b",
] as const;
function pickInlineColor(name: string): string {
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = (hash * 31 + name.charCodeAt(i)) >>> 0;
}
return INLINE_COLORS[hash % INLINE_COLORS.length] ?? INLINE_COLORS[0]!;
}
/**
* Multi-select label picker. Shows currently-selected labels as inline chips
* on the trigger and lets the user toggle any label in the workspace.
*
* Two modes:
* - **Attached mode** (`issueId` set): attach/detach hit the server
* optimistically — the UI updates before the server confirms.
* - **Draft mode** (`issueId` omitted): selection is held by the caller via
* `selectedIds` / `onSelectedIdsChange`; nothing is persisted until the
* caller attaches the labels itself. Used by the create-issue dialog.
*
* When the search term has no matches, offers inline creation: typing a
* new name and pressing Enter (or clicking the "Create X" row) creates the
* label with a hash-derived color and selects it in one motion. The created
* label is a real workspace label in both modes; only the attach step differs.
*
* A "Manage labels" item at the bottom opens the workspace Labels settings
* page, which is the single management surface for issue, agent, and skill
* label catalogs.
*/
export function LabelPicker({
issueId,
selectedIds = [],
onSelectedIdsChange,
open: controlledOpen,
onOpenChange,
align = "start",
defaultOpen = false,
triggerRender,
}: LabelPickerProps) {
const { t } = useT("issues");
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const open = controlledOpen ?? internalOpen;
const setOpen = onOpenChange ?? setInternalOpen;
const [filter, setFilter] = useState("");
const navigation = useNavigation();
const paths = useWorkspacePaths();
// Synchronous lock to prevent double-submit on rapid Enter / click. React
// state (create.isPending, filter) isn't visible until the next render, so
// two events within the same tick can both pass the canCreate guard and
// fire two create.mutate calls — the second hits 409 and shows a red toast
// for an error the user didn't cause. A ref closes the window cleanly.
const creatingRef = useRef(false);
// Draft mode when no issue exists yet: hold selection in the caller instead
// of hitting the attach/detach endpoints.
const isDraft = issueId === undefined;
const wsId = useWorkspaceId();
const { data: allLabels = [] } = useQuery(labelListOptions(wsId));
// `issueLabelsOptions` disables itself for an empty id, so the draft path
// never fires the by-issue read.
const { data: attachedLabels = [] } = useQuery(issueLabelsOptions(wsId, issueId ?? ""));
// Hooks must run unconditionally; in draft mode the empty id is never used
// because toggle/create route through onSelectedIdsChange instead.
const attach = useAttachLabel(issueId ?? "");
const detach = useDetachLabel(issueId ?? "");
const create = useCreateLabel();
// The selected set drives both the trigger chips and the list checkmarks.
// Draft mode resolves ids against the workspace list (dropping any id whose
// label was deleted meanwhile) and preserves the user's selection order.
const selectedLabels = useMemo<Label[]>(() => {
if (!isDraft) return attachedLabels;
return selectedIds
.map((id) => allLabels.find((l) => l.id === id))
.filter((l): l is Label => Boolean(l));
}, [isDraft, attachedLabels, selectedIds, allLabels]);
const selectedIdSet = useMemo(
() => new Set(selectedLabels.map((l) => l.id)),
[selectedLabels],
);
const query = filter.trim();
const queryLower = query.toLowerCase();
const filtered = allLabels.filter((l) => l.name.toLowerCase().includes(queryLower));
const exactMatch = allLabels.some((l) => l.name.toLowerCase() === queryLower);
const canCreate = query.length > 0 && !exactMatch && !create.isPending;
const removeLabel = (labelId: string) => {
if (isDraft) {
onSelectedIdsChange?.(selectedIds.filter((id) => id !== labelId));
} else {
detach.mutate(labelId);
}
};
const toggle = (labelId: string) => {
if (isDraft) {
onSelectedIdsChange?.(
selectedIdSet.has(labelId)
? selectedIds.filter((id) => id !== labelId)
: [...selectedIds, labelId],
);
} else if (selectedIdSet.has(labelId)) {
detach.mutate(labelId);
} else {
attach.mutate(labelId);
}
};
const createAndAttach = () => {
if (!canCreate || creatingRef.current) return;
creatingRef.current = true;
const name = query;
create.mutate(
{ name, color: pickInlineColor(name) },
{
onSuccess: (label) => {
if (isDraft) {
onSelectedIdsChange?.([...selectedIds, label.id]);
} else {
attach.mutate(label.id);
}
setFilter("");
},
onError: (err: unknown) => {
toast.error(err instanceof Error ? err.message : t(($) => $.pickers.label.create_failed));
},
onSettled: () => {
creatingRef.current = false;
},
},
);
};
const openManage = () => {
setOpen(false);
navigation.push(`${paths.settings()}?tab=labels`);
};
const hasLabels = selectedLabels.length > 0;
// In a custom trigger (PillButton) the trigger is itself a button, so the
// chips can't carry their own remove button. Otherwise fall back to the
// chip-wrap div used by the issue-detail sidebar.
const resolvedTriggerRender =
triggerRender ??
(hasLabels ? (
<div className="flex flex-wrap items-center gap-1 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors" />
) : undefined);
return (
<div className="flex flex-col gap-1.5">
<PropertyPicker
open={open}
onOpenChange={(v: boolean) => {
setOpen(v);
if (!v) setFilter("");
}}
width="w-80"
align={align}
searchable
searchPlaceholder={t(($) => $.pickers.label.search_placeholder)}
onSearchChange={setFilter}
triggerRender={resolvedTriggerRender}
trigger={
hasLabels ? (
<>
{selectedLabels.map((l) => (
<LabelChip
key={l.id}
label={l}
onRemove={triggerRender ? undefined : () => removeLabel(l.id)}
/>
))}
</>
) : (
<>
<Tag className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-muted-foreground">{t(($) => $.pickers.label.trigger_label)}</span>
</>
)
}
footer={
// Rendered outside the arrow-key listbox so keyboard nav doesn't
// treat "Manage labels…" as another label option.
<button
type="button"
onClick={openManage}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-body text-muted-foreground hover:bg-accent transition-colors"
>
<Settings2 className="h-3.5 w-3.5" />
<span>{t(($) => $.pickers.label.manage_action)}</span>
</button>
}
>
{filtered.map((label) => {
const selected = selectedIdSet.has(label.id);
return (
<PickerItem
key={label.id}
selected={selected}
onClick={() => toggle(label.id)}
>
<span
className="inline-block h-3 w-3 shrink-0 rounded-full"
style={{ backgroundColor: label.color }}
aria-hidden
/>
<span className="truncate">{label.name}</span>
</PickerItem>
);
})}
{filtered.length === 0 && !canCreate && <PickerEmpty />}
{canCreate && (
<PickerItem selected={false} onClick={createAndAttach}>
<Plus className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">
{t(($) => $.pickers.label.create_action)} <span className="font-medium">&ldquo;{query}&rdquo;</span>
</span>
<span
className="ml-auto inline-block h-3 w-3 shrink-0 rounded-full"
style={{ backgroundColor: pickInlineColor(query) }}
aria-hidden
/>
</PickerItem>
)}
</PropertyPicker>
</div>
);
}