From 7e8b0ea27808771be0bb0440fed49682deecad5b Mon Sep 17 00:00:00 2001 From: hectorqin Date: Thu, 22 Oct 2026 12:10:46 +0800 Subject: [PATCH] feat(web): placeholder covers take a stable per-title tint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A shelf of TXT books (or a media library before scraping) was a wall of identical panels, so finding one meant reading every placeholder. Titles now hash to one of twelve hues 30° apart (ui/cover-tint.ts), mixed 10-20% into the theme surface, so the tint follows light/dark and the text on it keeps its contrast. Shelf, library and media covers share it. ui.md §2.7. Co-Authored-By: Claude Opus 5.5 --- docs/ui.md | 10 ++++++++++ web/src/features/library/pages/LibraryPage.tsx | 3 ++- web/src/features/media/components/cover.tsx | 3 ++- web/src/features/media/styles/v2.css | 2 ++ web/src/features/shelf/pages/ShelfPage.tsx | 3 ++- web/src/styles/reader.css | 11 +++++++++++ web/src/ui/cover-tint.ts | 18 ++++++++++++++++++ 7 files changed, 47 insertions(+), 3 deletions(-) create mode 100644 web/src/ui/cover-tint.ts diff --git a/docs/ui.md b/docs/ui.md index 8e30f00..06a67a9 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -100,6 +100,16 @@ **胶囊要写 `pill`,不要写半高。** `height: 26px; border-radius: 13px` 在高度改成 28px 的那天 就不再是胶囊了;`--radius-pill` 与高度无关。 +### 2.7 无封面的占位 + +没有封面的书、影片按标题取一个色相(`web/src/ui/cover-tint.ts`,12 档、每档 30°), +写进 `--cover-hue`,样式表把 `hsl(var(--cover-hue) 55% 55%)` 以 10–20% 混进当前主题的 +surface。这是「组件不写死颜色」的唯一例外,理由与封面图片相同:它是内容的颜色,不是界面的颜色。 + +- **按标题取,不按位置取**:同一本书在书架、书库、搜索里颜色一致,刷新也不变。 +- **只混少量**:占位上的文字仍用 ink/muted,对比度由主题保证;深色模式下自然变成暗底微染。 +- 不加色相之前,一屏全是同一个色块,只能逐个读字找书。 + --- ## 3. 布局 diff --git a/web/src/features/library/pages/LibraryPage.tsx b/web/src/features/library/pages/LibraryPage.tsx index 23f6595..d64cca0 100644 --- a/web/src/features/library/pages/LibraryPage.tsx +++ b/web/src/features/library/pages/LibraryPage.tsx @@ -1,4 +1,5 @@ import { BookOpen, FileText, Folder, Plus, Search, Upload } from 'lucide-react'; +import { coverTint } from '../../../ui/cover-tint.ts'; import { ActionMenu } from '../../../ui/action-menu.tsx'; import { Callout, Icon } from '../../../ui/toolkit.tsx'; import { useQuery, useQueryClient } from '@tanstack/react-query'; @@ -25,7 +26,7 @@ function Cover({ book }: { book: Book }) { }).catch(() => { if (live) setSrc(null); }); return () => { live = false; if (objectUrl) URL.revokeObjectURL(objectUrl); }; }, [runtime.api, book.coverUrl]); - return
{src ? :
{book.title || '无封面'}
}{book.format || '书籍'}
; + return
{src ? :
{book.title || '无封面'}
}{book.format || '书籍'}
; } function CollectionHeader({ title, subtitle, backTo, actions, className, footer }: { title: string; subtitle: string; backTo?: string; actions?: React.ReactNode; className?: string; footer?: React.ReactNode }) { diff --git a/web/src/features/media/components/cover.tsx b/web/src/features/media/components/cover.tsx index 331b03c..a6ade66 100644 --- a/web/src/features/media/components/cover.tsx +++ b/web/src/features/media/components/cover.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'; import type { Item, MediaApi } from '../api/media-api.ts'; import { ApiError } from '../../../api/errors.ts'; import {personColor} from './person-portrait.tsx'; +import {coverTint} from '../../../ui/cover-tint.ts'; import { createStores } from '../../../store/idb.ts'; const coverStores=createStores().catch(()=>null); @@ -50,7 +51,7 @@ export function MediaCover({ api, item, square, retryable=false, loadWithoutMeta } else void load(); return () => { controller.abort(); observer?.disconnect(); if (blobUrl) URL.revokeObjectURL(blobUrl); }; }, [api, item.id, item.metadata.coverRef, item.metadata.tmdbPosterPath, item.metadata.embeddedCoverAssetId,item.metadata.musicBrainzCoverGroupId,attempt,loadWithoutMetadata]); - return + return {url && !failed ? setFailed('封面图片无法解码')}/> : {item.kind==='artist'?Array.from(item.title)[0]:({movie:'电影',series:'剧集',season:'剧集',episode:'单集',album:'专辑',track:'音乐',audiobook:'有声书'} as Record)[item.kind]||'影音'}} {failed&&retryable&&{failed}} ; diff --git a/web/src/features/media/styles/v2.css b/web/src/features/media/styles/v2.css index aabbee4..31e2bbe 100644 --- a/web/src/features/media/styles/v2.css +++ b/web/src/features/media/styles/v2.css @@ -19,6 +19,8 @@ .media-screen .media-tile small {display:block;font-size:11px;line-height:1.5;margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .media-screen .media-cover {border-radius:var(--radius-sm);box-shadow:0 3px 8px #15211714} .media-screen .media-cover:not(.has-image):not(.media-portrait) {background:var(--surface);color:var(--muted);border:1px solid var(--border)} +/* Per-title tint, as on the shelf (ui/cover-tint.ts, ui.md §2.7). */ +.media-screen .media-cover:not(.has-image):not(.media-portrait)[style*='--cover-hue'] {background:linear-gradient(150deg,color-mix(in oklab,hsl(var(--cover-hue) 55% 55%) 18%,var(--media-paper)),color-mix(in oklab,hsl(var(--cover-hue) 55% 55%) 10%,var(--surface)));border-color:color-mix(in oklab,hsl(var(--cover-hue) 55% 55%) 14%,var(--border))} .media-screen .media-cover:not(.has-image):not(.media-portrait) .media-cover-title {font-family:inherit;font-size:13px;font-weight:500;line-height:1.5;letter-spacing:.08em;writing-mode:horizontal-tb} .media-screen .media-detail-page {padding-top:26px} .media-detail-page .media-hero {align-items:center;gap:28px;margin:0} diff --git a/web/src/features/shelf/pages/ShelfPage.tsx b/web/src/features/shelf/pages/ShelfPage.tsx index c1a555c..b58cee1 100644 --- a/web/src/features/shelf/pages/ShelfPage.tsx +++ b/web/src/features/shelf/pages/ShelfPage.tsx @@ -1,4 +1,5 @@ import { useQueryClient } from '@tanstack/react-query'; +import { coverTint } from '../../../ui/cover-tint.ts'; import { Link, useSearchParams } from 'react-router-dom'; import { useEffect, useMemo, useState, type FormEvent } from 'react'; import { BookOpen, LibraryBig, MoreHorizontal, Search } from 'lucide-react'; @@ -38,7 +39,7 @@ export function BookCard({ book, showAuthor, showProgress, onRemove }: { book: B return
- {src ? :
{book.title || '无封面'}
} + {src ? :
{book.title || '无封面'}
} {book.format || '书籍'} {showProgress && progress !== undefined && progress > 0 &&
}
diff --git a/web/src/styles/reader.css b/web/src/styles/reader.css index 1d19a0b..413c365 100644 --- a/web/src/styles/reader.css +++ b/web/src/styles/reader.css @@ -1636,6 +1636,17 @@ book-content { word-break: break-word; } +/* Per-title tint (ui/cover-tint.ts, ui.md §2.7): mixed into the theme surface, so + it stays a tint in every theme and mode and the title on it keeps its contrast. */ +.book-card .cover .placeholder[style*='--cover-hue'] { + background: + linear-gradient( + 150deg, + color-mix(in oklab, hsl(var(--cover-hue) 55% 55%) 20%, var(--reader-surface)) 0%, + color-mix(in oklab, hsl(var(--cover-hue) 55% 55%) 12%, var(--reader-line)) 100% + ); +} + .book-card .format-badge { position: absolute; inset-block-end: 0.3rem; diff --git a/web/src/ui/cover-tint.ts b/web/src/ui/cover-tint.ts new file mode 100644 index 0000000..c97d9c4 --- /dev/null +++ b/web/src/ui/cover-tint.ts @@ -0,0 +1,18 @@ +import type { CSSProperties } from 'react'; + +/** + * A stable hue for a cover that has no art. + * + * A shelf of TXT files used to be a wall of identical panels, so the only way to find + * a book was to read every placeholder. The hue comes from the title, so the same book + * keeps its colour across screens and reloads; twelve steps 30° apart keep neighbours + * distinguishable. The stylesheet mixes the hue into the theme's own surface at a low + * strength (ui.md §2.7), so it tints rather than paints and follows light/dark. + */ +export function coverHue(title: string): number { + let hash = 0; + for (const character of title) hash = (hash * 31 + character.codePointAt(0)!) >>> 0; + return (hash % 12) * 30; +} + +export const coverTint = (title: string): CSSProperties => ({ '--cover-hue': coverHue(title) });