Files
multica/packages/views/rich-content/rich-code-block.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

225 lines
7.5 KiB
TypeScript

"use client";
/**
* RichCodeBlock — the ONLY fenced-code dispatcher in the product (MUL-4922).
*
* Every product surface (Chat, Issue description, Comment) reaches fenced code
* through this file. Adding a language branch anywhere else — a per-surface
* `if (lang === "…")` in a message list or a comment card — is exactly the
* drift this sweep deleted, so new languages get added HERE and nowhere else.
*
* Dispatch is on a whole language token, never a substring: `language-htmlbars`
* and `language-mermaidx` are ordinary code, not an HTML preview / diagram.
*
* Upgrading to a rich block additionally requires the fence to be CLOSED (see
* streaming-fence.ts). A half-streamed fence renders as plain source, so
* Mermaid never parses a partial diagram and no iframe is created for HTML that
* is still arriving.
*
* Leaf components (MermaidDiagram / HtmlBlockPreview / lowlight static code)
* are surface-agnostic and shared with the Tiptap editor's NodeViews. They are
* imported by direct path — never through the `editor` barrel — so this module
* does not pull the editor's Tiptap graph into Chat.
*/
import { memo, useEffect, useMemo, useState, type ReactNode } from "react";
import { toHtml } from "hast-util-to-html";
import { Check, Copy } from "lucide-react";
import { cn } from "@multica/ui/lib/utils";
import { copyText } from "@multica/ui/lib/clipboard";
import { useT } from "../i18n";
import {
MermaidDiagram,
MERMAID_SKELETON_HEIGHT_PX,
reservedMermaidHeightPx,
} from "../editor/mermaid-diagram";
import {
HtmlBlockPreview,
HTML_BLOCK_PREVIEW_HEIGHT_PX,
} from "../editor/html-block-preview";
import { highlightCode } from "../editor/syntax-highlight";
import { LazyRichBlock } from "./lazy-rich-block";
/**
* Languages that may become a rich block. Anything else — including unknown
* and absent languages — renders as static highlighted code.
*/
export type RichFenceLanguage = "mermaid" | "html";
export function isRichFenceLanguage(
language: string | undefined,
): language is RichFenceLanguage {
return language === "mermaid" || language === "html";
}
/**
* Whether a fenced block should render as a rich block rather than source.
* Both conditions are required: a rich-capable language AND a closed fence.
*/
export function shouldUpgradeFence(
language: string | undefined,
isFenceClosed: boolean,
): boolean {
return isRichFenceLanguage(language) && isFenceClosed;
}
// Memoized on source so appending text elsewhere in a streaming message does
// not re-run Mermaid's async render or reload an already-mounted iframe. React
// reconciliation keeps the instance mounted (same element type, same position);
// memo additionally keeps it from re-rendering.
const MemoMermaidDiagram = memo(MermaidDiagram);
const MemoHtmlBlockPreview = memo(HtmlBlockPreview);
/**
* Static lowlight-highlighted `<code>`, matching the editable Tiptap code
* block's engine and `.hljs-*` CSS so a fence looks identical in every surface.
*/
export function StaticCodeBody({
language,
body,
className,
}: {
language: string | undefined;
body: string;
className?: string;
}) {
const html = useMemo(() => {
const code = body.replace(/\n$/, "");
try {
const tree = highlightCode(code, language);
return toHtml(tree);
} catch {
return null;
}
}, [body, language]);
if (html == null) {
// Highlighter failure must not blank the code — render it unhighlighted.
return (
<code className={cn("hljs", className)}>{body.replace(/\n$/, "")}</code>
);
}
return (
<code
className={cn("hljs", language && `language-${language}`, className)}
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}
/**
* `<pre>` shell with hover copy chrome, matching the editable code block's
* header (language label + copy) in code-block-view.tsx.
*/
export function CodeBlockShell({
language,
code,
children,
}: {
language?: string;
code: string;
children: ReactNode;
}) {
const { t } = useT("editor");
const [copied, setCopied] = useState(false);
const copyLabel = t(($) => $.code_block.copy_code) || "Copy code";
const handleCopy = async () => {
if (!code) return;
if (await copyText(code)) {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
return (
<div className="code-block-wrapper group/code relative my-3">
<div className="absolute top-0 right-0 z-10 flex items-center gap-1.5 px-2 py-1.5 opacity-0 transition-opacity group-hover/code:opacity-100 focus-within:opacity-100">
{language && (
<span className="text-caption text-muted-foreground select-none">
{language}
</span>
)}
<button
type="button"
onClick={handleCopy}
className="flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
title={copyLabel}
aria-label={copyLabel}
>
{copied ? (
<Check className="h-3.5 w-3.5" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</button>
</div>
{/* No extra right padding: `.rich-text-editor pre` outranks utility
padding classes anyway, and the editable NodeView uses the same
1rem — keeping them identical keeps line wrapping identical. */}
<pre className="!m-0">{children}</pre>
</div>
);
}
/**
* The rich leaf for an upgraded fence. Only reached when shouldUpgradeFence()
* returned true, so the fence is known-closed here.
*
* Both leaves are expensive to instantiate (async Mermaid render / sandboxed
* iframe), so each is wrapped in the near-viewport lazy shell. The reserved
* height comes from the leaf itself, so the shell holds the same space the
* mounted block will occupy.
*/
export function RichFenceBlock({
language,
body,
}: {
language: RichFenceLanguage;
body: string;
}) {
// Split into two components so the Mermaid-only height hook is never called
// conditionally.
if (language === "mermaid") return <MermaidFenceBlock chart={body} />;
return <HtmlFenceBlock html={body} />;
}
/**
* Reserved height for a diagram: the skeleton default on the first frame, then
* the session-cached real height once mounted.
*
* The cache lives in sessionStorage, which the server does not have. Reading it
* during render therefore produces 280px on the server and the cached height in
* a browser with a warm cache — different `style="min-height:…"` on the very
* frame React hydrates, which React reports as an attribute mismatch and does
* NOT repair. Deferring the read to an effect keeps the first frame identical
* everywhere and still gets the zero-shift benefit immediately after.
*/
function useReservedMermaidHeightPx(chart: string): number {
const [height, setHeight] = useState(MERMAID_SKELETON_HEIGHT_PX);
useEffect(() => {
const cached = reservedMermaidHeightPx(chart);
setHeight((current) => (current === cached ? current : cached));
}, [chart]);
return height;
}
function MermaidFenceBlock({ chart }: { chart: string }) {
return (
<LazyRichBlock reservedHeightPx={useReservedMermaidHeightPx(chart)} sourceKey={chart}>
<MemoMermaidDiagram chart={chart} />
</LazyRichBlock>
);
}
function HtmlFenceBlock({ html }: { html: string }) {
// The preview iframe is a fixed height, so this needs no cache and is already
// identical on server and client.
return (
<LazyRichBlock reservedHeightPx={HTML_BLOCK_PREVIEW_HEIGHT_PX} sourceKey={html}>
<MemoHtmlBlockPreview html={html} />
</LazyRichBlock>
);
}