style(web): unify design system across default frontend

🔤 Typography
- Fix --font-sans stack: 'Public Sans Variable' was never applied (wrong family name), add CJK fallbacks (PingFang SC / Microsoft YaHei UI / Noto Sans SC)
- Sync <html lang> with i18n language for correct CJK glyph selection
- Remove dead fake font system (font-provider.tsx, config/fonts.ts, --font-inter/--font-manrope)
- Typography contract: 3-tier headings, arbitrary text-[10px]/[11px] → text-xs (46 files), no uppercase table headers, font-bold → font-semibold
- Numbers use tabular-nums only; font-mono reserved for keys/IDs/code (29 files)

🎨 Color discipline
- Retire StatusBadge string-hash rainbow (autoColor → neutral); all variants resolve to 5 semantic voices
- Add tintedBadgeClassMap as the single tinted-pill recipe
- Replace hardcoded emerald/amber/rose/sky classes with semantic tokens across ~30 files (timing badges, progress bars, system panels, pricing, rankings, log row tints)
- Drop all !important text overrides and [font-family:var(--font-body)] hacks

🧘 Motion restraint
- Remove admin entrance animations: table row stagger, card hover lift/shadow, button press scale, dashboard shimmer line
- Neuter page-transition stagger components into plain wrappers; page transitions reduced to pure opacity fade
- Decorative motion stays landing-only

📐 Shape
- Single radius source: --radius 1rem → 0.625rem
- Sparklines use solid chart tokens instead of decorative gradients
- PanelWrapper: border-only (no shadow), align radius with Card

📏 Guardrails
- Add "Design System Discipline" section (3.10.1) to web/default/AGENTS.md

 Verified: tsgo typecheck clean, rsbuild production build passes
