feat(web): placeholder covers take a stable per-title tint

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 <noreply@anthropic.com>
This commit is contained in:
hectorqinandClaude Opus 5.5 committed 2026-10-22 12:10:46 +08:00
1 parent c5125792d7
commit 7e8b0ea278
7 files changed
+47 -3

No files matched your search

+10
View File
@@ -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. 布局
@@ -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 <div className="cover">{src ? <img src={src} alt="" loading="lazy" /> : <div className="placeholder">{book.title || '无封面'}</div>}<span className="format-badge">{book.format || '书籍'}</span></div>;
return <div className="cover">{src ? <img src={src} alt="" loading="lazy" /> : <div className="placeholder" style={coverTint(book.title || '')}>{book.title || '无封面'}</div>}<span className="format-badge">{book.format || '书籍'}</span></div>;
}
function CollectionHeader({ title, subtitle, backTo, actions, className, footer }: { title: string; subtitle: string; backTo?: string; actions?: React.ReactNode; className?: string; footer?: React.ReactNode }) {
+2 -1
View File
@@ -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 <span ref={host} style={item.kind==='artist'?{backgroundColor:personColor(item.title)}:undefined} className={'media-cover ' + (square ? 'square ' : '') + (item.kind==='artist'?'media-portrait '+(!url||failed?'media-person-placeholder ':''):'') + (failed&&retryable?'media-cover-failed ':'') + (url && !failed ? 'has-image' : '')}>
return <span ref={host} style={item.kind==='artist'?{backgroundColor:personColor(item.title)}:url&&!failed?undefined:coverTint(item.title)} className={'media-cover ' + (square ? 'square ' : '') + (item.kind==='artist'?'media-portrait '+(!url||failed?'media-person-placeholder ':''):'') + (failed&&retryable?'media-cover-failed ':'') + (url && !failed ? 'has-image' : '')}>
{url && !failed ? <img src={url} alt="" decoding="async" onError={() => setFailed('封面图片无法解码')}/> : <span className="media-cover-title" aria-label={item.title}>{item.kind==='artist'?Array.from(item.title)[0]:({movie:'电影',series:'剧集',season:'剧集',episode:'单集',album:'专辑',track:'音乐',audiobook:'有声书'} as Record<string,string>)[item.kind]||'影音'}</span>}
{failed&&retryable&&<span className="media-cover-retry"><small role="status">{failed}</small><button type="button" onClick={()=>setAttempt(value=>value+1)}>重试封面</button></span>}
</span>;
+2
View File
@@ -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}
+2 -1
View File
@@ -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 <div className="book-card">
<Link className="book-open" aria-label={`${book.title} ${book.author ?? ''}`.trim()} to={`/book/${encodeURIComponent(book.id)}`}>
<div className="cover">
{src ? <img src={src} alt="" loading="lazy" /> : <div className="placeholder">{book.title || '无封面'}</div>}
{src ? <img src={src} alt="" loading="lazy" /> : <div className="placeholder" style={coverTint(book.title || '')}>{book.title || '无封面'}</div>}
<span className="format-badge">{book.format || '书籍'}</span>
{showProgress && progress !== undefined && progress > 0 && <div className="cover-progress"><span style={{ width: `${Math.round(Math.max(0, Math.min(1, progress)) * 100)}%` }} /></div>}
</div>
+11
View File
@@ -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;
+18
View File
@@ -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) });