diff --git a/docs/ui.md b/docs/ui.md index c2055c6..d7f3af8 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -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. 设计令牌 diff --git a/web/src/ui/icon.tsx b/web/src/ui/icon.tsx index f6d0f07..0bdf5b4 100644 --- a/web/src/ui/icon.tsx +++ b/web/src/ui/icon.tsx @@ -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; -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 ; }