feat(client): add semantic typography scale + gallery typography section

Two-layer type tokens (primitive --font-size-N + semantic --text-*) in style.css with a mobile remap, wired to Tailwind fontSize; system font stack; new gallery TypographySection.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Kinyoo
2026-07-14 15:05:28 +08:00
co-authored by Claude Opus 4.8
parent 715ebaf991
commit 324f299ffd
8 changed files with 553 additions and 167 deletions
@@ -6,85 +6,96 @@
* tree-shaken out of the production build. Changing a component shown here changes the
* real shared component, so every business page updates too.
*
* Layout mirrors ant.design/components: left sidebar navigates between component
* pages; the main area shows ONE component page at a time (not a long scroll).
* Default export (required by React.lazy in the route). See docs-ui-refactor/00-总纲.md.
*/
import { useState } from 'react';
import { ComponentType, useState } from 'react';
import { cn } from '~/utils';
import { OverviewSection } from './sections/OverviewSection';
import { TypographySection } from './sections/TypographySection';
import { ModalSection } from './sections/ModalSection';
import { ConfirmDialogSection } from './sections/ConfirmDialogSection';
import { ButtonSection } from './sections/ButtonSection';
import { FeedbackSection } from './sections/FeedbackSection';
interface NavItem {
type Status = 'wip' | 'todo' | 'done';
interface PageDef {
id: string;
label: string;
status?: 'wip' | 'todo' | 'done';
group: string;
status?: Status;
Page: ComponentType;
}
const NAV: NavItem[] = [
{ id: 'overview', label: '总览' },
{ id: 'modal', label: 'Modal 弹窗', status: 'wip' },
{ id: 'confirm', label: '二次确认弹窗', status: 'wip' },
{ id: 'button', label: 'Button 按钮', status: 'todo' },
{ id: 'feedback', label: '点赞点踩反馈', status: 'done' },
/** Registry — one entry per component page. Grouped like antd's sidebar. */
const PAGES: PageDef[] = [
{ id: 'overview', label: '总览', group: '开始', Page: OverviewSection },
{ id: 'typography', label: '字体 Typography', group: '基础 Foundation', status: 'wip', Page: TypographySection },
{ id: 'button', label: 'Button 按钮', group: '通用 General', status: 'todo', Page: ButtonSection },
{ id: 'modal', label: 'Modal 弹窗', group: '反馈 Feedback', status: 'wip', Page: ModalSection },
{ id: 'confirm', label: '二次确认弹窗', group: '反馈 Feedback', status: 'wip', Page: ConfirmDialogSection },
{ id: 'feedback', label: '点赞 / 点踩', group: '反馈 Feedback', status: 'done', Page: FeedbackSection },
];
const STATUS_DOT: Record<NonNullable<NavItem['status']>, string> = {
const STATUS_DOT: Record<Status, string> = {
wip: 'bg-amber-500',
todo: 'bg-gray-300',
done: 'bg-green-500',
};
export default function GalleryApp() {
const [active, setActive] = useState('overview');
/** Preserve group order as declared in PAGES. */
const GROUPS = PAGES.reduce<string[]>((acc, p) => {
if (!acc.includes(p.group)) acc.push(p.group);
return acc;
}, []);
const handleNav = (id: string) => {
setActive(id);
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
export default function GalleryApp() {
const [activeId, setActiveId] = useState('overview');
const active = PAGES.find((p) => p.id === activeId) ?? PAGES[0];
const ActivePage = active.Page;
return (
<div className="flex h-screen w-full overflow-hidden bg-background text-foreground">
{/* Sidebar */}
<aside className="flex w-60 shrink-0 flex-col border-r border-border-light bg-muted/20">
<aside className="flex w-64 shrink-0 flex-col border-r border-border-light bg-muted/20">
<div className="border-b border-border-light px-5 py-4">
<div className="text-base font-semibold text-text-primary"></div>
<div className="mt-0.5 text-xs text-muted-foreground">DEV only · </div>
<div className="text-h4 text-text-primary"></div>
<div className="mt-0.5 text-caption text-muted-foreground">DEV only · </div>
</div>
<nav className="flex-1 overflow-y-auto p-2">
{NAV.map((item) => (
<button
key={item.id}
onClick={() => handleNav(item.id)}
className={cn(
'flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm transition-colors',
active === item.id
? 'bg-blue-500/[0.08] font-medium text-text-primary'
: 'text-muted-foreground hover:bg-muted/60',
)}
>
{item.status && (
<span className={cn('size-1.5 rounded-full', STATUS_DOT[item.status])} />
)}
{item.label}
</button>
<nav className="flex-1 overflow-y-auto p-3">
{GROUPS.map((group) => (
<div key={group} className="mb-4">
<div className="px-3 pb-1 text-caption font-medium uppercase tracking-wide text-muted-foreground">
{group}
</div>
{PAGES.filter((p) => p.group === group).map((p) => (
<button
key={p.id}
onClick={() => setActiveId(p.id)}
className={cn(
'flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-body-sm transition-colors',
activeId === p.id
? 'bg-blue-500/[0.08] font-medium text-text-primary'
: 'text-muted-foreground hover:bg-muted/60',
)}
>
{p.status && <span className={cn('size-1.5 rounded-full', STATUS_DOT[p.status])} />}
<span className="truncate">{p.label}</span>
</button>
))}
</div>
))}
</nav>
<div className="border-t border-border-light px-5 py-3 text-xs text-muted-foreground">
<div className="border-t border-border-light px-5 py-3 text-caption text-muted-foreground">
docs-ui-refactor/
</div>
</aside>
{/* Content */}
<main className="flex-1 overflow-y-auto">
<div className="mx-auto max-w-5xl px-8 py-8">
<OverviewSection />
<ModalSection />
<ConfirmDialogSection />
<ButtonSection />
<FeedbackSection />
</div>
{/* Content — one component page at a time */}
<main key={activeId} className="flex-1 overflow-y-auto">
<ActivePage />
</main>
</div>
);
@@ -1,77 +1,137 @@
/**
* Gallery-only presentational helpers.
* Gallery-only presentational helpers — antd-docs-style layout.
*
* DEV-ONLY internal tooling — this whole `_gallery` folder is never shipped to
* production (the route is gated behind `import.meta.env.DEV`, see routes/index.tsx).
* It exists so the UI designer can eyeball every component's states/variants in the
* real app theme while unifying them. See docs-ui-refactor/00-总纲.md.
*
* Structure mirrors ant.design/components/*: each component is ONE page —
* ComponentPage(title + description + 何时使用/规则)
* └─ ExampleGroup(子标题)
* └─ ExampleGrid → ExampleCard(演示区 + 分隔线 + 说明)
*/
import { ReactNode } from 'react';
import { cn } from '~/utils';
/** A titled block that groups one component's examples. */
export function Section({
id,
/* ------------------------------------------------------------------ *
* Page shell — the antd component-doc page skeleton.
* ------------------------------------------------------------------ */
export function ComponentPage({
title,
eng,
description,
whenToUse,
children,
}: {
title: string;
eng?: string;
description?: ReactNode;
/** 何时使用 / 使用规则 — rendered as a bordered bullet list, antd-style. */
whenToUse?: ReactNode[];
children: ReactNode;
}) {
return (
<article className="mx-auto max-w-4xl px-8 py-10">
<header className="mb-8">
<h1 className="flex items-baseline gap-3 text-h1 text-text-primary">
{title}
{eng && <span className="text-h4 font-normal text-muted-foreground">{eng}</span>}
</h1>
{description && (
<p className="mt-3 max-w-3xl text-body text-text-primary">{description}</p>
)}
</header>
{whenToUse && whenToUse.length > 0 && (
<section className="mb-10">
<h2 className="mb-3 text-h3 text-text-primary">使 / </h2>
<ul className="space-y-2 rounded-xl border border-border-light bg-muted/20 p-5">
{whenToUse.map((item, i) => (
<li key={i} className="flex gap-2 text-body-sm text-text-primary">
<span className="mt-2 size-1.5 shrink-0 rounded-full bg-blue-500" />
<span>{item}</span>
</li>
))}
</ul>
</section>
)}
<section>
<h2 className="mb-4 text-h3 text-text-primary"> / </h2>
{children}
</section>
</article>
);
}
/* ------------------------------------------------------------------ *
* Example group — a titled cluster of examples within a page.
* ------------------------------------------------------------------ */
export function ExampleGroup({
title,
subtitle,
children,
}: {
id?: string;
title: string;
title?: string;
subtitle?: ReactNode;
children: ReactNode;
}) {
return (
<section id={id} className="mb-16 scroll-mt-6">
<div className="mb-4 border-b border-border-light pb-3">
<h2 className="text-xl font-semibold text-text-primary">{title}</h2>
{subtitle && <p className="mt-1 text-sm text-muted-foreground">{subtitle}</p>}
</div>
<div className="mb-8">
{title && <h3 className="mb-1 text-h4 text-text-primary">{title}</h3>}
{subtitle && <p className="mb-3 text-body-sm text-muted-foreground">{subtitle}</p>}
{!subtitle && title && <div className="mb-3" />}
{children}
</section>
</div>
);
}
/** A labeled example cell — shows one variant/state with a caption underneath. */
export function Demo({
label,
note,
/* ------------------------------------------------------------------ *
* Example card — antd demo cell: live demo on top, meta below a divider.
* ------------------------------------------------------------------ */
export function ExampleCard({
title,
description,
className,
children,
}: {
label: string;
note?: string;
title: string;
description?: ReactNode;
className?: string;
children: ReactNode;
}) {
return (
<div
className={cn(
'flex flex-col gap-3 rounded-xl border border-border-light bg-background p-5',
className,
)}
>
<div className="flex min-h-[64px] flex-wrap items-center gap-3">{children}</div>
<div className="mt-auto">
<div className="text-sm font-medium text-text-primary">{label}</div>
{note && <div className="mt-0.5 text-xs text-muted-foreground">{note}</div>}
<div className={cn('overflow-hidden rounded-xl border border-border-light bg-background', className)}>
<div className="flex min-h-[88px] flex-wrap items-center gap-3 p-6">{children}</div>
<div className="border-t border-dashed border-border-light px-5 py-3">
<div className="text-body-sm font-medium text-text-primary">{title}</div>
{description && <div className="mt-0.5 text-caption text-muted-foreground">{description}</div>}
</div>
</div>
);
}
/** Responsive grid for laying out Demo cells. */
export function DemoGrid({ children, cols = 3 }: { children: ReactNode; cols?: 2 | 3 | 4 }) {
/** Responsive grid for laying out ExampleCard / Demo cells. */
export function ExampleGrid({ children, cols = 3 }: { children: ReactNode; cols?: 1 | 2 | 3 | 4 }) {
const colClass =
cols === 2
? 'sm:grid-cols-2'
: cols === 4
? 'sm:grid-cols-2 lg:grid-cols-4'
: 'sm:grid-cols-2 lg:grid-cols-3';
cols === 1
? ''
: cols === 2
? 'sm:grid-cols-2'
: cols === 4
? 'sm:grid-cols-2 lg:grid-cols-4'
: 'sm:grid-cols-2 lg:grid-cols-3';
return <div className={cn('grid grid-cols-1 gap-4', colClass)}>{children}</div>;
}
/** A small comparison table for documenting differences between variants. */
/* ------------------------------------------------------------------ *
* A small comparison table for documenting differences between variants.
* ------------------------------------------------------------------ */
export function CompareTable({
head,
rows,
@@ -81,11 +141,11 @@ export function CompareTable({
}) {
return (
<div className="overflow-x-auto rounded-xl border border-border-light">
<table className="w-full text-left text-sm">
<table className="w-full text-left text-body-sm">
<thead className="bg-muted/40">
<tr>
{head.map((h, i) => (
<th key={i} className="px-4 py-2.5 font-medium text-text-primary">
<th key={i} className="whitespace-nowrap px-4 py-2.5 font-medium text-text-primary">
{h}
</th>
))}
@@ -106,3 +166,52 @@ export function CompareTable({
</div>
);
}
/* ------------------------------------------------------------------ *
* Legacy aliases — kept so not-yet-migrated sections still compile.
* New pages should use ComponentPage / ExampleGroup / ExampleCard / ExampleGrid.
* TODO(gallery): migrate Modal / ConfirmDialog sections to the new template.
* ------------------------------------------------------------------ */
/** @deprecated use ExampleGroup inside a ComponentPage. */
export function Section({
id,
title,
subtitle,
children,
}: {
id?: string;
title: string;
subtitle?: ReactNode;
children: ReactNode;
}) {
return (
<section id={id} className="mx-auto max-w-4xl px-8 py-10">
<div className="mb-6">
<h1 className="text-h1 text-text-primary">{title}</h1>
{subtitle && <p className="mt-3 max-w-3xl text-body text-text-primary">{subtitle}</p>}
</div>
{children}
</section>
);
}
/** @deprecated use ExampleCard. */
export function Demo({
label,
note,
className,
children,
}: {
label: string;
note?: string;
className?: string;
children: ReactNode;
}) {
return <ExampleCard title={label} description={note} className={className}>{children}</ExampleCard>;
}
/** @deprecated use ExampleGrid. */
export function DemoGrid({ children, cols = 3 }: { children: ReactNode; cols?: 1 | 2 | 3 | 4 }) {
return <ExampleGrid cols={cols}>{children}</ExampleGrid>;
}
@@ -4,7 +4,7 @@
* components should expose variants/sizes. See docs-ui-refactor/01-设计规范.md.
*/
import { Button } from '~/components/ui/Button';
import { Section, Demo, DemoGrid } from '../components/kit';
import { ComponentPage, ExampleGroup, ExampleGrid, ExampleCard } from '../components/kit';
const VARIANTS = [
'default',
@@ -21,35 +21,44 @@ const SIZES = ['sm', 'default', 'lg', 'icon'] as const;
export function ButtonSection() {
return (
<Section
id="button"
<ComponentPage
title="Button 按钮"
subtitle={
eng="Button"
description={
<>
<code>cva</code> "留改动余地" + {' '}
<code>className</code>
<code>cva</code> "留改动余地" +
<code>className</code>
</>
}
whenToUse={[
<> <code>default</code>绿 <code>destructive</code></>,
<> <code>outline</code> / <code>secondary</code> <code>ghost</code> / <code>link</code></>,
<> <code>sm / default / lg / icon</code> /</>,
<> <code>className</code> </>,
]}
>
<DemoGrid cols={4}>
{VARIANTS.map((v) => (
<Demo key={v} label={`variant="${v}"`}>
<Button variant={v}>{v}</Button>
<Button variant={v} disabled>
disabled
</Button>
</Demo>
))}
</DemoGrid>
<ExampleGroup title="变体 variant" subtitle="每格含常态与 disabled 态">
<ExampleGrid cols={4}>
{VARIANTS.map((v) => (
<ExampleCard key={v} title={`variant="${v}"`}>
<Button variant={v}>{v}</Button>
<Button variant={v} disabled>
disabled
</Button>
</ExampleCard>
))}
</ExampleGrid>
</ExampleGroup>
<h3 className="mb-3 mt-8 text-sm font-medium text-text-primary"> size</h3>
<DemoGrid cols={4}>
{SIZES.map((s) => (
<Demo key={s} label={`size="${s}"`}>
<Button size={s}>{s === 'icon' ? '★' : s}</Button>
</Demo>
))}
</DemoGrid>
</Section>
<ExampleGroup title="尺寸 size">
<ExampleGrid cols={4}>
{SIZES.map((s) => (
<ExampleCard key={s} title={`size="${s}"`}>
<Button size={s}>{s === 'icon' ? '★' : s}</Button>
</ExampleCard>
))}
</ExampleGrid>
</ExampleGroup>
</ComponentPage>
);
}
@@ -10,47 +10,52 @@
*/
import { useState } from 'react';
import { MessageFeedbackButtons } from '~/components/Chat/MessageFeedbackButtons';
import { Section, Demo, DemoGrid } from '../components/kit';
import { ComponentPage, ExampleGroup, ExampleGrid, ExampleCard } from '../components/kit';
function LoggedDemo({ liked }: { liked?: number }) {
const [last, setLast] = useState<string>('—');
return (
<div className="flex items-center gap-4">
<MessageFeedbackButtons
liked={liked}
onLike={(l) => setLast(`onLike(${l})`)}
onDislikeComment={(c) => setLast(`onDislikeComment("${c}")`)}
/>
<span className="text-xs text-muted-foreground">{last}</span>
</div>
);
const [last, setLast] = useState<string>('—');
return (
<div className="flex items-center gap-4">
<MessageFeedbackButtons
liked={liked}
onLike={(l) => setLast(`onLike(${l})`)}
onDislikeComment={(c) => setLast(`onDislikeComment("${c}")`)}
/>
<span className="text-caption text-muted-foreground">{last}</span>
</div>
);
}
export function FeedbackSection() {
return (
<Section
id="feedback"
title="点赞 / 点踩反馈"
subtitle={
<>
<code>MessageFeedbackButtons</code> 6 AI / /
3 / / appChat<b></b>
= 12 / 20 / 32 · 14px ·
400 · 6
</>
}
>
<DemoGrid cols={3}>
<Demo label="初始未评价" note="点踩先弹窗,提交后才高亮;取消不留痕">
<LoggedDemo />
</Demo>
<Demo label="已点赞态(liked=1" note="点踩弹窗取消后应保持点赞高亮">
<LoggedDemo liked={1} />
</Demo>
<Demo label="已点踩态(liked=2" note="再点踩=直接取消(onLike(0)),不弹窗">
<LoggedDemo liked={2} />
</Demo>
</DemoGrid>
</Section>
);
return (
<ComponentPage
title="点赞 / 点踩反馈"
eng="Message Feedback"
description={
<>
<code>MessageFeedbackButtons</code> 6 AI / /
3 / / appChat
</>
}
whenToUse={[
<><b></b>= </>,
<> = <code>onLike(0)</code></>,
<> 12 / 20 / 32 · 14px · 400 · 6</>,
]}
>
<ExampleGroup title="三种初始状态">
<ExampleGrid cols={3}>
<ExampleCard title="初始未评价" description="点踩先弹窗,提交后才高亮;取消不留痕">
<LoggedDemo />
</ExampleCard>
<ExampleCard title="已点赞态(liked=1" description="点踩弹窗取消后应保持点赞高亮">
<LoggedDemo liked={1} />
</ExampleCard>
<ExampleCard title="已点踩态(liked=2" description="再点踩=直接取消(onLike(0)),不弹窗">
<LoggedDemo liked={2} />
</ExampleCard>
</ExampleGrid>
</ExampleGroup>
</ComponentPage>
);
}
@@ -1,26 +1,28 @@
/** Gallery overview — DEV-ONLY. High-level status of the unification effort. */
import { Section, CompareTable } from '../components/kit';
import { ComponentPage, CompareTable } from '../components/kit';
export function OverviewSection() {
return (
<Section
id="overview"
title="组件统一化 · 总览"
subtitle="本页仅开发环境可见,用户看不到。改这里用到的组件 = 改真实业务组件,全站同步生效。"
<ComponentPage
title="组件统一化"
eng="Overview"
description="把 client 前台重复、样式不一致的高频组件逐个统一,最终抽成可复用的设计组件库。本页仅开发环境可见,用户看不到。"
whenToUse={[
<> = <b></b></>,
<></>,
<> <code>docs-ui-refactor/00-.md</code></>,
]}
>
<p className="mb-4 max-w-3xl text-sm text-text-primary">
client
<code>docs-ui-refactor/00-.md</code>
</p>
<CompareTable
head={['组件', '状态', '现有版本', '收敛基准']}
rows={[
['字体 Typography', '🟨 进行中', 'Tailwind 默认档', '九档 semantic token(已落地)'],
['Modal 弹窗', '🟨 进行中', '4(含 1 死代码)', '待定 · OGDialogTemplate 候选'],
['Select / 下拉', '⬜ 待办', '多个', '待定'],
['Button 按钮', '⬜ 待办', '1(已 cva 化,较好)', 'Button.tsx'],
['Select / 下拉', '⬜ 待办', '多个', '待定'],
['Dropdown 菜单', '⬜ 待办', '2', '待定'],
]}
/>
</Section>
</ComponentPage>
);
}
@@ -0,0 +1,169 @@
/**
* Typography section — showcases the semantic type scale from
* docs-ui-refactor/基础-字体规范.md so the designer can visually verify it.
*
* DEV-ONLY internal tooling, never shipped (route gated by import.meta.env.DEV).
* The classes shown here (text-caption ... text-metric) are real Tailwind
* classes generated from theme.extend.fontSize + CSS vars in src/style.css.
* Resize the window below 768px to see the mobile remap (same classNames).
*/
import { ComponentPage, ExampleGroup, CompareTable } from '../components/kit';
interface TypeStep {
/** Tailwind className, which is also the semantic token name. */
cls: string;
/** Desktop size / line-height in px. */
desktop: string;
/** Mobile (≤768px) size / line-height in px. */
mobile: string;
weight: 400 | 500;
usage: string;
}
/** Ordered small → large, matching the spec's §2 semantic table. */
const SCALE: TypeStep[] = [
{ cls: 'text-caption', desktop: '12 / 20', mobile: '12 / 20', weight: 400, usage: '时间戳、标签、水印' },
{ cls: 'text-body-sm', desktop: '13 / 21', mobile: '14 / 22', weight: 400, usage: '密集表格、侧栏次要项' },
{ cls: 'text-body', desktop: '14 / 22', mobile: '16 / 24', weight: 400, usage: '正文基准,表单、表格默认' },
{ cls: 'text-h4', desktop: '16 / 24', mobile: '16 / 24', weight: 500, usage: '强调正文、四级标题' },
{ cls: 'text-h3', desktop: '18 / 26', mobile: '17 / 25', weight: 500, usage: '卡片标题' },
{ cls: 'text-h2', desktop: '20 / 28', mobile: '18 / 26', weight: 500, usage: '区块标题' },
{ cls: 'text-h1', desktop: '24 / 32', mobile: '22 / 30', weight: 500, usage: '页面标题' },
{ cls: 'text-display', desktop: '30 / 38', mobile: '26 / 34', weight: 500, usage: '大标题、营销场景' },
{ cls: 'text-metric', desktop: '36 / 44', mobile: '30 / 38', weight: 500, usage: 'Dashboard 核心指标数字' },
];
const SAMPLE_ZH = '毕昇平台让大模型应用触手可及';
const SAMPLE_EN = 'The quick brown fox jumps over the lazy dog 0123456789';
interface FontStack {
/** Tailwind className (also usable in plain CSS via the stack value). */
cls: string;
/** Design token name in the spec (§1). */
token: string;
usage: string;
/** The actual comma-joined stack, for display. */
stack: string;
/** Live sample lines rendered with this stack. */
samples: string[];
}
/** §1 font stacks — pure system fonts, zero webfont loading. */
const FONT_STACKS: FontStack[] = [
{
cls: 'font-sans',
token: 'font-family-base',
usage: '全局默认(已写入 body/html,无需显式加类)',
stack:
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC", sans-serif',
samples: [SAMPLE_ZH, SAMPLE_EN],
},
{
cls: 'font-mono',
token: 'font-family-mono',
usage: 'ID、代码、日志',
stack: 'ui-monospace, "SF Mono", "Cascadia Mono", Consolas, "Liberation Mono", monospace',
samples: ['wf_a1b2c3 · 0123456789', 'const answer = 42; // code sample'],
},
];
export function TypographySection() {
return (
<ComponentPage
title="字体 Typography"
eng="Typography"
description="九档 semantic 字号(自带字重)+ 两档字重。系统字体栈已全量切换;窗口缩到 ≤768px 可看移动端重映射(className 不变)。"
whenToUse={[
<> semantic <code>text-body</code> / <code>text-h1</code> <code>text-sm</code> </>,
<> <code>font-normal</code>(400) / <code>font-medium</code>(500) 600/700</>,
<>使h1h2h3</>,
<>/ <code>tabular-nums</code>ID <code>font-mono</code></>,
]}
>
<ExampleGroup
title="字体栈 Font Family"
subtitle="纯系统字体栈,零加载成本:Mac/iOS 命中 SF + 苹方,Windows 命中 Segoe UI + 微软雅黑,Android 命中 Roboto + 思源黑体。两步法第①步已切栈,旧字体文件(Inter / 阿里普惠体 / Roboto Mono)保留待回归后删除。"
>
<CompareTable
head={['Token / 类名', '实时示例 + 完整字体栈', '用途']}
rows={FONT_STACKS.map((f) => [
<div key="t" className="whitespace-nowrap">
<code className="text-body-sm text-text-primary">{f.token}</code>
<div className="mt-0.5 text-caption">
<code>{f.cls}</code>
</div>
</div>,
<div key="s" className="min-w-[20rem]">
<div className="text-text-primary">
{f.samples.map((s) => (
<div key={s} className={`${f.cls} truncate text-h4 font-normal`}>
{s}
</div>
))}
</div>
<div className="mt-1.5 break-all font-mono text-caption">{f.stack}</div>
</div>,
f.usage,
])}
/>
</ExampleGroup>
<ExampleGroup
title="字号阶梯 Semantic"
subtitle="与规范 §2 Semantic 表同构,末列为该档实时渲染。移动列在窗口 ≤768px 时生效(className 不变)。"
>
<CompareTable
head={['Token', '桌面 (size/line)', '移动 (size/line)', '字重', '实时示例(内容即用途)']}
rows={SCALE.map((step) => [
<code key="t" className="whitespace-nowrap text-body-sm text-text-primary">
{step.cls}
</code>,
<span key="d" className="whitespace-nowrap tabular-nums">{step.desktop}</span>,
<span key="m" className="whitespace-nowrap tabular-nums">{step.mobile}</span>,
String(step.weight),
<div key="s" className={`${step.cls} text-text-primary`}>{step.usage}</div>,
])}
/>
</ExampleGroup>
{/* Font weights — the only two allowed steps */}
<ExampleGroup title="字重 Font Weight" subtitle="只使用 400 / 500 两档,禁用 600/700(微软雅黑缺中间字重,会合成粗体)。">
<CompareTable
head={['Token / 类名', '值', '实时示例(内容即用途)']}
rows={[
{ cls: 'font-normal', token: 'font-weight-regular', weight: 400, usage: '正文、说明' },
{ cls: 'font-medium', token: 'font-weight-medium', weight: 500, usage: '标题、强调、按钮' },
].map((w) => [
<div key="t" className="whitespace-nowrap">
<code className="text-body-sm text-text-primary">{w.token}</code>
<div className="mt-0.5 text-caption">
<code>{w.cls}</code>
</div>
</div>,
String(w.weight),
/* text-h3 carries fontWeight 500; inline style deterministically overrides it */
<div key="s" className="text-h3 text-text-primary" style={{ fontWeight: w.weight }}>
{w.usage}
</div>,
])}
/>
</ExampleGroup>
{/* Quick reference for migration */}
<ExampleGroup title="迁移速查" subtitle="随组件改造逐步替换,本次不批量改">
<CompareTable
head={['旧写法(Tailwind 默认档)', '新语义类', '差异']}
rows={[
['text-xs (12/16)', 'text-caption (12/20)', '行高 16→20'],
['text-sm (14/20)', 'text-body (14/22)', '行高 20→22,规范化 lh = size + 8'],
['text-base (16/24)', 'text-h4 (16/24, 500) 或正文场景 text-body', '按语义选择'],
['text-lg (18/28)', 'text-h3 (18/26, 500)', '行高 28→26,自带 500 字重'],
['text-xl (20/28)', 'text-h2 (20/28, 500)', '自带 500 字重'],
['text-2xl (24/32)', 'text-h1 (24/32, 500)', '自带 500 字重'],
['font-bold / font-semibold (157 处)', 'font-medium', '独立迁移,非本次范围'],
]}
/>
</ExampleGroup>
</ComponentPage>
);
}
+57 -3
View File
@@ -48,6 +48,53 @@
--font-size-base: 1rem;
--font-size-lg: 1.125rem;
--font-size-xl: 1.25rem;
/* ============================================================
* Typography tokens (docs-ui-refactor/基础-字体规范.md)
* Two layers: primitive (--font-size-1..9, numeric source) and
* semantic (--text-*, what components use via Tailwind classes
* text-caption / text-body / text-h1 ...). Line height = size + 8px.
* Components must reference the semantic layer only.
* ============================================================ */
/* Primitive — desktop scale */
--font-size-1: 0.75rem; --line-height-1: 1.25rem; /* 12 / 20 */
--font-size-2: 0.8125rem; --line-height-2: 1.3125rem; /* 13 / 21 */
--font-size-3: 0.875rem; --line-height-3: 1.375rem; /* 14 / 22 */
--font-size-4: 1rem; --line-height-4: 1.5rem; /* 16 / 24 */
--font-size-5: 1.125rem; --line-height-5: 1.625rem; /* 18 / 26 */
--font-size-6: 1.25rem; --line-height-6: 1.75rem; /* 20 / 28 */
--font-size-7: 1.5rem; --line-height-7: 2rem; /* 24 / 32 */
--font-size-8: 1.875rem; --line-height-8: 2.375rem; /* 30 / 38 */
--font-size-9: 2.25rem; --line-height-9: 2.75rem; /* 36 / 44 */
/* Semantic — referenced by tailwind.config.cjs theme.extend.fontSize */
--text-caption: var(--font-size-1); --leading-caption: var(--line-height-1);
--text-body-sm: var(--font-size-2); --leading-body-sm: var(--line-height-2);
--text-body: var(--font-size-3); --leading-body: var(--line-height-3);
--text-h4: var(--font-size-4); --leading-h4: var(--line-height-4);
--text-h3: var(--font-size-5); --leading-h3: var(--line-height-5);
--text-h2: var(--font-size-6); --leading-h2: var(--line-height-6);
--text-h1: var(--font-size-7); --leading-h1: var(--line-height-7);
--text-display: var(--font-size-8); --leading-display: var(--line-height-8);
--text-metric: var(--font-size-9); --leading-metric: var(--line-height-9);
}
/* Mobile (narrow viewport ≤768px) remap of the SEMANTIC layer only:
* body raised (14→16), headings lowered (24→22) — a flatter ladder.
* Component classNames stay unchanged; caption / h4 are identical on
* both breakpoints so they are not remapped here.
* Some mobile steps (17/25, 22/30, 26/34) are not on the primitive
* scale, so they are literal rem values by design. */
@media (max-width: 768px) {
:root {
--text-body-sm: var(--font-size-3); --leading-body-sm: var(--line-height-3); /* 13→14 */
--text-body: var(--font-size-4); --leading-body: var(--line-height-4); /* 14→16 */
--text-h3: 1.0625rem; --leading-h3: 1.5625rem; /* 18→17 / 25 */
--text-h2: var(--font-size-5); --leading-h2: var(--line-height-5); /* 20→18 */
--text-h1: 1.375rem; --leading-h1: 1.875rem; /* 24→22 / 30 */
--text-display: 1.625rem; --leading-display: 2.125rem; /* 30→26 / 34 */
--text-metric: var(--font-size-8); --leading-metric: var(--line-height-8); /* 36→30 */
}
}
html {
@@ -1945,17 +1992,24 @@ html {
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
}
/* Global font stack = font-family-base in 基础-字体规范.md §1.
* Pure system stack (zero webfont loading): SF Pro + PingFang on Apple,
* Segoe UI + Microsoft YaHei on Windows, Roboto + Noto Sans CJK on Android.
* Step ① of the two-step swap: @font-face blocks and font files
* (Inter / AlibabaPuHuiTi / Roboto Mono / Söhne) are intentionally KEPT
* until the Windows regression pass confirms rendering, then removed. */
body,
html {
height: 100%;
font-family:
-apple-system,
BlinkMacSystemFont,
"AlibabaPuHuiTi-3-55-Regular",
"Segoe UI",
Roboto,
"PingFang SC",
"Hiragino Sans GB",
"Microsoft YaHei",
"Helvetica Neue",
Arial,
"Noto Sans CJK SC",
sans-serif;
}
+33 -6
View File
@@ -8,18 +8,45 @@ module.exports = {
darkMode: ['class'],
theme: {
fontFamily: {
// -apple-system / BlinkMacSystemFont 在 macOS / iOS / Apple 设备的浏览器里
// 解析为系统字体(San Francisco / SF Pro 等),同时 Apple System 字体在
// 中文系统下会自动联动 PingFang SC,所以放最前面体验最好。
// 非 Apple 系统再回退到 Inter / 系统默认 sans-serif。
sans: ['-apple-system', 'BlinkMacSystemFont', 'Inter', 'sans-serif'],
mono: ['Roboto Mono', 'monospace'],
// font-family-base / font-family-mono in docs-ui-refactor/基础-字体规范.md §1.
// Pure system stack, kept in sync with the global body/html rule in src/style.css
// (that hardcoded rule is what actually sets the app-wide font; this config only
// affects explicit font-sans / font-mono usages).
sans: [
'-apple-system',
'BlinkMacSystemFont',
'"Segoe UI"',
'Roboto',
'"PingFang SC"',
'"Hiragino Sans GB"',
'"Microsoft YaHei"',
'"Noto Sans CJK SC"',
'sans-serif',
],
mono: ['ui-monospace', '"SF Mono"', '"Cascadia Mono"', 'Consolas', '"Liberation Mono"', 'monospace'],
},
// fontFamily: {
// sans: ['Söhne', 'sans-serif'],
// mono: ['Söhne Mono', 'monospace'],
// },
extend: {
// Semantic type scale (docs-ui-refactor/基础-字体规范.md §2/§7) — MUST live in
// `extend` so Tailwind's default text-xs/sm/base/... classes stay available
// (900+ existing usages). Values reference semantic CSS vars defined in
// src/style.css :root, which remap under 768px for the mobile ladder, so
// classNames never change per breakpoint. Each entry carries its own
// font-weight (400 body tier / 500 heading tier) — no extra font-medium needed.
fontSize: {
caption: ['var(--text-caption)', { lineHeight: 'var(--leading-caption)', fontWeight: '400' }],
'body-sm': ['var(--text-body-sm)', { lineHeight: 'var(--leading-body-sm)', fontWeight: '400' }],
body: ['var(--text-body)', { lineHeight: 'var(--leading-body)', fontWeight: '400' }],
h4: ['var(--text-h4)', { lineHeight: 'var(--leading-h4)', fontWeight: '500' }],
h3: ['var(--text-h3)', { lineHeight: 'var(--leading-h3)', fontWeight: '500' }],
h2: ['var(--text-h2)', { lineHeight: 'var(--leading-h2)', fontWeight: '500' }],
h1: ['var(--text-h1)', { lineHeight: 'var(--leading-h1)', fontWeight: '500' }],
display: ['var(--text-display)', { lineHeight: 'var(--leading-display)', fontWeight: '500' }],
metric: ['var(--text-metric)', { lineHeight: 'var(--leading-metric)', fontWeight: '500' }],
},
width: {
authPageWidth: '370px',
},