Files
multica/packages/views/issues/components/pickers/custom-property-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

504 lines
15 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import { CalendarDays, ExternalLink } from "lucide-react";
import { toast } from "sonner";
import type { Issue, IssueProperty, IssuePropertyValue } from "@multica/core/types";
import {
useSetIssueProperty,
useUnsetIssueProperty,
} from "@multica/core/properties";
import {
toDateOnly,
dateOnlyToLocalDate,
formatDateOnly,
} from "@multica/core/issues/date";
import { Calendar } from "@multica/ui/components/ui/calendar";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "@multica/ui/components/ui/popover";
import { Button } from "@multica/ui/components/ui/button";
import { Input } from "@multica/ui/components/ui/input";
import { useT } from "../../../i18n";
import { PropertyPicker, PickerItem } from "./property-picker";
/**
* Value editor for one custom property on one issue. The editor shape
* follows the definition type:
*
* select → PropertyPicker with one PickerItem per option
* multi_select → PropertyPicker with toggling items (stays open)
* date → Calendar popover (mirrors DueDatePicker)
* checkbox → Yes / No picker
* text/number/url → popover with an input, Enter commits
*
* Archived definitions render read-only: the popover only offers Clear
* (the server rejects new values on archived properties but always allows
* unset). Unknown types from newer servers degrade to the read-only view.
*/
export function CustomPropertyValueEditor({
issue,
property,
defaultOpen = false,
open,
onOpenChange,
}: {
issue: Issue;
property: IssueProperty;
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}) {
const setProperty = useSetIssueProperty();
const unsetProperty = useUnsetIssueProperty();
const value = issue.properties[property.id];
const onError = (error: unknown) =>
toast.error(error instanceof Error ? error.message : String(error));
return (
<CustomPropertyValueInput
property={property}
value={value}
defaultOpen={defaultOpen}
open={open}
onOpenChange={onOpenChange}
onChange={(next) => {
if (next === undefined) {
unsetProperty.mutate(
{ issueId: issue.id, propertyId: property.id },
{ onError },
);
return;
}
setProperty.mutate(
{ issueId: issue.id, propertyId: property.id, value: next },
{ onError },
);
}}
/>
);
}
/**
* Mutation-free custom-property editor. Create flows use this while an issue
* still exists only as a draft; issue detail wraps it above with the normal
* optimistic mutations.
*/
export function CustomPropertyValueInput({
property,
value,
onChange,
defaultOpen = false,
open: controlledOpen,
onOpenChange: controlledOnOpenChange,
trigger,
triggerRender,
}: {
property: IssueProperty;
value: IssuePropertyValue | undefined;
onChange: (value: IssuePropertyValue | undefined) => void;
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
trigger?: React.ReactNode;
triggerRender?: React.ReactElement<Record<string, unknown>>;
}) {
const { t } = useT("issues");
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const open = controlledOpen ?? internalOpen;
const setOpen = controlledOnOpenChange ?? setInternalOpen;
const hasValue = value !== undefined;
const commit = (next: IssuePropertyValue) => onChange(next);
const clear = () => onChange(undefined);
const valueTrigger = trigger ?? (
<CustomPropertyValueDisplay property={property} value={value} />
);
const emptyLabel = (
<span className="text-muted-foreground">
{t(($) => $.pickers.custom_property.empty)}
</span>
);
const clearFooter = hasValue ? (
<Button
variant="ghost"
size="xs"
onClick={() => {
clear();
setOpen(false);
}}
className="w-full justify-start text-muted-foreground hover:text-foreground"
>
{t(($) => $.pickers.custom_property.clear_action)}
</Button>
) : undefined;
// Archived (or unknown-type) definitions: read-only display; the only
// offered action is Clear so stale values can still be cleaned up.
const readOnly =
property.archived ||
!["select", "multi_select", "date", "checkbox", "text", "number", "url"].includes(
property.type,
);
if (readOnly) {
return (
<PropertyPicker
open={open}
onOpenChange={setOpen}
align="start"
trigger={valueTrigger}
triggerRender={triggerRender}
footer={clearFooter}
>
<p className="px-2 py-1.5 text-caption text-muted-foreground">
{t(($) => $.pickers.custom_property.archived_hint)}
</p>
</PropertyPicker>
);
}
switch (property.type) {
case "select": {
const options = property.config.options ?? [];
return (
<PropertyPicker
open={open}
onOpenChange={setOpen}
align="start"
searchable={options.length > 7}
trigger={valueTrigger}
triggerRender={triggerRender}
footer={clearFooter}
>
{options.map((option) => (
<PickerItem
key={option.id}
selected={value === option.id}
onClick={() => {
commit(option.id);
setOpen(false);
}}
>
<span className="size-2.5 shrink-0 rounded-full" style={{ backgroundColor: option.color }} />
<span className="truncate">{option.name}</span>
</PickerItem>
))}
</PropertyPicker>
);
}
case "multi_select": {
const options = property.config.options ?? [];
const selected = Array.isArray(value) ? value : [];
const toggle = (optionId: string) => {
const next = selected.includes(optionId)
? selected.filter((id) => id !== optionId)
: [...selected, optionId];
if (next.length === 0) clear();
else commit(next);
};
return (
<PropertyPicker
open={open}
onOpenChange={setOpen}
align="start"
searchable={options.length > 7}
trigger={valueTrigger}
triggerRender={triggerRender}
footer={clearFooter}
>
{options.map((option) => (
<PickerItem
key={option.id}
selected={selected.includes(option.id)}
onClick={() => toggle(option.id)}
>
<span className="size-2.5 shrink-0 rounded-full" style={{ backgroundColor: option.color }} />
<span className="truncate">{option.name}</span>
</PickerItem>
))}
</PropertyPicker>
);
}
case "date": {
const date = typeof value === "string" ? dateOnlyToLocalDate(value) : undefined;
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
className={triggerRender ? undefined : "flex items-center gap-1.5 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors overflow-hidden"}
render={triggerRender}
>
{valueTrigger}
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={date}
onSelect={(d: Date | undefined) => {
if (d) commit(toDateOnly(d));
else clear();
setOpen(false);
}}
/>
{date && (
<div className="border-t px-3 py-2">
<Button
variant="ghost"
size="xs"
onClick={() => {
clear();
setOpen(false);
}}
className="text-muted-foreground hover:text-foreground"
>
{t(($) => $.pickers.custom_property.clear_action)}
</Button>
</div>
)}
</PopoverContent>
</Popover>
);
}
case "checkbox":
return (
<PropertyPicker
open={open}
onOpenChange={setOpen}
align="start"
trigger={valueTrigger}
triggerRender={triggerRender}
footer={clearFooter}
>
<PickerItem
selected={value === true}
onClick={() => {
commit(true);
setOpen(false);
}}
>
{t(($) => $.pickers.custom_property.true_label)}
</PickerItem>
<PickerItem
selected={value === false}
onClick={() => {
commit(false);
setOpen(false);
}}
>
{t(($) => $.pickers.custom_property.false_label)}
</PickerItem>
</PropertyPicker>
);
default:
return (
<TextishPropertyEditor
property={property}
value={value}
open={open}
onOpenChange={setOpen}
onCommit={commit}
onClear={clear}
emptyLabel={emptyLabel}
trigger={valueTrigger}
triggerRender={triggerRender}
/>
);
}
}
/** Popover-with-input editor shared by text / number / url. */
function TextishPropertyEditor({
property,
value,
open,
onOpenChange,
onCommit,
onClear,
emptyLabel,
trigger,
triggerRender,
}: {
property: IssueProperty;
value: IssuePropertyValue | undefined;
open: boolean;
onOpenChange: (v: boolean) => void;
onCommit: (next: IssuePropertyValue) => void;
onClear: () => void;
emptyLabel: React.ReactNode;
trigger?: React.ReactNode;
triggerRender?: React.ReactElement<Record<string, unknown>>;
}) {
const { t } = useT("issues");
const [draft, setDraft] = useState("");
useEffect(() => {
if (open) setDraft(value === undefined ? "" : String(value));
}, [open, value]);
const placeholder =
property.type === "url"
? t(($) => $.pickers.custom_property.url_placeholder)
: property.type === "number"
? t(($) => $.pickers.custom_property.number_placeholder)
: t(($) => $.pickers.custom_property.value_placeholder);
const submit = () => {
const trimmed = draft.trim();
if (!trimmed) {
if (value !== undefined) onClear();
onOpenChange(false);
return;
}
if (property.type === "number") {
const parsed = Number(trimmed);
if (Number.isNaN(parsed)) return;
onCommit(parsed);
} else {
onCommit(trimmed);
}
onOpenChange(false);
};
return (
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger
className={triggerRender ? undefined : "flex items-center gap-1.5 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors overflow-hidden"}
render={triggerRender}
>
{trigger ?? (value === undefined ? (
emptyLabel
) : (
<CustomPropertyValueDisplay property={property} value={value} />
))}
</PopoverTrigger>
<PopoverContent className="w-64 p-2" align="start">
<form
onSubmit={(event) => {
event.preventDefault();
submit();
}}
className="flex items-center gap-2"
>
<Input
autoFocus
type={property.type === "number" ? "number" : "text"}
step={property.type === "number" ? "any" : undefined}
inputMode={property.type === "number" ? "decimal" : undefined}
value={draft}
onChange={(event) => setDraft(event.target.value)}
placeholder={placeholder}
className="h-8"
/>
{property.type === "url" && typeof value === "string" && (
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={t(($) => $.pickers.custom_property.open_link)}
onClick={() => window.open(value, "_blank", "noopener,noreferrer")}
>
<ExternalLink className="size-3.5" />
</Button>
)}
</form>
</PopoverContent>
</Popover>
);
}
/**
* Read view of a custom property value, shared by row triggers everywhere
* (sidebar rows now; cards/filters later). Option ids resolve to named,
* colored chips; unknown ids (option deleted from the definition) are
* silently dropped rather than rendering raw UUIDs.
*/
export function CustomPropertyValueDisplay({
property,
value,
}: {
property: IssueProperty;
value: IssuePropertyValue | undefined;
}) {
const { t } = useT("issues");
if (value === undefined) {
return (
<span className="text-muted-foreground">
{t(($) => $.pickers.custom_property.empty)}
</span>
);
}
const options = property.config.options ?? [];
switch (property.type) {
case "select": {
const option = options.find((o) => o.id === value);
if (!option) {
return (
<span className="text-muted-foreground">
{t(($) => $.pickers.custom_property.empty)}
</span>
);
}
return (
<span className="flex min-w-0 items-center gap-1.5">
<span className="size-2.5 shrink-0 rounded-full" style={{ backgroundColor: option.color }} />
<span className="truncate">{option.name}</span>
</span>
);
}
case "multi_select": {
const ids = Array.isArray(value) ? value : [];
const selected = options.filter((o) => ids.includes(o.id));
if (selected.length === 0) {
return (
<span className="text-muted-foreground">
{t(($) => $.pickers.custom_property.empty)}
</span>
);
}
return (
<span className="flex min-w-0 flex-wrap items-center gap-1">
{selected.map((option) => (
<span
key={option.id}
className="inline-flex max-w-32 items-center gap-1 rounded-full border border-surface-border px-1.5 py-px text-micro"
>
<span className="size-2 shrink-0 rounded-full" style={{ backgroundColor: option.color }} />
<span className="truncate">{option.name}</span>
</span>
))}
</span>
);
}
case "date":
return (
<span className="flex items-center gap-1.5">
<CalendarDays className="h-3.5 w-3.5 text-muted-foreground" />
{typeof value === "string"
? formatDateOnly(value, { month: "short", day: "numeric" }, "en-US")
: String(value)}
</span>
);
case "checkbox":
return (
<span>
{value === true
? t(($) => $.pickers.custom_property.true_label)
: t(($) => $.pickers.custom_property.false_label)}
</span>
);
case "url":
return (
<span className="flex min-w-0 items-center gap-1.5">
<ExternalLink className="h-3 w-3 shrink-0 text-muted-foreground" />
<span className="truncate">{String(value)}</span>
</span>
);
default:
return <span className="truncate tabular-nums">{String(value)}</span>;
}
}