mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-04 07:34:25 +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>
255 lines
7.6 KiB
TypeScript
255 lines
7.6 KiB
TypeScript
import * as React from 'react'
|
|
import { codeToHtml, bundledLanguages, type BundledLanguage } from 'shiki'
|
|
import { Copy, Check } from "lucide-react"
|
|
import { useTranslation } from "react-i18next"
|
|
import { Button } from "@multica/ui/components/ui/button"
|
|
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip"
|
|
import { cn } from '@multica/ui/lib/utils'
|
|
import { copyText } from '../lib/clipboard'
|
|
import {
|
|
CODE_LIGATURE_CLASS,
|
|
CODE_LIGATURE_DESCENDANT_CLASS,
|
|
} from '@multica/ui/lib/code-style'
|
|
|
|
export interface CodeBlockProps {
|
|
code: string
|
|
language?: string
|
|
className?: string
|
|
/**
|
|
* Render mode affects code block styling:
|
|
* - 'terminal': Minimal, keeps control chars visible
|
|
* - 'minimal': Clean code, basic styling
|
|
* - 'full': Rich styling with background, copy button, etc.
|
|
*/
|
|
mode?: 'terminal' | 'minimal' | 'full'
|
|
}
|
|
|
|
// Map common aliases to Shiki language names
|
|
const LANGUAGE_ALIASES: Record<string, BundledLanguage> = {
|
|
js: 'javascript',
|
|
ts: 'typescript',
|
|
py: 'python',
|
|
sh: 'bash',
|
|
zsh: 'bash',
|
|
yml: 'yaml',
|
|
rb: 'ruby',
|
|
rs: 'rust',
|
|
kt: 'kotlin',
|
|
'objective-c': 'objc',
|
|
objc: 'objc'
|
|
}
|
|
|
|
// Simple LRU cache for highlighted code
|
|
const highlightCache = new Map<string, string>()
|
|
const CACHE_MAX_SIZE = 200
|
|
|
|
function getCacheKey(code: string, lang: string): string {
|
|
return `${lang}:${code}`
|
|
}
|
|
|
|
function isValidLanguage(lang: string): lang is BundledLanguage {
|
|
const normalized = LANGUAGE_ALIASES[lang] || lang
|
|
return normalized in bundledLanguages
|
|
}
|
|
|
|
/**
|
|
* CodeBlock - Syntax highlighted code block using Shiki
|
|
*
|
|
* Uses Shiki dual themes with CSS variables for light/dark switching.
|
|
* No JS-based dark mode detection needed — theme switching is handled
|
|
* entirely via CSS (see globals.css for .shiki/.dark .shiki rules).
|
|
*
|
|
* @see https://shiki.style/guide/dual-themes
|
|
*/
|
|
export function CodeBlock({
|
|
code,
|
|
language = 'text',
|
|
className,
|
|
mode = 'full'
|
|
}: CodeBlockProps): React.JSX.Element {
|
|
const { t } = useTranslation("ui")
|
|
const [highlighted, setHighlighted] = React.useState<string | null>(null)
|
|
const [isLoading, setIsLoading] = React.useState(true)
|
|
const [copied, setCopied] = React.useState(false)
|
|
|
|
// Resolve language alias - keep as string to allow 'text' fallback
|
|
const langLower = language.toLowerCase()
|
|
const resolvedLang: string = LANGUAGE_ALIASES[langLower] || langLower
|
|
|
|
React.useEffect(() => {
|
|
let cancelled = false
|
|
|
|
async function highlight(): Promise<void> {
|
|
const cacheKey = getCacheKey(code, resolvedLang)
|
|
|
|
const cached = highlightCache.get(cacheKey)
|
|
if (cached) {
|
|
if (!cancelled) {
|
|
setHighlighted(cached)
|
|
setIsLoading(false)
|
|
}
|
|
return
|
|
}
|
|
|
|
try {
|
|
// Use valid language or fallback to plaintext
|
|
const lang = isValidLanguage(resolvedLang) ? resolvedLang : 'text'
|
|
|
|
// Dual themes: Shiki outputs CSS variables for both themes in one pass.
|
|
// CSS handles switching via .dark selector (see globals.css).
|
|
const html = await codeToHtml(code, {
|
|
lang,
|
|
themes: {
|
|
light: 'github-light',
|
|
dark: 'github-dark',
|
|
},
|
|
defaultColor: false,
|
|
})
|
|
|
|
// Cache the result
|
|
if (highlightCache.size >= CACHE_MAX_SIZE) {
|
|
const firstKey = highlightCache.keys().next().value
|
|
if (firstKey) highlightCache.delete(firstKey)
|
|
}
|
|
highlightCache.set(cacheKey, html)
|
|
|
|
if (!cancelled) {
|
|
setHighlighted(html)
|
|
setIsLoading(false)
|
|
}
|
|
} catch (error) {
|
|
// Fallback to plain text on error
|
|
console.warn(`Shiki highlighting failed for language "${resolvedLang}":`, error)
|
|
if (!cancelled) {
|
|
setHighlighted(null)
|
|
setIsLoading(false)
|
|
}
|
|
}
|
|
}
|
|
|
|
highlight()
|
|
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [code, resolvedLang])
|
|
|
|
const handleCopy = React.useCallback(async () => {
|
|
if (await copyText(code)) {
|
|
setCopied(true)
|
|
setTimeout(() => setCopied(false), 2000)
|
|
}
|
|
}, [code])
|
|
|
|
// Terminal mode: raw monospace with minimal styling
|
|
if (mode === 'terminal') {
|
|
return (
|
|
<pre className={cn('font-mono text-body whitespace-pre-wrap', CODE_LIGATURE_CLASS, className)}>
|
|
<code className={cn('font-mono', CODE_LIGATURE_CLASS)}>{code}</code>
|
|
</pre>
|
|
)
|
|
}
|
|
|
|
// Minimal mode: just syntax highlighting, no chrome
|
|
if (mode === 'minimal') {
|
|
if (isLoading || !highlighted) {
|
|
return (
|
|
<pre className={cn('font-mono text-body whitespace-pre-wrap', CODE_LIGATURE_CLASS, className)}>
|
|
<code className={cn('font-mono', CODE_LIGATURE_CLASS)}>{code}</code>
|
|
</pre>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
'font-mono text-body [&_pre]:!bg-transparent [&_pre]:!p-0 [&_pre]:whitespace-pre-wrap [&_pre]:break-all [&_code]:!bg-transparent [&_code]:font-mono [&_pre]:font-mono',
|
|
CODE_LIGATURE_CLASS,
|
|
CODE_LIGATURE_DESCENDANT_CLASS,
|
|
className
|
|
)}
|
|
dangerouslySetInnerHTML={{ __html: highlighted }}
|
|
/>
|
|
)
|
|
}
|
|
|
|
// Full mode: rich styling with header and copy button
|
|
return (
|
|
<div
|
|
className={cn(
|
|
'relative group rounded-lg overflow-hidden border bg-muted/30 mb-4 last:mb-0',
|
|
className
|
|
)}
|
|
>
|
|
{/* Language label + copy button */}
|
|
<div className="flex items-center justify-between px-3 py-1.5 bg-muted/50 border-b text-caption">
|
|
<span className="text-muted-foreground font-medium uppercase tracking-wide">
|
|
{resolvedLang !== 'text' ? resolvedLang : t(($) => $.plain_text)}
|
|
</span>
|
|
<Tooltip>
|
|
<TooltipTrigger
|
|
render={
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-xs"
|
|
onClick={handleCopy}
|
|
className="opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-foreground"
|
|
aria-label={t(($) => $.copy_code)}
|
|
>
|
|
{copied ? (
|
|
<Check className="size-3.5 text-success" />
|
|
) : (
|
|
<Copy className="size-3.5" />
|
|
)}
|
|
</Button>
|
|
}
|
|
/>
|
|
<TooltipContent>{t(($) => $.copy_code)}</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
|
|
{/* Code content */}
|
|
<div className="p-3 overflow-x-auto">
|
|
{isLoading || !highlighted ? (
|
|
<pre className={cn('font-mono text-body whitespace-pre-wrap break-all', CODE_LIGATURE_CLASS)}>
|
|
<code className={cn('font-mono', CODE_LIGATURE_CLASS)}>{code}</code>
|
|
</pre>
|
|
) : (
|
|
<div
|
|
className={cn(
|
|
'font-mono text-body [&_pre]:!bg-transparent [&_pre]:!m-0 [&_pre]:!p-0 [&_pre]:whitespace-pre-wrap [&_pre]:break-all [&_code]:!bg-transparent [&_code]:font-mono [&_pre]:font-mono',
|
|
CODE_LIGATURE_CLASS,
|
|
CODE_LIGATURE_DESCENDANT_CLASS
|
|
)}
|
|
dangerouslySetInnerHTML={{ __html: highlighted }}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* InlineCode - Styled inline code span
|
|
* Features: subtle background (3%), subtle border (5%), 75% opacity text
|
|
*/
|
|
export function InlineCode({
|
|
children,
|
|
className
|
|
}: {
|
|
children: React.ReactNode
|
|
className?: string
|
|
}): React.JSX.Element {
|
|
return (
|
|
<code
|
|
className={cn(
|
|
'px-1.5 py-0.5 rounded bg-foreground/[0.03] border border-foreground/[0.05] font-mono text-body text-foreground/75',
|
|
CODE_LIGATURE_CLASS,
|
|
className
|
|
)}
|
|
>
|
|
{children}
|
|
</code>
|
|
)
|
|
}
|