新增V3原型和主题语义令牌

This commit is contained in:
hectorqin committed 2026-10-20 10:20:32 +08:00
1 parent 5ffee3db51
commit be0f20064f
2 files changed
+631

No files matched your search

@@ -0,0 +1,391 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Reader V3 · 统一设计系统与完整原型</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #faf9f5; display: flex; align-items: center; justify-content: center; min-height: 100vh; font-family: -apple-system, BlinkMacSystemFont, sans-serif; }
#__bundler_loading { position: fixed; bottom: 20px; right: 20px; font: 13px/1.4 -apple-system, BlinkMacSystemFont, sans-serif; color: #666; background: #fff; padding: 8px 14px; border-radius: 8px; box-shadow: 0 1px 4px rgba(0,0,0,0.12); z-index: 10000; }
#__bundler_thumbnail { position: fixed; inset: 0; width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; background: #faf9f5; z-index: 9999; }
#__bundler_thumbnail svg { width: 100%; height: 100%; object-fit: contain; }
#__bundler_placeholder { color: #999; font-size: 14px; }
</style>
<noscript>
<style>#__bundler_loading { display: none; }</style>
<div style="position:fixed;bottom:12px;left:12px;font:13px/1.4 -apple-system,BlinkMacSystemFont,sans-serif;color:#999;background:rgba(255,255,255,0.9);padding:6px 12px;border-radius:6px;box-shadow:0 1px 4px rgba(0,0,0,0.08);z-index:10000;">
This page requires JavaScript to display.
</div>
</noscript>
</head>
<body>
<div id="__bundler_placeholder">Loading Reader V3 · 统一设计系统与完整原型...</div>
<div id="__bundler_loading">Unpacking...</div>
<script>
document.addEventListener('DOMContentLoaded', async function() {
const loading = document.getElementById('__bundler_loading');
function setStatus(msg) { if (loading) loading.textContent = msg; }
const FONT_MIME = /^(font[/]|application[/](x-)?font-|application[/]vnd\.ms-fontobject)/i;
const MIME_TOKEN = /^[\w.+-]+[/][\w.+-]+$/;
function toBase64(bytes) {
let bin = '';
for (let i = 0; i < bytes.length; i += 0x8000) {
bin += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
}
return btoa(bin);
}
// Error sink persists across replaceWith since it's on window, not the DOM.
window.addEventListener('error', function(e) {
// Failed resource loads (CSP-blocked links, scripts, images) fire plain
// events at the element — warn only; real JS errors carry message/error.
if (!e.message && !e.error && e.target && e.target !== window) {
console.warn('[bundle] resource failed to load:',
e.target.tagName, String(e.target.src || e.target.href || ''));
return;
}
var p = document.body || document.documentElement;
var d = document.getElementById('__bundler_err') || p.appendChild(document.createElement('div'));
d.id = '__bundler_err';
d.style.cssText = 'position:fixed;bottom:12px;left:12px;right:12px;font:12px/1.4 ui-monospace,monospace;background:#2a1215;color:#ff8a80;padding:10px 14px;border-radius:8px;border:1px solid #5c2b2e;z-index:99999;white-space:pre-wrap;max-height:40vh;overflow:auto';
d.textContent = (d.textContent ? d.textContent + String.fromCharCode(10) : '') +
'[bundle] ' + (e.message || e.type) +
(e.filename ? ' (' + e.filename.slice(0, 60) + ':' + e.lineno + ')' : '');
}, true);
try {
const manifestEl = document.querySelector('script[type="__bundler/manifest"]');
const templateEl = document.querySelector('script[type="__bundler/template"]');
if (!manifestEl || !templateEl) {
setStatus('Error: missing bundle data');
console.error('[bundler] Missing script tags — manifestEl:', !!manifestEl, 'templateEl:', !!templateEl);
return;
}
const manifest = JSON.parse(manifestEl.textContent);
let template = JSON.parse(templateEl.textContent);
// Nested page bundles (iframe targets). Each ships ONCE, in the ROOT
// document's manifest; frame srcs carry an about:blank#<uuid> marker
// instead of a substitutable uuid. Every document mints the blobs for
// its OWN frames — from local text at the root, or text obtained from
// its DIRECT parent over the relay protocol below — so each frame
// navigation stays same-origin with its initiator and a deduped page
// loads at any depth on any host, including a file:// download's
// opaque origins (where a blob minted by one document cannot be
// navigated to by another).
const pageOrderEl = document.querySelector('script[type="__bundler/page_order"]');
const pageOrder = pageOrderEl ? JSON.parse(pageOrderEl.textContent) : [];
const pageSet = new Set(pageOrder);
const pageTexts = {};
// ── Nested-page text protocol (parent-chain relay) ──
// Page bundles ship once, in the ROOT document's manifest. Every
// document mints the blobs for its OWN frames, obtaining page text
// through a strict parent-chain relay: a document only ever SENDS to
// its direct parent (which, for every document the publisher's own
// runtime minted, is another publisher document) and only ACCEPTS
// text from that direct parent; requests from below are honored only
// for the document's own child frames, and the ROOT never sends
// upward at all. That last rule is load-bearing: the artifact host's
// frame-ancestors allows claude.ai (and friends) to frame an
// artifact, so window.top can be a FOREIGN document — a top-addressed
// protocol would hand page uuids to that ancestor and accept forged
// "page text" from it, minting attacker HTML as an artifact-origin
// blob document. The chain never touches the ancestor: requests stop
// at the root, replies are origin-addressed per hop (never '*' when
// an origin exists), and both directions are origin-gated (on https
// a foreign sender's origin can never equal this document's; in
// opaque contexts — file:// — everything serializes as 'null', where
// an embedded EXTERNAL frame still fails the gate by carrying its
// own real origin, and a sandboxed child of such a frame is excluded
// by the own-child-frames check plus uuid unguessability). Shape is
// deny-by-default: one request form, one reply form, no error
// replies.
// window.origin is the SERIALIZED SECURITY origin ('null' in opaque
// contexts such as a sandboxed preview iframe) — location.origin is
// computed from the URL and stays 'https://…' even when the document
// is sandboxed, which would misgate every opaque chain.
const OWN_TARGET = /^https?:[/][/]/.test(window.origin || '')
? window.origin
: '*';
function trustedOrigin(e) {
if (e.origin === window.origin) return true; // https chains; opaque==opaque
// file:// trees: Chrome serializes the file document's origin as
// 'file://' while the blob: documents it mints serialize as 'null',
// so the two legitimate shapes can never string-match — accept the
// pair in both directions. https origins never serialize as either,
// and sender identity (own child frame / direct parent) is checked
// separately.
return (
(e.origin === 'null' &&
(window.origin === 'file://' ||
window.location.protocol === 'file:')) ||
(/^file:/.test(e.origin) && window.origin === 'null')
);
}
// Non-empty only in the root document (nested bundles ship an empty
// island), so it doubles as the root marker: the root answers from
// local text and treats a miss as authoritative — never relaying to
// its (possibly foreign) parent.
const isPageRoot = pageOrder.length > 0;
const blobUrls = {};
const resourceBlobs = {};
const UUID_SHAPE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const pendingFrames = {}; // uuid -> [{el, frag}] awaiting text for own frames
const pendingRelays = {}; // uuid -> [childWindow] awaiting text to pass down
function mountPage(el, frag, text) {
const pageBlob = new Blob([text], { type: 'text/html' });
const u = URL.createObjectURL(pageBlob);
resourceBlobs[u] = pageBlob;
el.src = u + frag;
}
function deliverPage(uuid, text) {
const frames = pendingFrames[uuid];
delete pendingFrames[uuid];
if (frames) for (const f of frames) mountPage(f.el, f.frag, text);
const relays = pendingRelays[uuid];
delete pendingRelays[uuid];
if (relays) {
for (const cw of relays) {
try { cw.postMessage({ __bundler_page: uuid, __bundler_text: text }, OWN_TARGET); } catch (err) { /* gone */ }
}
}
}
function ownFrameWindows() {
const out = [];
for (const f of Array.from(document.querySelectorAll('iframe'))) {
try { if (f.contentWindow) out.push(f.contentWindow); } catch (err) { /* detached */ }
}
return out;
}
window.addEventListener('message', function (e) {
const d = e.data;
if (!d || typeof d !== 'object' || !e.source) return;
// Origin gate first; the structural checks below (own-child-frames
// for requests, direct-parent + solicited-uuid for replies, the
// root's never-relay rule) are what hold in fully opaque chains
// where origin strings can't discriminate — a sandboxed root's
// FOREIGN wrapper still fails all three even though both serialize
// as real-vs-'null' rather than matching.
if (!trustedOrigin(e)) return;
if (typeof d.__bundler_need === 'string' && UUID_SHAPE.test(d.__bundler_need)) {
// Requests are honored only for this document's own child frames.
if (ownFrameWindows().indexOf(e.source) < 0) return;
const uuid = d.__bundler_need;
const text = pageTexts[uuid];
if (typeof text === 'string') {
try { e.source.postMessage({ __bundler_page: uuid, __bundler_text: text }, OWN_TARGET); } catch (err) { /* gone */ }
return;
}
if (isPageRoot || window.parent === window) return; // authoritative miss
const waiting = pendingRelays[uuid];
if (waiting) {
if (waiting.indexOf(e.source) < 0) waiting.push(e.source);
return;
}
// Never-answered uuids would otherwise accumulate forever under a
// request spray; past the cap (far above any real page count) new
// relays are dropped rather than remembered.
if (Object.keys(pendingRelays).length >= 128) return;
pendingRelays[uuid] = [e.source];
window.parent.postMessage({ __bundler_need: uuid }, OWN_TARGET);
} else if (
typeof d.__bundler_page === 'string' &&
UUID_SHAPE.test(d.__bundler_page) &&
typeof d.__bundler_text === 'string'
) {
// The shape test is load-bearing beyond tidiness: the solicited
// check below uses the in operator, which matches prototype keys
// ('__proto__', 'constructor') — an unshaped key would reach
// deliverPage and throw, painting the error banner.
// Text is accepted only from the direct parent — the document
// that minted this one.
if (e.source !== window.parent || window.parent === window) return;
if (!(d.__bundler_page in pendingFrames) && !(d.__bundler_page in pendingRelays)) return;
deliverPage(d.__bundler_page, d.__bundler_text);
}
});
const uuids = Object.keys(manifest);
setStatus('Unpacking ' + uuids.length + ' assets...');
await Promise.all(uuids.map(async (uuid) => {
const entry = manifest[uuid];
try {
const binaryStr = atob(entry.data);
const bytes = new Uint8Array(binaryStr.length);
for (let i = 0; i < binaryStr.length; i++) bytes[i] = binaryStr.charCodeAt(i);
let finalBytes = bytes;
if (entry.compressed) {
if (typeof DecompressionStream !== 'undefined') {
const ds = new DecompressionStream('gzip');
const writer = ds.writable.getWriter();
const reader = ds.readable.getReader();
writer.write(bytes);
writer.close();
const chunks = [];
let totalLen = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
totalLen += value.length;
}
finalBytes = new Uint8Array(totalLen);
let offset = 0;
for (const chunk of chunks) { finalBytes.set(chunk, offset); offset += chunk.length; }
} else {
console.warn('DecompressionStream not available, asset ' + uuid + ' may not render');
}
}
if (pageSet.has(uuid)) {
pageTexts[uuid] = new TextDecoder().decode(finalBytes);
return;
}
if (FONT_MIME.test(entry.mime) && MIME_TOKEN.test(entry.mime)) {
// Strict artifact hosts allow font-src data: but not blob:.
blobUrls[uuid] = 'data:' + entry.mime + ';base64,' +
(entry.compressed ? toBase64(finalBytes) : entry.data);
} else {
const blob = new Blob([finalBytes], { type: entry.mime });
blobUrls[uuid] = URL.createObjectURL(blob);
resourceBlobs[blobUrls[uuid]] = blob;
}
} catch (err) {
console.error('Failed to decode asset ' + uuid + ':', err);
const blob = new Blob([], { type: entry.mime });
blobUrls[uuid] = URL.createObjectURL(blob);
resourceBlobs[blobUrls[uuid]] = blob;
}
}));
const extResEl = document.querySelector('script[type="__bundler/ext_resources"]');
const extResources = extResEl ? JSON.parse(extResEl.textContent) : [];
const resourceMap = {};
for (const entry of extResources) {
if (blobUrls[entry.uuid]) resourceMap[entry.id] = blobUrls[entry.uuid];
}
// Artifact-host CSP (connect-src 'self') refuses fetch() of blob: URLs —
// consumers read these Blobs directly. Survives the swap like the error sink.
window.__resourceBlobs = resourceBlobs;
setStatus('Rendering...');
// Page uuids have no blob here — they ride inside about:blank#<uuid>
// frame markers the pass below resolves after the swap.
for (const uuid of uuids) {
if (pageSet.has(uuid)) continue;
template = template.split(uuid).join(blobUrls[uuid]);
}
// Strip integrity + crossorigin — blob URLs from a file:// document inherit
// a null origin, so crossorigin forces a CORS fetch that SRI then rejects.
// The manifest bytes are ours; SRI protects against CDN compromise, not this.
// appifacts: only on tags now pointing at OUR blob: URLs. A reference the
// bundler left live-fetched (an absolute x-import, a runtime dependency it
// could not embed, an authored external script) keeps the pin it was
// written with: that fetch is exactly what SRI protects. (A crossorigin
// on a parent whose child carries the blob — <video crossorigin><source>
// — is left alone too; the attribute is then the author's to drop.)
template = template.replace(/<[a-z][^>]*>/gi, (tag) => /\s(?:src|href|srcset|poster|data|xlink:href)="blob:/i.test(tag) ? tag.replace(/\s+integrity="[^"]*"/gi, '').replace(/\s+crossorigin="[^"]*"/gi, '') : tag);
const resourceScript = '<script>window.__resources = ' +
JSON.stringify(resourceMap).replace(/<\//g, '<\\/') +
';</' + 'script>';
// Inject after <head> so the DOCTYPE stays first; prepending the script
// would push the parser into quirks mode. DOMParser always emits a <head>
// (synthesizing one if the source HTML omitted it) but may carry
// attributes through, so match the full opening tag. slice() rather than
// replace() keeps us clear of $-pattern substitution in resourceScript.
const headOpen = template.match(/<head[^>]*>/i);
if (headOpen) {
const i = headOpen.index + headOpen[0].length;
template = template.slice(0, i) + resourceScript + template.slice(i);
}
// Parse the template and swap the root element. Scripts inserted via
// DOMParser/replaceWith are inert per spec — re-create each with
// createElement so they execute, awaiting onload for src scripts to
// preserve ordering (React before ReactDOM before Babel before text/babel).
const doc = new DOMParser().parseFromString(template, 'text/html');
document.documentElement.replaceWith(doc.documentElement);
// Resolve page-frame markers: mint this document's own blob for each
// framed page — from local text when this is the root, otherwise by
// asking the direct parent over the relay protocol above. An
// unanswered request leaves the frame on about:blank, the same
// degraded state as a refused embed.
{
const MARKER = /^about:blank#([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(#.*)?$/;
const needed = [];
for (const el of Array.from(document.querySelectorAll('iframe'))) {
const m = MARKER.exec(el.getAttribute('src') || '');
if (!m) continue;
const text = pageTexts[m[1]];
if (typeof text === 'string') {
mountPage(el, m[2] || '', text);
} else if (window.parent !== window && !isPageRoot) {
const had = pendingFrames[m[1]];
(pendingFrames[m[1]] = had || []).push({ el: el, frag: m[2] || '' });
if (!had) needed.push(m[1]);
}
}
for (const uuid of needed) {
window.parent.postMessage({ __bundler_need: uuid }, OWN_TARGET);
}
}
const dead = Array.from(document.scripts);
for (const old of dead) {
const s = document.createElement('script');
for (const a of old.attributes) s.setAttribute(a.name, a.value);
s.textContent = old.textContent;
// text/babel scripts with a src: read and inline. transformScriptTags
// does XHR against the src, but blob:null/ from a file:// origin is
// silently dropped. Inlining makes it a plain inline babel script,
// which transformScriptTags handles unconditionally.
if ((s.type === 'text/babel' || s.type === 'text/jsx') && s.src) {
const pre = resourceBlobs[s.src.split('#')[0]];
s.textContent = pre ? await pre.text() : await (await fetch(s.src)).text();
s.removeAttribute('src');
}
const p = s.src ? new Promise(function(r) { s.onload = s.onerror = r; }) : null;
old.replaceWith(s);
if (p) await p;
}
// Babel standalone auto-transforms type=text/babel on DOMContentLoaded,
// which fired before we swapped the document. Trigger manually if present.
if (window.Babel && typeof window.Babel.transformScriptTags === 'function') {
window.Babel.transformScriptTags();
}
} catch (err) {
setStatus('Error unpacking: ' + err.message);
console.error('Bundle unpack error:', err);
}
});
</script>
<script type="__bundler/manifest">
{"801f58b9-2cf4-4192-9e34-2d8498f779c6":{"mime":"text/html","compressed":true,"data":"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 truncated
</script>
<script type="__bundler/ext_resources">
[]
</script>
<script type="__bundler/page_order">
["801f58b9-2cf4-4192-9e34-2d8498f779c6","e1a9c43f-5bb0-4195-bf86-05987c44f630","36f0df23-1b83-44bd-9a01-a40c552af612","07971d62-3759-46b1-acd0-23b5f58ddb2f","235b90e7-6856-45e7-bf0f-8e4e786d131d","c92429a5-edf6-4971-a16e-aa7f734bfd47","2aeaa2e7-1433-4d80-b3cb-e66674b2f47c","bd44616d-8355-46fe-b629-565d747432b7","4a9d128d-e698-444d-a855-24cc10c8b7de","2aceed2a-3d36-4114-8394-6c422450294d","2829a2db-ca86-41af-84d2-1640a0b665de","2b3bef1e-c2b5-4a63-a85b-012d65b14cfb","7ccb7574-5981-43ef-8032-b82033e6ab90","5121e7b1-aa8b-480d-8048-ef1f18feccd2","c1daf045-d813-4b9f-a36c-1e568dd681b3","a985188c-af61-4f7a-ad00-c670ae7fa3dd","ea76b2d3-8c37-4552-b2e5-c4d10b93b68e","b5c69603-014d-44e3-b165-d0730199c884","c8f1aa84-538e-4784-8b01-45b73d034dcd","0452ad50-ba17-4521-98e3-cac7420c5918","716f9625-1180-4f90-8a17-5d806e61b896","3ac56421-6f90-4b59-9451-e05e0477b44b","f040e75b-38b2-4832-909e-090ef7dbbe46","5e7750f8-fafc-40fa-952c-f0b965789d6b","19776c1f-c18f-48d7-98aa-f9ecc4fc7e4f","6772e9ed-e833-4e48-90c6-a3a564d628d6","ebfadca5-ae07-4e63-9f58-dc718bc1768c","41cdce17-b7f8-48df-97c6-0e57f6362891","793f1f4a-9cf8-4d97-880b-33347ad3e335","a4062aa0-5890-40ba-9437-18244f4220a4","4b78be80-df5f-48aa-a46f-ba1e57110415","f3b8ec16-2a87-47a6-bcd5-28dc93d2528f"]
</script>
<script type="__bundler/template">
"<!DOCTYPE html>\n<html lang=\"en\"><head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<title>Reader V3 · 统一设计系统与完整原型<\u002Ftitle>\n<style>\nhtml{background:#f5f4ef;color:#1f1e1d;font:14px/1.4 system-ui,-apple-system,\"Segoe UI\",sans-serif}\nbody{margin:0;padding:48px;display:flex;flex-direction:column;align-items:flex-start;gap:48px}\n.board h2{margin:0 0 12px;font-size:13px;font-weight:500;color:#6b6a68}\n.board iframe{display:block;border:0;background:#fff;box-shadow:0 1px 3px rgba(0,0,0,.12)}\n<\u002Fstyle>\n<\u002Fhead>\n<body>\n<section class=\"board\"><h2>1 首页<\u002Fh2><iframe src=\"about:blank#801f58b9-2cf4-4192-9e34-2d8498f779c6\" title=\"1 首页\" width=\"390\" height=\"844\" loading=\"eager\" style=\"width:390px;height:844px\"><\u002Fiframe><\u002Fsection>\n<section class=\"board\"><h2>2 书架<\u002Fh2><iframe src=\"about:blank#e1a9c43f-5bb0-4195-bf86-05987c44f630\" title=\"2 书架\" width=\"390\" height=\"844\" loading=\"eager\" style=\"width:390px;height:844px\"><\u002Fiframe><\u002Fsection>\n<section class=\"board\"><h2>3 阅读器(黑白灰阅读主题)<\u002Fh2><iframe src=\"about:blank#36f0df23-1b83-44bd-9a01-a40c552af612\" title=\"3 阅读器(黑白灰阅读主题)\" width=\"390\" height=\"844\" loading=\"eager\" style=\"width:390px;height:844px\"><\u002Fiframe><\u002Fsection>\n<section class=\"board\"><h2>4 外观抽屉<\u002Fh2><iframe src=\"about:blank#07971d62-3759-46b1-acd0-23b5f58ddb2f\" title=\"4 外观抽屉\" width=\"390\" height=\"844\" loading=\"eager\" style=\"width:390px;height:844px\"><\u002Fiframe><\u002Fsection>\n<section class=\"board\"><h2>5 影音首页<\u002Fh2><iframe src=\"about:blank#235b90e7-6856-45e7-bf0f-8e4e786d131d\" title=\"5 影音首页\" width=\"390\" height=\"844\" loading=\"eager\" style=\"width:390px;height:844px\"><\u002Fiframe><\u002Fsection>\n<section class=\"board\"><h2>6 视频播放(竖屏)<\u002Fh2><iframe src=\"about:blank#c92429a5-edf6-4971-a16e-aa7f734bfd47\" title=\"6 视频播放(竖屏)\" width=\"390\" height=\"844\" loading=\"eager\" style=\"width:390px;height:844px\"><\u002Fiframe><\u002Fsection>\n<section class=\"board\"><h2>7 音乐播放<\u002Fh2><iframe src=\"about:blank#2aeaa2e7-1433-4d80-b3cb-e66674b2f47c\" title=\"7 音乐播放\" width=\"390\" height=\"844\" loading=\"eager\" style=\"width:390px;height:844px\"><\u002Fiframe><\u002Fsection>\n<section class=\"board\"><h2>8 我的(已登录)<\u002Fh2><iframe src=\"about:blank#bd44616d-8355-46fe-b629-565d747432b7\" title=\"8 我的(已登录)\" width=\"390\" height=\"844\" loading=\"eager\" style=\"width:390px;height:844px\"><\u002Fiframe><\u002Fsection>\n<section class=\"board\"><h2>9 登录<\u002Fh2><iframe src=\"about:blank#4a9d128d-e698-444d-a855-24cc10c8b7de\" title=\"9 登录\" width=\"390\" height=\"844\" loading=\"eager\" style=\"width:390px;height:844px\"><\u002Fiframe><\u002Fsection>\n<section class=\"board\"><h2>10 有声书播放<\u002Fh2><iframe src=\"about:blank#2aceed2a-3d36-4114-8394-6c422450294d\" title=\"10 有声书播放\" width=\"390\" height=\"844\" loading=\"eager\" style=\"width:390px;height:844px\"><\u002Fiframe><\u002Fsection>\n<section class=\"board\"><h2>11 倍速与定时<\u002Fh2><iframe src=\"about:blank#2829a2db-ca86-41af-84d2-1640a0b665de\" title=\"11 倍速与定时\" width=\"390\" height=\"844\" loading=\"eager\" style=\"width:390px;height:844px\"><\u002Fiframe><\u002Fsection>\n<section class=\"board\"><h2>12 字幕与音轨<\u002Fh2><iframe src=\"about:blank#2b3bef1e-c2b5-4a63-a85b-012d65b14cfb\" title=\"12 字幕与音轨\" width=\"390\" height=\"844\" loading=\"eager\" style=\"width:390px;height:844px\"><\u002Fiframe><\u002Fsection>\n<section class=\"board\"><h2>13 视频横屏播放<\u002Fh2><iframe src=\"about:blank#7ccb7574-5981-43ef-8032-b82033e6ab90\" title=\"13 视频横屏播放\" width=\"844\" height=\"390\" loading=\"eager\" style=\"width:844px;height:390px\"><\u002Fiframe><\u002Fsection>\n<section class=\"board\"><h2>14 设计系统<\u002Fh2><iframe src=\"about:blank#5121e7b1-aa8b-480d-8048-ef1f18feccd2\" title=\"14 设计系统\" width=\"1280\" height=\"900\" loading=\"eager\" style=\"width:1280px;height:900px\"><\u002Fiframe><\u002Fsection>\n<section class=\"board\"><h2>15 桌面首页(侧栏外壳)<\u002Fh2><iframe src=\"about:blank#c1daf045-d813-4b9f-a36c-1e568dd681b3\" title=\"15 桌面首页(侧栏外壳)\" width=\"1280\" height=\"800\" loading=\"eager\" style=\"width:1280px;height:800px\"><\u002Fiframe><\u002Fsection>\n<section class=\"board\"><h2>16 桌面阅读器(目录常驻)<\u002Fh2><iframe src=\"about:blank#a985188c-af61-4f7a-ad00-c670ae7fa3dd\" title=\"16 桌面阅读器(目录常驻)\" width=\"1280\" height=\"800\" loading=\"eager\" style=\"width:1280px;height:800px\"><\u002Fiframe><\u002Fsection>\n<section class=\"board\"><h2>17 桌面视频播放<\u002Fh2><iframe src=\"about:blank#ea76b2d3-8c37-Line truncated
</script>
</body>
</html>
@@ -0,0 +1,240 @@
# Reader V3 主题语义令牌
版本 v3.1 · 2026-10-20 · 对应线上原型“Reader V3 · 统一设计系统与完整原型”
## 1. 概述
主题 = 一组语义令牌的取值。组件只引用语义令牌,不写死颜色;换主题只换这一组值,界面结构、间距、字号完全不变。
Reader V3 内置 4 个主题,每个主题含浅色和深色两套取值,共 8 套配色:
| 主题 | 定位 | 色相 |
| --- | --- | --- |
| 暖陶 | 默认应用主题 | 陶土橙 |
| 雾蓝 | 应用主题 | 蓝 |
| 青绿 | 应用主题 | 绿 |
| 黑白灰 | 阅读专用主题,只在阅读器内生效 | 无彩色 |
设计原则:
- 同色系:每个主题的底色、卡片、描边、文字、按钮都从同一个色相派生,只调明暗和浓淡。
- 四个频道(阅读、影视、音乐、听书)取主色相邻的色相,明度和彩度保持一致,只做小面积色标。
- 浅色主题整体提亮,按钮用柔和的浅色底配同色相的近黑文字;深色主题底色不用纯黑。
- 组件之间不直接比较颜色,只引用令牌。
## 2. 令牌角色与命名
原有 13 个角色,另新增 2 个文字色角色(brand-text、ch-\*-text),共 15 类。CSS 变量名如下。
| 角色 | CSS 变量 | 用途 |
| --- | --- | --- |
| bg | `--color-bg` | 页面底色 |
| surface | `--color-surface` | 卡片、抽屉、导航栏 |
| surface-2 | `--color-surface-2` | 分段控件、输入框底、悬停 |
| ink | `--color-ink` | 正文与标题 |
| muted | `--color-muted` | 次要文字、图标 |
| line | `--color-line` | 分隔线、描边 |
| brand | `--color-brand` | 主按钮底色、选中态、进度条填充(只作填充,不作文字色) |
| brand-soft | `--color-brand-soft` | 选中底色、标签底色 |
| on-brand | `--color-on-brand` | 品牌色填充上的文字和图标 |
| brand-text | `--color-brand-text` | 品牌色的文字、图标、链接(新增) |
| ch-{read\|video\|music\|audio} | `--color-ch-read` 等 | 频道图标底色、进度条填充 |
| ch-\*-soft | `--color-ch-read-soft` 等 | 继续卡片、频道入口底色 |
| ch-\*-text | `--color-ch-read-text` 等 | 频道色的文字(新增) |
| on-ch | `--color-on-ch` | 频道色填充上的图标和字 |
| scrim | `--color-scrim` | 抽屉遮罩 |
新增 brand-text 和 ch-\*-text 的原因:浅色主题的 brand 和频道色已改为柔和的浅色,直接当文字用对比度不够,所以文字和图标改用更深的同色相取值。
## 3. 主题取值
### 3.1 暖陶
| 令牌 | 浅色 | 深色 |
| --- | --- | --- |
| bg | `#FDF9F6` | `#1F1713` |
| surface | `#FFFFFF` | `#2A201A` |
| surface-2 | `#F8EFE8` | `#352A23` |
| ink | `#3A2A22` | `#F7EDE6` |
| muted | `#82685B` | `#BBA79B` |
| line | `#F0E3DA` | `#43342B` |
| brand | `#F2B79F` | `#E88F73` |
| brand-soft | `#FBE9E0` | `#3F2A21` |
| on-brand | `#2B1810` | `#1A100C` |
| brand-text | `#A8482A` | `#F0987C` |
| on-ch | `#2B1810` | `#1A100C` |
| scrim | `rgba(58,42,34,.40)` | `rgba(0,0,0,.60)` |
| 频道 | 浅色 ch | 浅色 soft | 浅色 text | 深色 ch | 深色 soft | 深色 text |
| --- | --- | --- | --- | --- | --- | --- |
| read 阅读 | `#F2B79F` | `#FBE9E0` | `#A8482A` | `#E88F73` | `#3F2A21` | `#E88F73` |
| video 影视 | `#F6CA97` | `#FCF0DD` | `#8A5200` | `#E9AA6E` | `#3A2E1E` | `#E9AA6E` |
| music 音乐 | `#EFA9A3` | `#FAE4E1` | `#A8343E` | `#E58A8A` | `#3D2526` | `#E58A8A` |
| audio 听书 | `#E6BE9E` | `#F7EBDD` | `#7A5232` | `#D9A07F` | `#36291F` | `#D9A07F` |
### 3.2 雾蓝
| 令牌 | 浅色 | 深色 |
| --- | --- | --- |
| bg | `#F8FAFE` | `#0F1722` |
| surface | `#FFFFFF` | `#172131` |
| surface-2 | `#EDF2FA` | `#202C40` |
| ink | `#1F3250` | `#E8EEF7` |
| muted | `#5A6C8C` | `#9FB0C8` |
| line | `#E4EBF6` | `#2A3850` |
| brand | `#9DC0F2` | `#7FB1F0` |
| brand-soft | `#E6EFFC` | `#1C3050` |
| on-brand | `#0F1D36` | `#0A1220` |
| brand-text | `#2F63B0` | `#8DB8F0` |
| on-ch | `#0F1D36` | `#0A1220` |
| scrim | `rgba(31,50,80,.40)` | `rgba(0,0,0,.60)` |
| 频道 | 浅色 ch | 浅色 soft | 浅色 text | 深色 ch | 深色 soft | 深色 text |
| --- | --- | --- | --- | --- | --- | --- |
| read 阅读 | `#9DC0F2` | `#E6EFFC` | `#2F63B0` | `#7FB1F0` | `#1C3050` | `#7FB1F0` |
| video 影视 | `#9FD2EE` | `#E3F2FA` | `#1F6F93` | `#7CC3E8` | `#1A3347` | `#7CC3E8` |
| music 音乐 | `#B2B8F2` | `#ECEEFC` | `#4A52B0` | `#9AA3F0` | `#25274D` | `#9AA3F0` |
| audio 听书 | `#90CBD8` | `#E0F2F5` | `#1F6F7C` | `#70C1CF` | `#17353A` | `#70C1CF` |
### 3.3 青绿
| 令牌 | 浅色 | 深色 |
| --- | --- | --- |
| bg | `#F7FBF8` | `#0E1914` |
| surface | `#FFFFFF` | `#15251E` |
| surface-2 | `#EAF4EE` | `#1E3229` |
| ink | `#1B3329` | `#E6F2EC` |
| muted | `#52705F` | `#98B3A7` |
| line | `#E0EEE5` | `#28403A` |
| brand | `#8AD6B1` | `#6ED6A8` |
| brand-soft | `#E1F4EA` | `#1A3B2E` |
| on-brand | `#0F2A1E` | `#07130E` |
| brand-text | `#1F6B4F` | `#7FDDB4` |
| on-ch | `#0F2A1E` | `#07130E` |
| scrim | `rgba(27,51,41,.40)` | `rgba(0,0,0,.60)` |
| 频道 | 浅色 ch | 浅色 soft | 浅色 text | 深色 ch | 深色 soft | 深色 text |
| --- | --- | --- | --- | --- | --- | --- |
| read 阅读 | `#8AD6B1` | `#E1F4EA` | `#1F6B4F` | `#6ED6A8` | `#1A3B2E` | `#6ED6A8` |
| video 影视 | `#A9DB9B` | `#EBF6E2` | `#3F6E1F` | `#8ED67A` | `#243A1E` | `#8ED67A` |
| music 音乐 | `#8CD2CF` | `#E1F3F2` | `#1F6B68` | `#6CCBC6` | `#173A38` | `#6CCBC6` |
| audio 听书 | `#B6D98E` | `#F0F5E0` | `#5B6B1F` | `#A6CF72` | `#2C3818` | `#A6CF72` |
### 3.4 黑白灰(阅读专用)
| 令牌 | 浅色 | 深色 |
| --- | --- | --- |
| bg | `#F5F5F5` | `#121212` |
| surface | `#FFFFFF` | `#1B1B1B` |
| surface-2 | `#EBEBEB` | `#262626` |
| ink | `#1A1A1A` | `#E6E6E6` |
| muted | `#666666` | `#9E9E9E` |
| line | `#E0E0E0` | `#2E2E2E` |
| brand | `#3D3D3D` | `#E6E6E6` |
| brand-soft | `#EDEDED` | `#2A2A2A` |
| on-brand | `#FFFFFF` | `#121212` |
| brand-text | `#3D3D3D` | `#E6E6E6` |
| on-ch | `#FFFFFF` | `#121212` |
| scrim | `rgba(26,26,26,.40)` | `rgba(0,0,0,.60)` |
| 频道 | 浅色 ch | 浅色 soft | 浅色 text | 深色 ch | 深色 soft | 深色 text |
| --- | --- | --- | --- | --- | --- | --- |
| read 阅读 | `#3D3D3D` | `#EDEDED` | `#3D3D3D` | `#CFCFCF` | `#2A2A2A` | `#CFCFCF` |
| video 影视 | `#4A4A4A` | `#EDEDED` | `#4A4A4A` | `#BDBDBD` | `#2A2A2A` | `#BDBDBD` |
| music 音乐 | `#575757` | `#EDEDED` | `#575757` | `#ABABAB` | `#2A2A2A` | `#ABABAB` |
| audio 听书 | `#666666` | `#EDEDED` | `#666666` | `#999999` | `#2A2A2A` | `#999999` |
黑白灰没有彩色,四个频道只靠明度的细微差别区分,实际界面里频道主要靠图标和文字标签区分。
## 4. 使用规则
1. 组件不写死颜色。除了封面、剧照等内容图片,界面里不出现十六进制色值。
2. brand 和 ch-\* 只作填充。带这些底色的文字和图标,用 on-brand 或 on-ch。
3. 品牌色的文字、链接、选中态图标,用 brand-text;频道色的文字用 ch-*-text。不要直接把 brand 或 ch-* 当文字色,浅色主题下对比度不够。
4. 继续卡片、频道入口的底色用 ch-*-soft,文字用 ink 或 ch-*-text。
5. 选中的标签、分段控件用 brand-soft 底加 brand-text 文字。
6. 抽屉、弹层的遮罩用 scrim。
7. 影视、音乐播放页是固定深色沉浸界面,使用当前主题的深色取值,不随浅色切换。
8. 不使用纯黑 `#000` 作背景;纯白只作浅色主题的 surface。
## 5. 对比度要求
正文、次要文字、频道色文字至少 4.5:1,图标与控件至少 3:1。以下组合已用脚本对 8 套配色逐一计算,全部达标:
| 组合 | 要求 |
| --- | --- |
| ink 对 bg、surface | 4.5:1 以上 |
| muted 对 bg、surface-2 | 4.5:1 以上 |
| brand-text 对 brand-soft、bg | 4.5:1 以上 |
| on-brand 对 brand | 4.5:1 以上 |
| ch-*-text 对 ch-*-soft | 4.5:1 以上 |
| on-ch 对 ch-\* | 4.5:1 以上 |
新增主题或调色时,必须重新计算这几组。
## 6. 主题选择与切换
- 应用主题可选暖陶、雾蓝、青绿,默认暖陶。
- 明暗模式可选“跟随系统”“浅色”“深色”,默认跟随系统。
- “跟随系统”= 指定一个浅色时使用的主题和一个深色时使用的主题,默认都是暖陶。
- 黑白灰是阅读专用主题,不作为应用主题,只在阅读器内生效。阅读主题可选“跟随应用”或“黑白灰”;选黑白灰时,浅色和夜间分别用它的浅色和深色取值。
- 主题偏好存在当前设备,登录后可同步到账号。
## 7. CSS 实现示例
以暖陶为例,其他主题结构相同,换成第 3 节的取值即可。
```css
[data-theme="warm"][data-mode="light"] {
--color-bg: #FDF9F6;
--color-surface: #FFFFFF;
--color-surface-2: #F8EFE8;
--color-ink: #3A2A22;
--color-muted: #82685B;
--color-line: #F0E3DA;
--color-brand: #F2B79F;
--color-brand-soft: #FBE9E0;
--color-on-brand: #2B1810;
--color-brand-text: #A8482A;
--color-ch-read: #F2B79F; --color-ch-read-soft: #FBE9E0; --color-ch-read-text: #A8482A;
--color-ch-video: #F6CA97; --color-ch-video-soft: #FCF0DD; --color-ch-video-text: #8A5200;
--color-ch-music: #EFA9A3; --color-ch-music-soft: #FAE4E1; --color-ch-music-text: #A8343E;
--color-ch-audio: #E6BE9E; --color-ch-audio-soft: #F7EBDD; --color-ch-audio-text: #7A5232;
--color-on-ch: #2B1810;
--color-scrim: rgba(58, 42, 34, .40);
}
[data-theme="warm"][data-mode="dark"] {
--color-bg: #1F1713;
--color-surface: #2A201A;
--color-surface-2: #352A23;
--color-ink: #F7EDE6;
--color-muted: #BBA79B;
--color-line: #43342B;
--color-brand: #E88F73;
--color-brand-soft: #3F2A21;
--color-on-brand: #1A100C;
--color-brand-text: #F0987C;
--color-ch-read: #E88F73; --color-ch-read-soft: #3F2A21; --color-ch-read-text: #E88F73;
--color-ch-video: #E9AA6E; --color-ch-video-soft: #3A2E1E; --color-ch-video-text: #E9AA6E;
--color-ch-music: #E58A8A; --color-ch-music-soft: #3D2526; --color-ch-music-text: #E58A8A;
--color-ch-audio: #D9A07F; --color-ch-audio-soft: #36291F; --color-ch-audio-text: #D9A07F;
--color-on-ch: #1A100C;
--color-scrim: rgba(0, 0, 0, .60);
}
```
切换方式:在根元素上设置 `data-theme`(warm、blue、green)和 `data-mode`(light、dark)。选择“跟随系统”时,由 `prefers-color-scheme` 决定 `data-mode`。阅读器容器单独设置 `data-theme="gray"`,使其不受应用主题影响。
## 8. 与旧主题的对应关系
| 旧主题 | 处理方式 |
| --- | --- |
| 暖纸(浅色) | 替换为暖陶浅色 |
| 深夜(深色) | 替换为暖陶深色 |
| 雾蓝(浅色) | 替换为新的雾蓝浅色 |
| 蔷薇(浅色) | 取消 |
| 原野(浅色) | 取消,由青绿浅色替代 |
| 石墨(深色) | 取消,由雾蓝深色替代 |
迁移时,已选择旧主题的用户按上表映射;取消的主题统一回落到暖陶,并保留原来的明暗模式。