Files
multica/packages/views/layout/breadcrumb-header.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

68 lines
2.4 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 { Fragment, type ReactNode } from "react";
import { ChevronRight } from "lucide-react";
import { cn } from "@multica/ui/lib/utils";
import { PageHeader } from "./page-header";
import { AppLink } from "../navigation";
/**
* One ancestor crumb. Always a clickable link to the segment's container — the
* breadcrumb expresses a containment chain, so every segment must navigate
* somewhere. Non-navigable chrome (skeletons, "unknown" states) does NOT belong
* here; omit the segment instead.
*/
export interface BreadcrumbSegment {
href: string;
/** Plain text, or a composed node (e.g. icon + label). */
label: ReactNode;
/**
* Overrides the default `shrink-0`. Pass `flex items-center gap-1 min-w-0
* max-w-72` for a truncating segment (e.g. a long project title).
*/
className?: string;
}
interface BreadcrumbHeaderProps {
/** Ancestor links, rendered left-to-right with chevron separators. */
segments: BreadcrumbSegment[];
/** The current page — non-clickable leaf. Caller controls styling/adornments. */
leaf: ReactNode;
/** Right-side actions. Wrapped in a `shrink-0` flex row; omit for none. */
actions?: ReactNode;
className?: string;
}
/**
* Unified detail-page header: `{ancestor ancestor …} leaf [actions]`.
*
* Replaces the per-page hand-rolled breadcrumbs that had drifted into four
* different styles (workspace-name root, `/` separator, back-arrow, raw div).
* The mental model is identical everywhere: the leading crumbs are the thing's
* real containers and clicking one navigates up to it.
*/
export function BreadcrumbHeader({ segments, leaf, actions, className }: BreadcrumbHeaderProps) {
return (
<PageHeader className={cn("gap-2 bg-background text-body", className)}>
<div className="flex flex-1 items-center gap-1.5 min-w-0">
{segments.map((segment) => (
<Fragment key={segment.href}>
<AppLink
href={segment.href}
className={cn(
"text-muted-foreground hover:text-foreground transition-colors",
segment.className ?? "shrink-0",
)}
>
{segment.label}
</AppLink>
<ChevronRight className="h-3 w-3 text-muted-foreground/50 shrink-0" />
</Fragment>
))}
{leaf}
</div>
{actions ? <div className="flex items-center gap-1 shrink-0">{actions}</div> : null}
</PageHeader>
);
}