feat(viewer): UI-02 visual foundation, top bar and album info
V01+V02 (Task 2 of the UI-02 viewer redesign plan): - viewer-tokens.css: UI-02 §3.1 design tokens scoped to .viewer-shell (never :root), so embed routes consume none of them - App.vue: normal routes get a sticky white top bar (PhotoLive brand + compact locale switcher + lightweight "..." menu holding the reduce-motion toggle); global 16px content padding removed so banner and photos run edge-to-edge; embed shell branch untouched - LocaleSwitcher: compact "EN/中文 + caret" trigger with a proper menu (current language checked), 44px touch target, Escape/outside close - AlbumHeader (new): banner (2.4:1 mobile, 320px desktop cap, object-fit contain, collapses on missing/failed), 2-line expandable title + share button (clipboard copy with selectable-text fallback), 3-line expandable pre-line description, meta row (Intl-formatted view count with em-dash fallback for unknown, album-timezone dates with full range on title, venue), Latest/Popular 44px tabs and category pills from the API with horizontal scroll + overflow fade - ActivityPage: delegates the info area to AlbumHeader; sequence reset/sort-lock/category-by-id semantics unchanged; desktop content capped at 1120px - i18n: viewer.* strings (brand/share/views/readMore/latest/popular/ allCategories/noPhotos/newPhotos/reduceMotion/settings) in zh-CN+en - tests: viewer-shell (token source + normal/embed isolation) and album-header (8 cases) specs; activity/i18n specs updated for the menu switcher and the em-dash unknown view count; e2e selectors refreshed Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import App from '../App.vue';
|
||||
import { createViewerI18n, type ViewerI18n } from '../i18n';
|
||||
import { router } from '../router';
|
||||
|
||||
// UI-02 V01 外壳与视觉 token:
|
||||
// · token 断言直接读 viewer-tokens.css 源文件(vitest 不加载样式表),
|
||||
// 作用域正确性由渲染分支验证——普通路由挂 .viewer-shell(token 生效),
|
||||
// embed 路由走 .embed-shell 裸分支(不挂类 → 不消费任何 token,隔离红线);
|
||||
// · 顶栏:品牌 PhotoLive + 紧凑语言控件 + 「⋯」轻量菜单内的减少动画入口。
|
||||
vi.mock('../api/client', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../api/client')>();
|
||||
return {
|
||||
...actual,
|
||||
// embed 路由数据:404 → 静默 gone(无 UI、无轮询),外壳分支足以验证。
|
||||
// ApiError 经 actual 命名空间构造(工厂被提升,不能引用顶层 import 绑定)。
|
||||
fetchPublicEmbedSlot: vi.fn().mockRejectedValue(
|
||||
new actual.ApiError('EMBED_SLOT_NOT_FOUND', 'slot gone', 404, 'req-shell'),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
let i18n: ViewerI18n;
|
||||
let mounted: VueWrapper | null = null;
|
||||
|
||||
function mountApp(): VueWrapper {
|
||||
const wrapper = mount(App, { global: { plugins: [router, i18n] } });
|
||||
mounted = wrapper;
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
describe('viewer shell tokens and embed isolation (UI-02 V01)', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
i18n = createViewerI18n('zh-CN');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mounted?.unmount();
|
||||
mounted = null;
|
||||
document.documentElement.className = '';
|
||||
});
|
||||
|
||||
it('declares all UI-02 §3.1 tokens scoped to .viewer-shell (not :root/global)', () => {
|
||||
const cssPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'styles', 'viewer-tokens.css');
|
||||
const css = readFileSync(cssPath, 'utf8');
|
||||
const expected: Record<string, string> = {
|
||||
'--viewer-primary': '#1A73E8',
|
||||
'--viewer-surface': '#FFFFFF',
|
||||
'--viewer-page-bg': '#F3F6FA',
|
||||
'--viewer-text': '#172033',
|
||||
'--viewer-text-secondary': '#647084',
|
||||
'--viewer-divider': '#E5EAF1',
|
||||
'--viewer-dark-bg': '#0E1218',
|
||||
'--viewer-like-accent': '#FF667D',
|
||||
'--viewer-font-family': "system-ui, -apple-system, 'Segoe UI', 'PingFang SC', sans-serif",
|
||||
'--viewer-radius-img': '4px',
|
||||
'--viewer-radius-pill': '20px',
|
||||
'--viewer-pad-info': '16px',
|
||||
'--viewer-pad-photo': '4px',
|
||||
'--viewer-touch-target': '44px',
|
||||
};
|
||||
for (const [name, value] of Object.entries(expected)) {
|
||||
expect(css, `${name} token`).toContain(`${name}: ${value};`);
|
||||
}
|
||||
// 不得把 token 泄漏到全局根(embed iframe 会继承 :root 变量)。
|
||||
expect(css).not.toContain(':root');
|
||||
expect(css).toContain('.viewer-shell');
|
||||
});
|
||||
|
||||
it('normal routes render .viewer-shell with PhotoLive brand top bar and prefs menu', async () => {
|
||||
await router.push('/');
|
||||
await router.isReady();
|
||||
const app = mountApp();
|
||||
|
||||
expect(app.find('.viewer-shell').exists()).toBe(true);
|
||||
expect(app.find('.embed-shell').exists()).toBe(false);
|
||||
|
||||
const topbar = app.find('header.topbar');
|
||||
expect(topbar.exists()).toBe(true);
|
||||
expect(topbar.find('.brand-name').text()).toBe('PhotoLive');
|
||||
expect(topbar.find('.locale-trigger').exists()).toBe(true);
|
||||
|
||||
// 「⋯」轻量菜单(§3.2):减少动画入口可发现、可切换。
|
||||
const more = topbar.find('.icon-btn');
|
||||
expect(more.attributes('aria-haspopup')).toBe('menu');
|
||||
await more.trigger('click');
|
||||
const item = app.find('.prefs-item');
|
||||
expect(item.text()).toBe('减少动画');
|
||||
const before = item.attributes('aria-checked');
|
||||
await item.trigger('click');
|
||||
expect(app.find('.prefs-item').attributes('aria-checked')).toBe(before === 'true' ? 'false' : 'true');
|
||||
// 还原模块级偏好单例,避免影响后续用例。
|
||||
await app.find('.prefs-item').trigger('click');
|
||||
});
|
||||
|
||||
it('embed routes keep the bare shell: no .viewer-shell, no top bar, no outer padding wrapper', async () => {
|
||||
await router.push('/embed/slot-shell-test');
|
||||
await router.isReady();
|
||||
const app = mountApp();
|
||||
await flushPromises();
|
||||
|
||||
expect(app.find('.embed-shell').exists()).toBe(true);
|
||||
expect(app.find('.viewer-shell').exists()).toBe(false);
|
||||
expect(app.find('header.topbar').exists()).toBe(false);
|
||||
expect(app.find('.viewer-main').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user