refactor(web): ui/icon.tsx owns the icon set — names, sizes and stroke

IconName came from the legacy font's code-point table, so a new icon meant a
new font glyph, while 59 files imported lucide-react directly with their own
glyphs, 17 sizes and strokes from 1.4 to Lucide's default 2. icon.tsx now
defines every name (in the product's words: video, audiobook, lock,
folder-browse, …), each drawn by one Lucide glyph; IconName is its keys.
Icon gains a six-step size scale (14–32), `filled`, and `strong` for the
current navigation item; the stroke is 1.8 everywhere, 1.4 at the 32px
empty-state size. ui.md §1 describes the rules.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
hectorqinandClaude Opus 5.5 committed 2026-10-30 07:26:51 +00:00
1 parent 4a6b7a3858
commit 97429ddeed
2 files changed
+100 -29

No files matched your search

+12 -3
View File
@@ -12,9 +12,18 @@
## 1. 图标
图标统一使用 `lucide-react` 的 SVG,`web/src/ui/icon.tsx` 集中维护名称映射,线宽 1.8,尺寸 1em。
界面使用 Icon / IconButton / IconTextButton,不直接嵌入字体码位或 emoji。
装饰图标标记 aria-hidden;没有文字的按钮必须有中文 aria-label。原字体文件与生成工具仅供历史兼容,不再加载到阅读界面。
图标统一使用 `lucide-react` 的 SVG,`web/src/ui/icon.tsx` 集中维护名称映射,也是**唯一**引入 `lucide-react` 的文件
(`test/icons.test.ts` 守住)。界面使用 Icon / IconButton / IconTextButton,不直接嵌入字体码位、emoji 或 ✓ × + 这类字符。
- **名称**:一个意思一个名字、一个字形,用本项目自己的英文名(`video`、`audiobook`、`lock`、`folder-browse`),
不借用其他图标集的名字。新增图标只改 `icon.tsx` 的 `ICONS`。
- **尺寸**:不传 `size` 时为 1em(控件里用这个);需要固定尺寸时只取 14 / 16 / 18 / 20 / 24 / 32 六档。
- **线宽**:一律 1.8;32px 的空状态大图标用 1.4,否则放大后像粗体图示;导航当前项用 `strong`(2.1)。
`filled` 用文字颜色填充(播放三角、已收藏)。
- 装饰图标默认 `aria-hidden`;独立表意时传 `label`。没有文字的按钮必须有中文 aria-label。
原字体文件与生成工具(`tools/icons`、`reader-icons.ttf`、`icon-names.ts`)仅供历史兼容,不再加载到界面,
图标名称也不再由它决定。
## 2. 设计令牌
+88 -26
View File
@@ -1,34 +1,96 @@
import {
Menu, ChevronLeft, ChevronRight, ArrowLeft, Folder, FileText, SlidersHorizontal,
Search, X, Plus, Check, Upload, Trash2, Download, RefreshCw, BookOpen,
Library, Play, Pause, SkipBack, SkipForward, Square, Headphones, Moon, Sun,
Eye, Info, TriangleAlert, Clock, Ellipsis, Pencil, ArrowDownUp, LogOut,
CirclePlus, IndentIncrease, Type, MoveVertical, Settings, SlidersVertical, Bookmark,
Menu, ChevronLeft, ChevronRight, ChevronDown, ArrowLeft, ArrowUp, ArrowDown, ArrowUpRight,
Folder, FolderOpen, FolderX, File, FileText, SlidersHorizontal, Search, X, Plus, Check,
Upload, Trash2, Download, RefreshCw, RotateCw, RotateCcw, BookOpen, Library, LibraryBig,
Play, PlayCircle, Pause, SkipBack, SkipForward, Square, Headphones, Volume2, Moon, Sun,
SunMoon, Palette, Eye, EyeOff, Info, TriangleAlert, CloudOff, Clock, History, Timer,
TimerOff, Ellipsis, Pencil, ArrowDownUp, LogOut, CirclePlus, IndentIncrease, Type,
MoveVertical, Settings, SlidersVertical, Bookmark, Music, ListMusic, ListVideo, ListPlus,
ListOrdered, Layers, Heart, Star, Clapperboard, Film, UserRound, Lock, LockOpen, Gauge,
Subtitles, AudioLines, MicVocal, Shuffle, Repeat, Repeat1, Maximize, Minimize, Expand,
Shrink, LocateFixed, LayoutGrid, House, Rss,
} from 'lucide-react';
import type { IconName } from './icon-names.ts';
import type { JSX } from 'react';
export type { IconName };
import type { CSSProperties, JSX } from 'react';
/**
* The icon set: one name per idea, in the product's own words, each drawn by one
* Lucide glyph at one stroke weight (ui.md §1).
*
* This file is the only place that imports `lucide-react`. Screens used to import
* glyphs directly, and the set drifted the way a set without an owner does: settings
* drawn by three different gears, lock by two padlocks, the stroke at 1.4, 1.7, 1.8,
* 1.9 and Lucide's default 2 on the same screen, and seventeen sizes. A name here is
* what a screen asks for; which glyph answers it is decided once.
*
* The names are the product's, not another icon set's (`test/icons.test.ts` keeps
* borrowed names out of the legacy font table; the same rule applies here). The
* aliases at the end are older spellings kept so existing call sites compile.
*/
const ICONS = {
menu: Menu, 'chevron-left': ChevronLeft, 'chevron-right': ChevronRight,
'arrow-left': ArrowLeft, folder: Folder, 'file-text': FileText,
sliders: SlidersHorizontal, search: Search, close: X, plus: Plus, check: Check,
upload: Upload, trash: Trash2, download: Download, refresh: RefreshCw,
book: BookOpen, shelf: Library, play: Play, pause: Pause,
'step-backward': SkipBack, 'step-forward': SkipForward, stop: Square,
volume: Headphones, moon: Moon, sun: Sun, eye: Eye, info: Info,
warning: TriangleAlert, clock: Clock, more: Ellipsis, edit: Pencil,
sort: ArrowDownUp, logout: LogOut, 'sign-out': LogOut, 'add-circle': CirclePlus,
indent: IndentIncrease, 'text-size': Type, 'line-height': MoveVertical,
settings: Settings, library: Library, tune: SlidersVertical,
font: Type, 'volume-high': Headphones, 'backward-step': SkipBack,
'forward-step': SkipForward, bars: Menu, gear: Settings, xmark: X,
'magnifying-glass': Search, books: Library, bookmark: Bookmark,
} satisfies Record<IconName, typeof Menu>;
export interface IconProps { name: IconName; label?: string | null; className?: string; }
// navigation and structure
menu: Menu, 'chevron-left': ChevronLeft, 'chevron-right': ChevronRight, 'chevron-down': ChevronDown,
'arrow-left': ArrowLeft, 'arrow-up': ArrowUp, 'arrow-down': ArrowDown, external: ArrowUpRight,
home: House, grid: LayoutGrid, locate: LocateFixed,
// files and the library
folder: Folder, 'folder-browse': FolderOpen, 'folder-missing': FolderX, file: File, 'file-text': FileText,
book: BookOpen, shelf: Library, library: LibraryBig, bookmark: Bookmark, feed: Rss,
// actions
search: Search, close: X, plus: Plus, check: Check, upload: Upload, trash: Trash2,
download: Download, refresh: RefreshCw, edit: Pencil, sort: ArrowDownUp, more: Ellipsis,
'add-circle': CirclePlus, 'queue-add': ListPlus, logout: LogOut, 'sign-out': LogOut,
// playback
play: Play, 'play-circle': PlayCircle, pause: Pause, stop: Square,
'step-backward': SkipBack, 'step-forward': SkipForward,
'rewind': RotateCcw, 'fast-forward': RotateCw, shuffle: Shuffle, repeat: Repeat, 'repeat-one': Repeat1,
speed: Gauge, timer: Timer, 'timer-off': TimerOff, fullscreen: Maximize, 'fullscreen-exit': Minimize,
'fit-fill': Expand, 'fit-whole': Shrink, lock: Lock, unlock: LockOpen,
subtitles: Subtitles, 'audio-track': AudioLines, versions: Layers, episodes: ListVideo,
playlist: ListMusic, 'ordered-list': ListOrdered, lyrics: MicVocal, speaker: Volume2,
// media kinds
video: Clapperboard, film: Film, music: Music, audiobook: Headphones, volume: Headphones,
// reading and appearance
'text-size': Type, 'line-height': MoveVertical, indent: IndentIncrease, appearance: SunMoon,
palette: Palette, moon: Moon, sun: Sun, settings: Settings, sliders: SlidersHorizontal,
tune: SlidersVertical,
// state and people
eye: Eye, 'eye-off': EyeOff, info: Info, warning: TriangleAlert, offline: CloudOff,
clock: Clock, history: History, favorite: Heart, star: Star, account: UserRound,
// older spellings
font: Type, 'volume-high': Headphones, 'backward-step': SkipBack, 'forward-step': SkipForward,
bars: Menu, gear: Settings, xmark: X, 'magnifying-glass': Search, books: LibraryBig,
} as const;
export type IconName = keyof typeof ICONS;
/**
* The size scale, in px. Without a size the glyph is 1em of its own box (the
* `.icon` rule sets that to the title size), which is what controls use. A size is
* for the places that need a fixed one: inline with small text, list rows, empty
* states. Six steps rather than whatever number the call site happened to type.
*/
export type IconSize = 14 | 16 | 18 | 20 | 24 | 32;
export interface IconProps {
name: IconName;
/** Only for an icon that carries meaning on its own; decorative icons stay hidden. */
label?: string | null;
className?: string;
size?: IconSize;
/** Filled with the text colour (a play triangle, a set bookmark). */
filled?: boolean;
/** A heavier stroke for the selected item of a navigation (the tab bar's current tab). */
strong?: boolean;
style?: CSSProperties;
}
/** Shared geometry and stroke weight, independent of font rendering. */
export function Icon({ name, label, className }: IconProps): JSX.Element {
export function Icon({ name, label, className, size, filled, strong, style }: IconProps): JSX.Element {
const Glyph = ICONS[name];
// The large empty-state glyphs are drawn lighter: a 1.8 stroke scaled to 32px reads
// as a bold pictogram rather than the line icon the rest of the set is.
return <Glyph className={className ? 'icon ' + className : 'icon'} size="1em"
strokeWidth={1.8} aria-hidden={label ? undefined : true}
strokeWidth={strong ? 2.1 : size === 32 ? 1.4 : 1.8} fill={filled ? 'currentColor' : 'none'}
style={size ? { ...style, fontSize: `${size}px` } : style}
aria-hidden={label ? undefined : true}
role={label ? 'img' : undefined} aria-label={label ?? undefined} />;
}