mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-04 17:18:35 +02:00
* fix(ui): replace the text-transparency ladder with solid tones (MUL-5452) Hierarchy was being expressed with transparency: 152 call sites of text-muted-foreground/30..80, 26 of text-foreground/60..90, plus a handful on destructive and current, and a few written as a standalone opacity-* utility instead of a slash alpha. On light surfaces every muted variant failed WCAG AA - /80 reached only 3.78:1 and /40 sat at 1.80:1, below even the 3:1 floor for non-text - because the palette had no step below --muted-foreground, so transparency was the only tool for 'quieter than muted'. The palette now has that step, and it is deliberately non-text: --faint-foreground clears 3:1 (WCAG 1.4.11) on every surface for icons, chevrons, separator glyphs and empty-cell em dashes. There is no room for a third readable text tone - AA caps a lighter text tone 0.018 L away from muted - so text keeps exactly one floor, --muted-foreground. Also fixes text-destructive/70 on a cron error message, which was 3.61:1. This branch changes zero font sizes. The sub-12px half of the issue is MUL-5451's (#6136); keeping the two apart is what makes this one reviewable on its own after #6108 was reverted. apps/web/app/text-contrast.test.ts replaces muted-foreground-contrast.test.ts rather than sitting beside it. It recomputes the floors from tokens.css instead of hard-coding ratios, and fails the build on all four ways to spell the defect: /70, /[0.5], /[50%], and a detached opacity-* in the same class string. Transparency behind hover/focus/disabled stays allowed - the resting state carries the contrast obligation and it is solid. Co-authored-by: multica-agent <github@multica.ai> * fix(ui): correlate transparency across a whole class expression Review found two ways past the guard, both real. A per-literal check cannot see cn("… text-muted-foreground", suppressed && "opacity-60") - one element wearing a colour in one argument and a dim in the next. That split shape is the common one, and it was hiding live violations: the comment trigger chips dimmed aria-pressed label text to 2.55:1 while the sweep reported clean. The second was my own exemption. Accepting any state word within 80 characters let "text-muted-foreground hover:text-foreground opacity-50" through, because the hover: belongs to the colour, not to the opacity. The detector now correlates across a whole cn() call or template literal, splits it into segments that each carry their own condition, and exempts only on the variant prefix the opacity utility itself carries or on the condition governing its segment. Segment splitting is what keeps ${disabled ? "opacity-60" : ""} exempt while flagging its neighbours. Fixed what that surfaced: three trigger-chip controls (the suppressed state is already carried by the avatar's own grayscale, the sentence wording and a solid muted step), a disabled-skill icon chip, and the diff gutter marker. tab-bar's isDragging is exempt - a drag ghost is an in-flight gesture, the same category as :active. The detector now has its own table of thirteen cases. Every hole so far has been silent, so the shapes it must and must not catch are pinned next to the reason each one exists. Co-authored-by: multica-agent <github@multica.ai> * test(ui): cover the faint tone in the cn() merge regression test text-faint-foreground is a new text-<x> class, which is the exact shape that silently broke the sidebar labels in #6108: tailwind-merge cannot tell a size from a colour and drops one of them. The token does resolve correctly today - verified both orders against a size step and against another colour - but the test that exists to catch this was not covering it, so the guarantee rested on nothing. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Lambda <agent@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',
|
|
CODE_LIGATURE_CLASS,
|
|
className
|
|
)}
|
|
>
|
|
{children}
|
|
</code>
|
|
)
|
|
}
|