This commit is contained in:
t0ng7u
2026-07-11 02:02:11 +08:00
parent 0cb741d8da
commit 262ab93123
97 changed files with 561 additions and 863 deletions
+1 -1
View File
@@ -413,7 +413,7 @@ export const CodeBlockFrame = ({
{showToolbar && (
<div className='bg-muted/35 border-border/70 flex min-h-10 items-center gap-2 border-b px-2 py-1.5'>
<div className='min-w-0 flex-1'>
<div className='text-muted-foreground truncate font-mono text-[11px] font-medium tracking-wide uppercase'>
<div className='text-muted-foreground truncate font-mono text-xs font-medium tracking-wide uppercase'>
{title}
</div>
</div>
@@ -110,7 +110,7 @@ function CompactContent<TData>({ row }: { row: Row<TData> }) {
return (
<div key={cell.id} className='min-w-0 flex-1 overflow-hidden'>
{label && (
<div className='text-muted-foreground mb-0.5 text-[10px] leading-none select-none'>
<div className='text-muted-foreground mb-0.5 text-xs leading-none select-none'>
{label}
</div>
)}
@@ -177,7 +177,7 @@ function FallbackContent<TData>({ row }: { row: Row<TData> }) {
key={cell.id}
className='flex items-start justify-between gap-2 overflow-hidden'
>
<span className='text-muted-foreground shrink-0 text-[10px] font-medium select-none'>
<span className='text-muted-foreground shrink-0 text-xs font-medium select-none'>
{label}
</span>
<div className='flex min-w-0 flex-1 items-center justify-end overflow-hidden text-xs'>
@@ -24,10 +24,9 @@ export const staticDataTableClassNames = {
compactHeaderRow: 'hover:bg-transparent',
mutedHeaderRow:
'[background-color:var(--table-header)] hover:[background-color:var(--table-header-hover)]',
compactHeaderCell:
'text-muted-foreground py-2 text-[10px] font-medium tracking-wider uppercase',
compactHeaderCell: 'text-muted-foreground py-2 text-xs font-medium',
compactHeaderCellRight:
'text-muted-foreground py-2 text-right text-[10px] font-medium tracking-wider uppercase',
'text-muted-foreground py-2 text-right text-xs font-medium',
compactCell: 'py-2.5',
compactTopCell: 'py-2.5 align-top',
compactTopNumericCell: 'py-2.5 text-right align-top font-mono',
+1 -1
View File
@@ -98,7 +98,7 @@ export function GroupBadge(props: GroupBadgeProps) {
<span className='max-w-full min-w-0 overflow-hidden'>{badge}</span>
<span
className={cn(
'inline-flex h-5 shrink-0 items-center rounded-full px-1.5 font-mono text-xs leading-none font-medium tabular-nums',
'inline-flex h-5 shrink-0 items-center rounded-full px-1.5 text-xs leading-none font-medium tabular-nums',
getGroupRatioClassName(ratio)
)}
>
+2 -8
View File
@@ -97,10 +97,7 @@ function hardenIsolatedHtml(html: string): string {
template.content.querySelectorAll('a[target="_blank"]').forEach((link) => {
const rel = new Set(
link
.getAttribute('rel')
?.split(/\s+/)
.filter(Boolean) ?? []
link.getAttribute('rel')?.split(/\s+/).filter(Boolean) ?? []
)
rel.add('noopener')
@@ -182,10 +179,7 @@ function IsolatedHtmlContent(props: {
}, [props.html])
return (
<div
ref={containerRef}
className={cn('block w-full', props.className)}
/>
<div ref={containerRef} className={cn('block w-full', props.className)} />
)
}
+1 -1
View File
@@ -229,7 +229,7 @@ export function JsonCodeEditor({
<span
className={cn(
'flex items-center gap-1 text-xs',
jsonStatus.valid ? 'text-emerald-600' : 'text-destructive'
jsonStatus.valid ? 'text-success' : 'text-destructive'
)}
>
{jsonStatus.valid ? (
+1 -1
View File
@@ -66,7 +66,7 @@ export function NavGroup({ title, items }: NavGroupProps) {
return (
<SidebarGroup className='px-2 py-1'>
<SidebarGroupLabel className='text-muted-foreground/70 px-2 text-[11px] font-medium tracking-wider uppercase'>
<SidebarGroupLabel className='text-muted-foreground/70 px-2 text-xs font-medium tracking-wider uppercase'>
{title}
</SidebarGroupLabel>
<SidebarMenu>
@@ -85,7 +85,7 @@ export function SectionPageLayout(props: SectionPageLayoutProps) {
)}
<div className='flex flex-wrap items-center justify-between gap-x-3 gap-y-2 sm:gap-x-4'>
<div className='min-w-0 flex-1'>
<h2 className='truncate text-base font-bold tracking-tight sm:text-lg'>
<h2 className='truncate text-lg font-semibold tracking-tight'>
{title}
</h2>
</div>
+6 -6
View File
@@ -253,7 +253,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = React.memo(
<div
className={cn(
'text-muted-foreground px-2 py-1 font-medium',
isMobile ? 'text-xs' : 'text-[10px]'
isMobile ? 'text-xs' : 'text-xs'
)}
>
{t('{{category}} Models', { category })}
@@ -274,7 +274,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = React.memo(
<div
className={cn(
'truncate font-medium',
isMobile ? 'text-sm' : 'text-[11px]'
isMobile ? 'text-sm' : 'text-xs'
)}
>
<span className='inline'>{model.label}</span>
@@ -400,7 +400,7 @@ export const GroupSelector: React.FC<GroupSelectorProps> = React.memo(
className={isMobile ? '!max-h-full flex-1 p-2' : 'max-h-[240px]'}
>
<CommandGroup>
<div className='text-muted-foreground px-2 py-1 text-[10px] font-medium'>
<div className='text-muted-foreground px-2 py-1 text-xs font-medium'>
{t('Model Group')}
</div>
{groups.map((group) => (
@@ -417,7 +417,7 @@ export const GroupSelector: React.FC<GroupSelectorProps> = React.memo(
>
<div className='flex min-w-0 flex-1 items-center gap-2 pr-4'>
<div className='flex min-w-0 flex-1 flex-col'>
<span className='text-foreground truncate text-[11px] font-medium'>
<span className='text-foreground truncate text-xs font-medium'>
{group.label}
</span>
{(group.desc || group.description) && (
@@ -658,7 +658,7 @@ export const ModelGroupSelector: React.FC<ModelGroupSelectorProps> = ({
<span className='min-w-0 truncate text-xs'>
{currentModel?.label || t('Model')}
</span>
<span className='bg-muted text-muted-foreground hidden max-w-20 shrink-0 rounded px-1.5 py-0.5 text-[10px] sm:inline-flex'>
<span className='bg-muted text-muted-foreground hidden max-w-20 shrink-0 rounded px-1.5 py-0.5 text-xs sm:inline-flex'>
{currentGroup?.label || t('Group')}
</span>
<ChevronsUpDown className='text-muted-foreground ml-auto size-3.5 shrink-0 opacity-60' />
@@ -672,7 +672,7 @@ export const ModelGroupSelector: React.FC<ModelGroupSelectorProps> = ({
!isMobile && modelGroupSelectorLayoutClasses.groupColumn
)}
>
<div className='text-muted-foreground px-1 text-[11px] leading-4 font-medium'>
<div className='text-muted-foreground px-1 text-xs leading-4 font-medium'>
{t('Model Group')}
</div>
<div
+1 -1
View File
@@ -318,7 +318,7 @@ export function NotificationPopover({
{unreadCount > 0 ? (
<Badge
variant='destructive'
className='absolute -top-1 -right-1 flex h-5 min-w-5 items-center justify-center px-1 text-[10px] font-semibold tabular-nums'
className='absolute -top-1 -right-1 flex h-5 min-w-5 items-center justify-center px-1 text-xs font-semibold tabular-nums'
>
{unreadCount > 99 ? '99+' : unreadCount}
</Badge>
+12 -76
View File
@@ -20,16 +20,7 @@ import { Outlet, useRouterState } from '@tanstack/react-router'
import { motion, useReducedMotion, type Variants } from 'motion/react'
import type { ReactNode } from 'react'
import {
CARD_ITEM_VARIANTS,
CARD_STAGGER_VARIANTS,
MOTION_TRANSITION,
MOTION_VARIANTS,
STAGGER_ITEM_VARIANTS,
STAGGER_VARIANTS,
TABLE_ROW_VARIANTS,
TABLE_STAGGER_VARIANTS,
} from '@/lib/motion'
import { MOTION_TRANSITION, MOTION_VARIANTS } from '@/lib/motion'
interface PageTransitionProps {
children: ReactNode
@@ -92,23 +83,13 @@ interface StaggerContainerProps {
variants?: Variants
}
/* Entrance choreography is retired on admin surfaces: content appears
* immediately so tables and dashboards read as instant. The components stay
* as plain wrappers so call sites keep working; decorative motion remains
* available to the landing page via its dedicated landing-* utilities. */
export function StaggerContainer(props: StaggerContainerProps) {
const shouldReduce = useReducedMotion()
if (shouldReduce) {
return <div className={props.className}>{props.children}</div>
}
return (
<motion.div
variants={props.variants ?? STAGGER_VARIANTS}
initial='initial'
animate='animate'
className={props.className}
>
{props.children}
</motion.div>
)
return <div className={props.className}>{props.children}</div>
}
interface StaggerItemProps {
@@ -118,68 +99,23 @@ interface StaggerItemProps {
}
export function StaggerItem(props: StaggerItemProps) {
return (
<motion.div
variants={props.variants ?? STAGGER_ITEM_VARIANTS}
className={props.className}
>
{props.children}
</motion.div>
)
return <div className={props.className}>{props.children}</div>
}
export function TableStaggerContainer(props: StaggerContainerProps) {
const shouldReduce = useReducedMotion()
if (shouldReduce) {
return <>{props.children}</>
}
return (
<motion.tbody
variants={TABLE_STAGGER_VARIANTS}
initial='initial'
animate='animate'
className={props.className}
>
{props.children}
</motion.tbody>
)
return <tbody className={props.className}>{props.children}</tbody>
}
export function TableStaggerRow(props: StaggerItemProps) {
return (
<motion.tr variants={TABLE_ROW_VARIANTS} className={props.className}>
{props.children}
</motion.tr>
)
return <tr className={props.className}>{props.children}</tr>
}
export function CardStaggerContainer(props: StaggerContainerProps) {
const shouldReduce = useReducedMotion()
if (shouldReduce) {
return <div className={props.className}>{props.children}</div>
}
return (
<motion.div
variants={CARD_STAGGER_VARIANTS}
initial='initial'
animate='animate'
className={props.className}
>
{props.children}
</motion.div>
)
return <div className={props.className}>{props.children}</div>
}
export function CardStaggerItem(props: StaggerItemProps) {
return (
<motion.div variants={CARD_ITEM_VARIANTS} className={props.className}>
{props.children}
</motion.div>
)
return <div className={props.className}>{props.children}</div>
}
interface FadeInProps {
+1 -1
View File
@@ -63,7 +63,7 @@ export function ProfileDropdown() {
>
<Avatar className='size-6'>
<AvatarFallback
className={`${avatarFallbackClassName} text-[11px]`}
className={`${avatarFallbackClassName} text-xs`}
style={avatarFallbackStyle}
>
{avatarFallback}
+1 -4
View File
@@ -16,10 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import {
HtmlContent,
type HtmlContentVariant,
} from '@/components/html-content'
import { HtmlContent, type HtmlContentVariant } from '@/components/html-content'
import { Markdown } from '@/components/ui/markdown'
type RichContentMode = 'markdown' | 'html'
+1 -1
View File
@@ -50,7 +50,7 @@ export function Search({ className = '', placeholder }: SearchProps) {
size={16}
/>
<span className='ms-4'>{resolvedPlaceholder}</span>
<kbd className='bg-muted group-hover:bg-accent pointer-events-none absolute end-[0.3rem] top-[0.3rem] hidden h-5 items-center gap-1 rounded border px-1.5 font-mono text-[10px] font-medium opacity-100 select-none sm:flex'>
<kbd className='bg-muted group-hover:bg-accent pointer-events-none absolute end-[0.3rem] top-[0.3rem] hidden h-5 items-center gap-1 rounded border px-1.5 font-mono text-xs font-medium opacity-100 select-none sm:flex'>
<span className='text-xs'></span>
{t('K')}
</kbd>
+42 -23
View File
@@ -21,30 +21,34 @@ For commercial licensing, please contact support@quantumnous.com
import * as React from 'react'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { stringToColor } from '@/lib/colors'
import { cn } from '@/lib/utils'
/* Color discipline: badges speak exactly five voices — success / warning /
* danger / info / neutral. Legacy hue keys (blue, purple, teal, ...) remain
* valid variant names for compatibility, but they all resolve to one of the
* five semantic tokens so tables stop reading as rainbows. Chart hues stay
* reserved for data visualization. */
export const dotColorMap = {
success: 'bg-success',
warning: 'bg-warning',
danger: 'bg-destructive',
info: 'bg-info',
neutral: 'bg-neutral',
purple: 'bg-chart-4',
purple: 'bg-neutral',
amber: 'bg-warning',
blue: 'bg-chart-1',
cyan: 'bg-chart-2',
blue: 'bg-neutral',
cyan: 'bg-neutral',
green: 'bg-success',
grey: 'bg-neutral',
indigo: 'bg-chart-1',
indigo: 'bg-neutral',
'light-blue': 'bg-info',
'light-green': 'bg-emerald-400',
lime: 'bg-chart-3',
'light-green': 'bg-success',
lime: 'bg-neutral',
orange: 'bg-warning',
pink: 'bg-chart-5',
pink: 'bg-neutral',
red: 'bg-destructive',
teal: 'bg-chart-2',
violet: 'bg-chart-4',
teal: 'bg-neutral',
violet: 'bg-neutral',
yellow: 'bg-warning',
} as const
@@ -54,26 +58,38 @@ export const textColorMap = {
danger: 'text-destructive',
info: 'text-info',
neutral: 'text-muted-foreground',
purple: 'text-chart-4',
purple: 'text-muted-foreground',
amber: 'text-warning',
blue: 'text-chart-1',
cyan: 'text-chart-2',
blue: 'text-muted-foreground',
cyan: 'text-muted-foreground',
green: 'text-success',
grey: 'text-muted-foreground',
indigo: 'text-chart-1',
indigo: 'text-muted-foreground',
'light-blue': 'text-info',
'light-green': 'text-emerald-500 dark:text-emerald-300',
lime: 'text-chart-3',
'light-green': 'text-success',
lime: 'text-muted-foreground',
orange: 'text-warning',
pink: 'text-chart-5',
pink: 'text-muted-foreground',
red: 'text-destructive',
teal: 'text-chart-2',
violet: 'text-chart-4',
teal: 'text-muted-foreground',
violet: 'text-muted-foreground',
yellow: 'text-warning',
} as const
export type StatusVariant = keyof typeof dotColorMap
/** Soft tinted pill surface per semantic voice — the one sanctioned recipe
* for badges that need a filled background (timing pills, duration pills).
* Border/background/text always come from the same token so light and dark
* modes stay consistent without `!important` overrides. */
export const tintedBadgeClassMap = {
success: 'border border-success/25 bg-success/10 text-success',
warning: 'border border-warning/30 bg-warning/10 text-warning',
danger: 'border border-destructive/25 bg-destructive/10 text-destructive',
info: 'border border-info/25 bg-info/10 text-info',
neutral: 'border border-border/60 bg-muted/30 text-muted-foreground',
} as const
/** Controls the visual style of the badge.
* - `badge` — default pill with background and padding (default)
* - `text` — plain text, no background or padding, only color
@@ -112,6 +128,8 @@ export interface StatusBadgeProps extends Omit<
size?: 'sm' | 'md' | 'lg' | null
copyable?: boolean
copyText?: string
/** Deprecated: categorical identity is conveyed by label/icon, not hue.
* Accepted for call-site compatibility; renders as `neutral`. */
autoColor?: string
/** Visual style. Defaults to 'badge'. Can be overridden via StatusBadgeTypeContext. */
type?: StatusBadgeType
@@ -137,9 +155,10 @@ export function StatusBadge({
const contextType = React.useContext(StatusBadgeTypeContext)
const type = typeProp ?? contextType
const computedVariant: StatusVariant = autoColor
? (stringToColor(autoColor) as StatusVariant)
: (variant ?? 'neutral')
// String-hash rainbow coloring is retired; `autoColor` intentionally falls
// through to the neutral voice (see prop doc above).
void autoColor
const computedVariant: StatusVariant = variant ?? 'neutral'
const handleClick = (e: React.MouseEvent<HTMLSpanElement>) => {
if (copyable) {
@@ -166,7 +185,7 @@ export function StatusBadge({
className={cn(
'inline-flex w-fit max-w-full min-w-0 shrink items-center font-medium tracking-normal whitespace-nowrap transition-colors',
isBadge
? cn('rounded-4xl', sizeMap[size ?? 'sm'])
? cn('rounded-md', sizeMap[size ?? 'sm'])
: cn(
textSizeMap[size ?? 'sm'],
type === 'underline' && 'border-b border-current pb-px'
+1 -1
View File
@@ -27,7 +27,7 @@ export function TableId(props: TableIdProps) {
return (
<span
className={cn(
'text-muted-foreground inline-block font-mono tabular-nums',
'text-muted-foreground inline-block tabular-nums',
props.className
)}
>
+1 -1
View File
@@ -269,7 +269,7 @@ function ChartTooltipContent({
</span>
</div>
{item.value != null && (
<span className='text-foreground font-mono font-medium tabular-nums'>
<span className='text-foreground font-medium tabular-nums'>
{typeof item.value === 'number'
? item.value.toLocaleString()
: String(item.value)}
+4 -4
View File
@@ -63,7 +63,7 @@ export function TitledCard({
className={cn('gap-0 overflow-hidden py-0', className)}
>
<CardHeader
className={cn('border-b p-3 !pb-3 sm:p-5 sm:!pb-5', headerClassName)}
className={cn('border-b p-4 !pb-4 sm:p-5 sm:!pb-5', headerClassName)}
>
<div className='flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between'>
<div className='flex min-w-0 items-center gap-3'>
@@ -80,7 +80,7 @@ export function TitledCard({
<div className='min-w-0'>
<CardTitle
className={cn(
'text-lg tracking-tight sm:text-xl',
'text-base font-semibold tracking-tight',
titleClassName
)}
>
@@ -88,7 +88,7 @@ export function TitledCard({
</CardTitle>
{description != null && (
<CardDescription
className={cn('text-xs sm:text-sm', descriptionClassName)}
className={cn('text-sm', descriptionClassName)}
>
{description}
</CardDescription>
@@ -100,7 +100,7 @@ export function TitledCard({
)}
</div>
</CardHeader>
<CardContent className={cn('p-3 sm:p-5', contentClassName)}>
<CardContent className={cn('p-4 sm:p-5', contentClassName)}>
{children}
</CardContent>
</Card>