mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-15 09:16:42 +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>
295 lines
10 KiB
TypeScript
295 lines
10 KiB
TypeScript
"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">“{query}”</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>
|
||
);
|
||
}
|