Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 56 additions & 10 deletions scripts/test-detail-browser.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,34 +11,80 @@ const { chromium } = require('playwright');
const errors=[]; page.on('pageerror', e=>errors.push(e.message));
const content='## 阅读体验\n\n统一正文、标题和侧边信息的位置,让阅读更轻松。\n\n## 功能说明\n\n正文内容保留原有的 Markdown 格式。\n\n## 使用方式\n\n在手机与电脑上打开页面。';
const author={author_name:'这是用于检验窄屏排版的很长用户名', author_avatar:'/login-icons/microsoft.svg',published_at:'2026-09-14T00:00:00Z'};
async function assertArticlePills(covered, width) {
const header=page.locator(covered?'.article-has-cover .hero-band':'.article-no-cover .hero-heading');
assert.equal(await header.count(),1);
assert.equal(await page.locator(covered?'.hero-heading':'.hero-band').count(),0);
assert.equal(await page.locator('.hero-card').count(),covered?1:0);
for(const dark of [false,true]) {
await page.evaluate(value=>document.documentElement.classList.toggle('dark',value),dark);
for(const row of ['.detail-breadcrumb','.detail-label']) {
const pill=header.locator(`${row} ${covered?'.hero-pill':'.hero-pill--light'}`);
assert.equal(await pill.count(),1,`${width} ${dark?'dark':'light'} ${row}: expected capsule variant`);
await page.mouse.move(0,0);
const base=await pill.evaluate(el=>{
const s=getComputedStyle(el),before=getComputedStyle(el,'::before');
const probe=document.createElement('span');el.append(probe);
const color=value=>{probe.style.color=value;return getComputedStyle(probe).color;};
const expected={text:color('var(--text-primary)'),fill:color('var(--fill)'),hover:color('var(--heading-pill-hover)'),focus:color('var(--article-accent)'),background:color('var(--background)')};probe.remove();
return {background:s.backgroundColor,color:s.color,radius:s.borderRadius,borders:[s.borderTopWidth,s.borderRightWidth,s.borderBottomWidth,s.borderLeftWidth],transform:s.transform,shadow:s.boxShadow,textShadow:s.textShadow,filter:s.filter,backdrop:s.backdropFilter,font:s.font,padding:s.padding,targetHeight:Math.max(el.getBoundingClientRect().height,parseFloat(before.height)||0),expected};
});
assert.equal(base.radius,'999px');assert.deepEqual(base.borders,['0px','0px','0px','0px']);
assert.equal(base.transform,'none');assert.equal(base.shadow,'none');assert.equal(base.textShadow,'none');assert.equal(base.filter,'none');assert.equal(base.backdrop,'none');
assert.ok(base.targetHeight>=44,'capsule hit area must be at least 44px');
assert.equal(base.color,covered?'rgb(255, 255, 255)':base.expected.text);
assert.equal(base.background,covered?'rgba(255, 255, 255, 0.1)':base.expected.fill);
if(!covered) {
const heading=await header.evaluate(el=>({background:getComputedStyle(el).backgroundColor,image:getComputedStyle(el).backgroundImage}));
assert.equal(heading.background,base.expected.background);assert.equal(heading.image,'none');
}
await pill.hover();
const hover=await pill.evaluate(el=>{const s=getComputedStyle(el);return {background:s.backgroundColor,color:s.color,transform:s.transform,shadow:s.boxShadow,filter:s.filter,backdrop:s.backdropFilter,font:s.font,padding:s.padding};});
assert.equal(hover.background,covered?'rgba(255, 255, 255, 0.18)':base.expected.hover);
for(const key of ['color','transform','shadow','filter','backdrop','font','padding'])assert.equal(hover[key],base[key],`hover only changes background: ${key}`);
if(covered) {
await page.mouse.down();
assert.equal(await pill.evaluate(el=>getComputedStyle(el).backgroundColor),'rgba(255, 255, 255, 0.24)');
await page.mouse.move(0,0);await page.mouse.up();
}
await page.mouse.move(0,0);await pill.focus();await page.keyboard.press('Shift+Tab');await page.keyboard.press('Tab');
const focus=await pill.evaluate(el=>({visible:el.matches(':focus-visible'),style:getComputedStyle(el).outlineStyle,width:getComputedStyle(el).outlineWidth,color:getComputedStyle(el).outlineColor}));
assert.equal(focus.visible,true);assert.equal(focus.style,'solid');assert.equal(focus.width,'2px');assert.equal(focus.color,covered?'rgba(255, 255, 255, 0.85)':base.expected.focus);
await pill.evaluate(el=>el.blur());
}
}
await page.evaluate(()=>document.documentElement.classList.remove('dark'));
}
await page.route('**/api/**', route=>{
const path=new URL(route.request().url()).pathname; let json={};
if(path==='/api/auth/session')json={user:null};
if(path.includes('/comments'))json=[];
if(path==='/api/posts/1')json={id:1,title:'博客前端视觉重构与阅读体验',content,summary:'同一套布局,适配不同类型的内容。',...author,category:'开发记录与前端设计',tags:['React','移动端适配','长标签测试'.repeat(12)]};
if(/^\/api\/posts\/[12]$/.test(path)){const id=Number(path.split('/').at(-1));json={id,title:'博客前端视觉重构与阅读体验',content,summary:'同一套布局,适配不同类型的内容。',...author,cover_image:id===2?'/login-icons/microsoft.svg':null,category:'开发记录与前端设计',tags:['React','移动端适配','长标签测试'.repeat(12)]};}
if(path.startsWith('/api/projects/'))json={id:1,name:'PromptDock',content,stage:'active',summary:'原生 macOS 提示词管理工具',...author,tech_stack:'SwiftUI · SwiftData · AppKit',demo_url:'https://example.com',repo:'example/project',releases:{items:[],page:1,total:0,pageSize:20}};
if(path==='/api/updates/1')json={id:1,...author,content:'QQ 和 Google 登录已经开放,欢迎在账号设置中绑定体验。'};
if(path==='/api/posts/1/discovery')json={related:[],previous:null,next:null};
if(path==='/api/series/article/1')json=null;
if(/^\/api\/posts\/[12]\/discovery$/.test(path))json={related:[],previous:null,next:null};
if(/^\/api\/series\/article\/[12]$/.test(path))json=null;
if(path.startsWith('/api/analytics/'))json={views:123456};
return route.fulfill({json});
});
for(const width of [1920,1440,1100,768,375,320]){
await page.setViewportSize({width,height:900}); let expected;
for(const [name,path] of [['article','/article/1'],['project','/projects/promptdock']]){
for(const [name,path] of [['article','/article/1'],['article-cover','/article/2'],['project','/projects/promptdock']]){
const isArticle=name.startsWith('article');
await page.goto('http://127.0.0.1:4202'+path);
await page.locator('.detail-title').waitFor();
if(name==='article')await page.getByLabel('123456 次阅读',{exact:true}).waitFor();
if(isArticle)await page.getByLabel('123456 次阅读',{exact:true}).waitFor();
assert.ok(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),`${name} ${width} page overflow`);
const outer=await page.locator('main.detail-container').evaluate(el=>{const r=el.getBoundingClientRect(),s=getComputedStyle(el);return {left:r.left,right:el.parentElement.getBoundingClientRect().right-r.right,width:r.width,paddingLeft:s.paddingLeft,paddingRight:s.paddingRight};});
assert.ok(Math.abs(outer.left-outer.right)<1, `${name} ${width}: equal outer gutters ${JSON.stringify(outer)}`);
assert.equal(outer.paddingLeft,'0px');assert.equal(outer.paddingRight,'0px');
const usableWidth=await page.locator('main.detail-container').evaluate(el=>el.parentElement.getBoundingClientRect().width);
if(width>=1440){assert.equal(outer.width,name==='article'?usableWidth:1200);measurements.push({name,viewport:width,...outer});}
const pill=await page.locator(name==='article'?'.detail-label .hero-pill':'.detail-category').evaluate(el=>{const s=getComputedStyle(el);return {background:s.backgroundColor,radius:s.borderRadius};});
if(width>=1440){assert.equal(outer.width,isArticle?usableWidth:1200);measurements.push({name,viewport:width,...outer});}
const pill=await page.locator(isArticle?`.detail-label ${name==='article-cover'?'.hero-pill':'.hero-pill--light'}`:'.detail-category').evaluate(el=>{const s=getComputedStyle(el);return {background:s.backgroundColor,radius:s.borderRadius};});
assert.notEqual(pill.background,'rgba(0, 0, 0, 0)');assert.equal(pill.radius,'999px');
if(isArticle)await assertArticlePills(name==='article-cover',width);
const body=await page.locator('.detail-body').boundingBox();
if(name==='article'){
if(isArticle){
if(width>=1100)assert.equal(body.width,720);
assert.ok(Math.abs(body.x+body.width/2-(outer.left+outer.width/2))<1,'article reading column centered');
const groups=await page.locator('.hero-meta .g').evaluateAll(es=>es.map(e=>({y:e.getBoundingClientRect().y,white:getComputedStyle(e).whiteSpace})));
Expand Down Expand Up @@ -72,7 +118,7 @@ const { chromium } = require('playwright');
}
if(width<=375){assert.equal(await page.locator('.detail-title').evaluate(el=>getComputedStyle(el).fontSize),'28px');await page.getByRole('button',{name:'打开菜单',exact:true}).click();await page.getByRole('navigation',{name:'手机导航'}).waitFor();await page.keyboard.press('Escape');}
await page.screenshot({path:`.cache/detail-${name}-${width}.png`,fullPage:true});
if(width===375){await page.evaluate(()=>document.documentElement.classList.add('dark'));await page.screenshot({path:`.cache/detail-${name}-dark.png`,fullPage:true});}
if(width===375){await page.evaluate(()=>document.documentElement.classList.add('dark'));await page.screenshot({path:`.cache/detail-${name}-dark.png`,fullPage:true});await page.evaluate(()=>document.documentElement.classList.remove('dark'));}
}
}
// A single mounted sidebar follows breakpoint changes and native summary keyboard activation.
Expand Down Expand Up @@ -101,6 +147,6 @@ const { chromium } = require('playwright');
}
require('node:fs').writeFileSync('.cache/centering-measurements.json',JSON.stringify(measurements,null,2)+'\n');
assert.deepEqual(errors,[]);
console.log('PASS: shared 640–680px column, aligned atomic metadata, 375px/320px navigation, sidebar folding, dark theme and no page overflow.');
console.log('PASS: shared 640–680px column, covered/coverless capsules in both themes, aligned atomic metadata, 375px/320px navigation, sidebar folding and no page overflow.');
} finally { await browser?.close(); await server.httpServer.close(); }
})().catch(e=>{console.error(e);process.exitCode=1;});
6 changes: 3 additions & 3 deletions src/app/components/ArticlePresentation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,9 @@ export function ArticlePresentation({ post, preview = false, views = null,progre
let tags: string[] = [];
try { tags = Array.isArray(post.tags) ? post.tags : JSON.parse(post.tags || '[]'); } catch {}
return <article className={`article-presentation ${cover?'article-has-cover':'article-no-cover'} ${preview?'detail-preview':''}`} data-reading-content={preview?undefined:true}>
<header className="hero-band" style={bandStyle}><div className="hero-inner">
{progress}
<div className="detail-intro"><nav className="detail-breadcrumb" aria-label="面包屑导航" data-reading-ignore><Link className="hero-pill" to="/articles">← 文章库</Link></nav><div className="detail-label"><Link className="hero-pill" to={`/category/${encodeURIComponent(post.category||'文章')}`}>{post.category||'文章'}</Link></div></div>
{progress}
<header className={cover?'hero-band':'hero-heading'} style={cover?bandStyle:undefined}><div className="hero-inner">
<div className="detail-intro"><nav className="detail-breadcrumb" aria-label="面包屑导航" data-reading-ignore><Link className={cover?'hero-pill':'hero-pill--light'} to="/articles">← 文章库</Link></nav><div className="detail-label"><Link className={cover?'hero-pill':'hero-pill--light'} to={`/category/${encodeURIComponent(post.category||'文章')}`}>{post.category||'文章'}</Link></div></div>
<h1 className="detail-title">{post.title}</h1>
<SingleLine className="hero-meta" label="发布信息">
<span className="g">
Expand Down
4 changes: 2 additions & 2 deletions src/app/components/ArticleTableOfContents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,13 @@ export function ArticleTableOfContents({headings,preview=false}:{headings:Headin
observe();select();
return()=>{changes.disconnect();observer.disconnect();win.removeEventListener('scroll',onScroll);if(frame)win.cancelAnimationFrame(frame);};
},[headings]);
const links=<nav aria-label="文章目录">{headings.map(h=><a key={h.id} className={`${h.level>=3?'toc-sub ':''}${active===h.id?'on':''}`} aria-current={active===h.id?'location':undefined} href={`#${h.id}`} onClick={event=>{
const links=<nav aria-label="文章目录">{headings.map(h=><a key={h.id} title={h.title} className={`${h.level>=3?'toc-sub ':''}${active===h.id?'on':''}`} aria-current={active===h.id?'location':undefined} href={`#${h.id}`} onClick={event=>{
const doc=rail.current?.ownerDocument,node=doc?.getElementById(h.id),win=doc?.defaultView;if(!node||!win)return;
event.preventDefault();setActive(h.id);
const header=doc.querySelector('.site-header')?.getBoundingClientRect().height||76;
win.scrollTo({top:win.scrollY+node.getBoundingClientRect().top-header-24,behavior:reducedMotion()?'auto':'smooth'});
try{win.history.replaceState(win.history.state,'',`#${h.id}`);}catch{/* Embedded editor previews can restrict history. */}
}}>{h.title}</a>)}</nav>;
}}><span className="toc-entry-title">{h.title}</span></a>)}</nav>;
const settings=(compact=false)=>!preview&&<button type="button" className={`reading-settings-trigger ${compact?'reading-toolbar-settings':'toc-reading-trigger'}`} aria-label="阅读设置" aria-expanded={settingsOpen} aria-controls="reading-settings-panel" onClick={event=>window.dispatchEvent(new CustomEvent('mooncci:reading-tools-open',{detail:{trigger:event.currentTarget}}))}><span aria-hidden="true">Aa</span>{!compact&&'阅读设置'}</button>;
if(!headings.length&&preview)return null;
return <aside ref={rail} className={`article-rail${headings.length?'':' article-rail-settings-only'}`} data-reading-ignore>
Expand Down
3 changes: 2 additions & 1 deletion src/app/components/ContentUI.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ export function ResourceState({
{children}
</div>;
}
export function SitePage({ children, narrow = false, detail = false,className='' }: { children: ReactNode; narrow?: boolean; detail?: boolean;className?:string }) {
export function SitePage({ children, narrow = false, detail = false,className='',footerContent }: { children: ReactNode; narrow?: boolean; detail?: boolean;className?:string;footerContent?:ReactNode }) {
const location=useLocation();
useLayoutEffect(() => { visibleDiagnostic(location.pathname, 'code-ready'); }, [location.key]);
return (
Expand All @@ -113,6 +113,7 @@ export function SitePage({ children, narrow = false, detail = false,className=''
<main onAnimationEnd={event=>{if(event.target===event.currentTarget) visibleDiagnostic(location.pathname,'motion-complete');}} className={`site-container page-content ${narrow ? 'reading-page' : ''} ${detail ? 'detail-container' : ''}`}>
{narrow ? <div className="page-reading-column">{children}</div> : children}
</main>
{footerContent}
<SiteFooter />
</div>
);
Expand Down
Loading
Loading