From bfb646fcc91dc0f6484f7c45a3ea14f0f5681923 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Wed, 28 Oct 2026 15:18:26 +0800 Subject: [PATCH 1/7] Track production Studio acceptance scripts for Git delivery --- scripts/verify-studio-library.mjs | 31 ++++++++++++++++++++++++++++ scripts/verify-studio-visual.cjs | 34 +++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 scripts/verify-studio-library.mjs create mode 100644 scripts/verify-studio-visual.cjs diff --git a/scripts/verify-studio-library.mjs b/scripts/verify-studio-library.mjs new file mode 100644 index 0000000..e14d76f --- /dev/null +++ b/scripts/verify-studio-library.mjs @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict'; +import {writeFile} from 'node:fs/promises'; +const base='http://8.211.154.134:18131'; +async function api(path,body,method=body?'POST':'GET',status=200){const r=await fetch(base+path,{method,...(body?{headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}:{})});assert.equal(r.status,status,path);return r.json()} +const catalog=await api('/studio/api/layouts');assert.equal(catalog.layouts.length,28); +const note=(await api('/api/notes/8eb8c76500580f7a4b56ba3c')).entry; +const asset=note.coverAssetId||note.assets.find(a=>a.kind==='image').id; +const preview=await fetch(base+'/api/preview?'+new URLSearchParams({source:'/api/media/'+asset,width:'960'}));assert.equal(preview.status,200);assert.equal(preview.headers.get('content-type'),'image/webp');const bytes=(await preview.arrayBuffer()).byteLength;assert.ok(bytes<=220000); +const results=[]; +for(const layoutId of ['standard-01','ref-17']){ + const schema=await api('/studio/api/layouts/'+layoutId);assert.ok(schema.elements.some(s=>s.id==='T01')); + const p={texts:{T01:{value:'API 验收 · 原生页面',fontFamily:'serif',fontWeight:600}},images:{I01:{assetId:asset,fit:'cover',cropX:35,cropY:65,zoom:1.2}},added:[{id:'user-api-check',kind:'text',role:'扩展正文',box:[10,120,60,9],direction:'horizontal',text:'通过 HTTP 保存并恢复',reading:'custom'}],layers:{'user-api-check':{z:20,rotation:2}}}; + let d=(await api('/api/studio/designs',{schemaVersion:1,layoutId,name:'API 验收 '+layoutId,parameters:p},'POST',201)).design; + const id=d.id,postId=d.postId; + assert.deepEqual((await api('/api/studio/designs/'+id)).design.parameters,d.parameters); + const html=await fetch(base+d.url).then(r=>r.text());assert.ok(html.includes('API 验收'));assert.ok(html.includes('user-api-check'));assert.ok(html.includes('/api/preview?'));assert.ok(!html.includes('src="/api/media/')); + d=(await api('/api/studio/designs/'+id,{name:'API 验收修订 '+layoutId,parameters:p,revision:d.revision},'PATCH')).design; + assert.equal(d.postId,postId);assert.equal(d.revision,2); + await api('/api/studio/designs/'+id,{name:'过期写入',parameters:{},revision:1},'PATCH',409); + d=(await api('/api/studio/designs/'+id,{revision:2},'DELETE')).design;assert.equal(d.archived,true); + assert.equal((await fetch(base+d.url)).status,404);assert.equal((await api('/api/notes/'+postId)).entry.archived,true); + assert.deepEqual((await api('/api/studio/designs/'+id)).design.parameters,d.parameters); + assert.ok((await api('/api/studio/designs?archived=true&layoutId='+layoutId)).designs.some(v=>v.id===id)); + d=(await api('/api/studio/designs/'+id,{revision:3,archived:false},'POST')).design;assert.equal(d.revision,4);assert.equal(d.archived,false);assert.equal((await fetch(base+d.url)).status,200); + results.push({layoutId,id,url:d.url,revision:d.revision,postId,parameters:p}); + // Restore tested URL for visual inspection, then archive only these clearly named test records later. +} +for(const l of catalog.layouts){const r=await fetch(base+l.url);assert.equal(r.status,200,l.id);const html=await r.text();assert.ok(html.includes('data-layout-id="'+l.id+'"'));assert.ok(!/r)})); diff --git a/scripts/verify-studio-visual.cjs b/scripts/verify-studio-visual.cjs new file mode 100644 index 0000000..324fb79 --- /dev/null +++ b/scripts/verify-studio-visual.cjs @@ -0,0 +1,34 @@ +const {readFile,mkdir,writeFile}=require('node:fs/promises'); +const {createHmac,randomBytes}=require('node:crypto'); +const {chromium}=require('playwright-core'); +const assert=require('node:assert/strict'); +(async()=>{ + const base='http://8.211.154.134:18131',origin=new URL(base).origin,dir='/tmp/studio-library/visual';await mkdir(dir,{recursive:true}); + const config=JSON.parse(await readFile(process.env.AUTH_CONFIG_FILE,'utf8')); + const now=Math.floor(Date.now()/1000),part=o=>Buffer.from(JSON.stringify(o)).toString('base64url'); + const content=part({alg:'HS256',typ:'JWT'})+'.'+part({iss:'prompt',aud:'prompt-web',sub:'shared',iat:now,exp:now+1200,jti:randomBytes(16).toString('hex')}); + const token=content+'.'+createHmac('sha256',Buffer.from(config.jwtKey,'hex')).update(content).digest('base64url'); + const browser=await chromium.launch({executablePath:'/usr/bin/chromium',headless:true,args:['--no-sandbox','--disable-dev-shm-usage']}); + const context=await browser.newContext({viewport:{width:1100,height:1000},deviceScaleFactor:1}); + await context.route('**/*',async route=>{const u=new URL(route.request().url());if(u.origin===origin)return route.continue({headers:{...route.request().headers(),Authorization:'Bearer '+token}});if(['data:','blob:'].includes(u.protocol))return route.continue();return route.abort()}); + const page=await context.newPage(),errors=[],images=[]; + page.on('pageerror',error=>errors.push(error.message)); + page.on('response',async response=>{if(response.request().resourceType()==='image'){const url=new URL(response.url());images.push({path:url.pathname+url.search,status:response.status(),type:response.headers()['content-type'],bytes:(await response.body().catch(()=>Buffer.alloc(0))).length})}}); + const metrics=[]; + for(let i=1;i<=27;i++){ + const id='ref-'+String(i).padStart(2,'0');await page.goto(base+'/studio/layouts/'+id,{waitUntil:'networkidle'});await page.locator('article.native-layout').screenshot({path:dir+'/'+id+'.png'}); + const m=await page.locator('article.native-layout').evaluate(el=>({id:el.dataset.layoutId,elements:el.querySelectorAll('[data-element]').length,width:el.clientWidth,height:el.clientHeight,overflow:[...el.querySelectorAll('.native-copy')].filter(e=>e.scrollHeight>e.clientHeight+2||e.scrollWidth>e.clientWidth+2).map(e=>e.closest('[data-element]').dataset.element)}));metrics.push(m); + } + await page.goto(base+'/studio',{waitUntil:'networkidle'});assert.equal(await page.locator('.library-item').count(),27);await page.screenshot({path:dir+'/catalog-desktop.png',fullPage:true}); + const verify=JSON.parse(await readFile('/tmp/studio-library/verification.json','utf8')); + for(const d of verify.results){await page.goto(base+d.url,{waitUntil:'networkidle'});assert.equal(await page.locator('img').count(),1);const current=await page.locator('img').evaluate(img=>({src:img.currentSrc,complete:img.complete,width:img.naturalWidth}));assert.ok(current.complete&¤t.width>0);assert.equal(new URL(current.src).pathname,'/api/preview');await page.locator('article.native-layout').screenshot({path:dir+'/'+d.layoutId+'-saved.png'}); + await page.getByRole('button',{name:'编辑页面',exact:true}).click();assert.equal(await page.locator('.workbench-top').count(),1);await page.screenshot({path:dir+'/'+d.layoutId+'-editor.png'}); + } + for(const slug of ['learning-skills-template','layout-selection']){await page.goto(base+'/studio/ideas/'+slug,{waitUntil:'networkidle'});await page.screenshot({path:dir+'/tool-'+slug+'.png',fullPage:true})} + await page.setViewportSize({width:390,height:844});await page.goto(base+'/studio',{waitUntil:'networkidle'});assert.ok(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));await page.screenshot({path:dir+'/catalog-mobile.png',fullPage:true}); + await page.goto(base+verify.results[0].url,{waitUntil:'networkidle'});await page.screenshot({path:dir+'/standard-mobile.png',fullPage:true}); + await page.goto(base+'/categories?category='+encodeURIComponent('文档入口'),{waitUntil:'networkidle'});await page.screenshot({path:dir+'/document-entries.png',fullPage:true}); + assert.equal(images.filter(r=>new URL(r.path,base).pathname.startsWith('/api/media/')).length,0,'Display must never fetch originals');assert.equal(errors.length,0,JSON.stringify(errors)); + await writeFile(dir+'/metrics.json',JSON.stringify({metrics,images,errors},null,2));await browser.close(); + console.log(JSON.stringify({pages:27,errors:errors.length,displayImages:images.length,originalImageRequests:0,overflow:metrics.filter(m=>m.overflow.length)})); +})().catch(e=>{console.error(e.message);process.exit(1)}); -- 2.52.0 From 833a345b85fca17cc4ddb16d6f2e57f33ab81c20 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Wed, 28 Oct 2026 15:42:10 +0800 Subject: [PATCH 2/7] Make production acceptance checks retain evidence and clean test records --- scripts/verify-studio-library.mjs | 16 ++++++++++++++++ scripts/verify-studio-visual.cjs | 6 +++--- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/scripts/verify-studio-library.mjs b/scripts/verify-studio-library.mjs index e14d76f..c0f2d5d 100644 --- a/scripts/verify-studio-library.mjs +++ b/scripts/verify-studio-library.mjs @@ -2,6 +2,20 @@ import assert from 'node:assert/strict'; import {writeFile} from 'node:fs/promises'; const base='http://8.211.154.134:18131'; async function api(path,body,method=body?'POST':'GET',status=200){const r=await fetch(base+path,{method,...(body?{headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}:{})});assert.equal(r.status,status,path);return r.json()} +if(process.argv.includes('--finalize')){ + const {results}=JSON.parse(await (await import('node:fs/promises')).readFile('/tmp/studio-library/verification.json','utf8')); + const canonical='8eb8c76500580f7a4b56ba3c',legacy='9ebd8f78d54b4b9db741b4e8'; + const entry=(await api('/api/notes/'+canonical)).entry; + for(const needle of ['standard-01','DELETE','revision','cropX','兼容工具原说明'])assert.ok(entry.editableMarkdown.includes(needle),needle); + for(const id of [canonical,legacy])assert.equal((await fetch(base+'/explore/'+id)).status,200); + const old=(await api('/api/notes/'+legacy)).entry;assert.ok(old.archived&&old.editableMarkdown.includes(canonical)); + const category=await api('/api/explore-feed?category='+encodeURIComponent('文档入口')); + assert.equal(category.total,3);assert.equal(category.nextCursor,null); + assert.deepEqual(category.entries.map(e=>e.id).sort(),['3c98946754c33309d73d5284','4ce3283fd98bb03a8fb39a19',canonical].sort()); + for(const item of results){const d=(await api('/api/studio/designs/'+item.id)).design;assert.ok(d.name.startsWith('API 验收'));assert.equal(d.postId,item.postId);if(!d.archived)await api('/api/studio/designs/'+d.id,{revision:d.revision},'DELETE');assert.equal((await api('/api/studio/designs/'+d.id)).design.archived,true)} + const final={canonical:base+'/explore/'+canonical,documentEntries:3,legacyCompatible:true,testRecordsArchived:results.length}; + await writeFile('/tmp/studio-library/final-verification.json',JSON.stringify(final,null,2));console.log(JSON.stringify(final)); +}else{ const catalog=await api('/studio/api/layouts');assert.equal(catalog.layouts.length,28); const note=(await api('/api/notes/8eb8c76500580f7a4b56ba3c')).entry; const asset=note.coverAssetId||note.assets.find(a=>a.kind==='image').id; @@ -29,3 +43,5 @@ for(const l of catalog.layouts){const r=await fetch(base+l.url);assert.equal(r.s for(const slug of ['learning-skills-template','layout-selection']){assert.equal((await fetch(base+'/studio/ideas/'+slug)).status,200);const r=await fetch(base+'/studio/api/export/'+slug+'?format=svg');assert.equal(r.status,200);assert.match(r.headers.get('content-type'),/svg/);} await writeFile('/tmp/studio-library/verification.json',JSON.stringify({results,preview:{asset,bytes,type:'image/webp'},pages:28},null,2)); console.log(JSON.stringify({crudFamilies:2,pages:28,conflicts:2,archiveRestore:2,compressedPreviewBytes:bytes,retainedToolbox:2,results:results.map(({parameters,...r})=>r)})); + +} diff --git a/scripts/verify-studio-visual.cjs b/scripts/verify-studio-visual.cjs index 324fb79..fcb7772 100644 --- a/scripts/verify-studio-visual.cjs +++ b/scripts/verify-studio-visual.cjs @@ -17,7 +17,7 @@ const assert=require('node:assert/strict'); const metrics=[]; for(let i=1;i<=27;i++){ const id='ref-'+String(i).padStart(2,'0');await page.goto(base+'/studio/layouts/'+id,{waitUntil:'networkidle'});await page.locator('article.native-layout').screenshot({path:dir+'/'+id+'.png'}); - const m=await page.locator('article.native-layout').evaluate(el=>({id:el.dataset.layoutId,elements:el.querySelectorAll('[data-element]').length,width:el.clientWidth,height:el.clientHeight,overflow:[...el.querySelectorAll('.native-copy')].filter(e=>e.scrollHeight>e.clientHeight+2||e.scrollWidth>e.clientWidth+2).map(e=>e.closest('[data-element]').dataset.element)}));metrics.push(m); + const m=await page.locator('article.native-layout').evaluate(el=>({id:el.dataset.layoutId,elements:el.querySelectorAll('[data-element]').length,width:el.clientWidth,height:el.clientHeight,overflow:[...el.querySelectorAll('.native-copy')].filter(e=>e.scrollHeight>e.clientHeight+2||e.scrollWidth>e.clientWidth+2).map(e=>e.closest('[data-element]').dataset.element)}));metrics.push(m);await writeFile(dir+'/metrics.json',JSON.stringify({metrics,images,errors},null,2)); } await page.goto(base+'/studio',{waitUntil:'networkidle'});assert.equal(await page.locator('.library-item').count(),27);await page.screenshot({path:dir+'/catalog-desktop.png',fullPage:true}); const verify=JSON.parse(await readFile('/tmp/studio-library/verification.json','utf8')); @@ -27,8 +27,8 @@ const assert=require('node:assert/strict'); for(const slug of ['learning-skills-template','layout-selection']){await page.goto(base+'/studio/ideas/'+slug,{waitUntil:'networkidle'});await page.screenshot({path:dir+'/tool-'+slug+'.png',fullPage:true})} await page.setViewportSize({width:390,height:844});await page.goto(base+'/studio',{waitUntil:'networkidle'});assert.ok(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));await page.screenshot({path:dir+'/catalog-mobile.png',fullPage:true}); await page.goto(base+verify.results[0].url,{waitUntil:'networkidle'});await page.screenshot({path:dir+'/standard-mobile.png',fullPage:true}); - await page.goto(base+'/categories?category='+encodeURIComponent('文档入口'),{waitUntil:'networkidle'});await page.screenshot({path:dir+'/document-entries.png',fullPage:true}); - assert.equal(images.filter(r=>new URL(r.path,base).pathname.startsWith('/api/media/')).length,0,'Display must never fetch originals');assert.equal(errors.length,0,JSON.stringify(errors)); + await page.goto(base+'/categories?category='+encodeURIComponent('文档入口'),{waitUntil:'domcontentloaded',timeout:60000});await page.getByRole('heading',{name:'分类',exact:true}).waitFor({state:'visible'});await page.screenshot({path:dir+'/document-entries.png',fullPage:true}); + assert.equal(images.filter(r=>new URL(r.path,base).pathname.startsWith('/api/media/')).length,0,'Display must never fetch originals');assert.equal(errors.length,0,JSON.stringify(errors));for(const image of images){assert.equal(new URL(image.path,base).pathname,'/api/preview');assert.equal(image.status,200);assert.equal(image.type,'image/webp');assert.ok(image.bytes>0&&image.bytes<=220000)} await writeFile(dir+'/metrics.json',JSON.stringify({metrics,images,errors},null,2));await browser.close(); console.log(JSON.stringify({pages:27,errors:errors.length,displayImages:images.length,originalImageRequests:0,overflow:metrics.filter(m=>m.overflow.length)})); })().catch(e=>{console.error(e.message);process.exit(1)}); -- 2.52.0 From 4848ecc52874fa14dec943eb2cf342322e4aad5a Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Wed, 28 Oct 2026 15:43:47 +0800 Subject: [PATCH 3/7] Wait for the actual category navigation landmark --- scripts/verify-studio-visual.cjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/verify-studio-visual.cjs b/scripts/verify-studio-visual.cjs index fcb7772..b75f0ba 100644 --- a/scripts/verify-studio-visual.cjs +++ b/scripts/verify-studio-visual.cjs @@ -27,7 +27,7 @@ const assert=require('node:assert/strict'); for(const slug of ['learning-skills-template','layout-selection']){await page.goto(base+'/studio/ideas/'+slug,{waitUntil:'networkidle'});await page.screenshot({path:dir+'/tool-'+slug+'.png',fullPage:true})} await page.setViewportSize({width:390,height:844});await page.goto(base+'/studio',{waitUntil:'networkidle'});assert.ok(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));await page.screenshot({path:dir+'/catalog-mobile.png',fullPage:true}); await page.goto(base+verify.results[0].url,{waitUntil:'networkidle'});await page.screenshot({path:dir+'/standard-mobile.png',fullPage:true}); - await page.goto(base+'/categories?category='+encodeURIComponent('文档入口'),{waitUntil:'domcontentloaded',timeout:60000});await page.getByRole('heading',{name:'分类',exact:true}).waitFor({state:'visible'});await page.screenshot({path:dir+'/document-entries.png',fullPage:true}); + await page.goto(base+'/categories?category='+encodeURIComponent('文档入口'),{waitUntil:'domcontentloaded',timeout:60000});await page.getByRole('navigation',{name:'系统标签分类',exact:true}).waitFor({state:'visible'});await page.screenshot({path:dir+'/document-entries.png',fullPage:true}); assert.equal(images.filter(r=>new URL(r.path,base).pathname.startsWith('/api/media/')).length,0,'Display must never fetch originals');assert.equal(errors.length,0,JSON.stringify(errors));for(const image of images){assert.equal(new URL(image.path,base).pathname,'/api/preview');assert.equal(image.status,200);assert.equal(image.type,'image/webp');assert.ok(image.bytes>0&&image.bytes<=220000)} await writeFile(dir+'/metrics.json',JSON.stringify({metrics,images,errors},null,2));await browser.close(); console.log(JSON.stringify({pages:27,errors:errors.length,displayImages:images.length,originalImageRequests:0,overflow:metrics.filter(m=>m.overflow.length)})); -- 2.52.0 From aa1509df8a9c595a8cab6f21f677655688693a03 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Wed, 28 Oct 2026 16:12:31 +0800 Subject: [PATCH 4/7] Match native catalogue previews and fit vertical title line boxes --- apps/studio/src/components/LayoutWorkbench.tsx | 5 +---- apps/studio/src/components/NativeCurvedCopy.tsx | 7 +++++++ apps/studio/src/components/NativePreview.tsx | 14 ++++++++++++++ apps/studio/src/lib/native-layout.ts | 2 +- apps/studio/src/pages/index.tsx | 5 ++--- apps/studio/src/styles/globals.css | 3 +++ scripts/verify-studio-visual.cjs | 10 ++++++---- 7 files changed, 34 insertions(+), 12 deletions(-) create mode 100644 apps/studio/src/components/NativeCurvedCopy.tsx create mode 100644 apps/studio/src/components/NativePreview.tsx diff --git a/apps/studio/src/components/LayoutWorkbench.tsx b/apps/studio/src/components/LayoutWorkbench.tsx index 501219b..51ad834 100644 --- a/apps/studio/src/components/LayoutWorkbench.tsx +++ b/apps/studio/src/components/LayoutWorkbench.tsx @@ -1,3 +1,4 @@ +import {CurvedCopy} from './NativeCurvedCopy'; import {useEffect,useRef,useState,type CSSProperties,type KeyboardEvent,type PointerEvent} from 'react'; import {ArrowLeft,MousePointer2,Type,Square,Circle,ImagePlus,Layers,SlidersHorizontal,Undo2,Redo2,Copy,Trash2,Eye,EyeOff,Lock,Unlock,ChevronUp,ChevronDown,Minus,Plus,Scan,Save} from 'lucide-react'; import Head from 'next/head'; @@ -11,10 +12,6 @@ function TextElement({value,editable,onChange,onInvalid,onDirty,...props}:{value useEffect(()=>{if(ref.current&&document.activeElement!==ref.current)ref.current.textContent=value},[value]); return
{const next=(e.currentTarget.innerText||'').replaceAll('\r','');if(Array.from(next).length>500||next.split('\n').length>8){e.currentTarget.textContent=value;onInvalid()}else if(next!==value)onChange(next)}}>{value}
; } -function CurvedCopy({layout,slot,value,style,spacing=0}:{layout:Layout;slot:Slot;value:string;style:CSSProperties;spacing?:number}){ - const letters=Array.from(value),tall=slot.box[3]>slot.box[2]; - return
{letters.map((letter,index)=>{const t=.5+(index/Math.max(1,letters.length-1)-.5)*(1+spacing/Math.max(.1,textSize(layout,slot,value))),x=tall?90-75*Math.sin(Math.PI*t):4+92*t,y=tall?2+94*t:5+75*Math.sin(Math.PI*t),angle=tall?90+Math.cos(Math.PI*t)*18:Math.atan2(slot.box[3]*.75*Math.PI*Math.cos(Math.PI*t),slot.box[2]*.92)*180/Math.PI;return })}
; -} export function LayoutWorkbench({layout,design:initialDesign}:LayoutPageProps){ const [params,setParams]=useState(initialDesign?.parameters||emptyParameters()),[design,setDesign]=useState(initialDesign),[editing,setEditing]=useState(false),[selected,setSelected]=useState(null),[name,setName]=useState(initialDesign?.name||layout.name),[status,setStatus]=useState(''),[busy,setBusy]=useState(false),[dirty,setDirty]=useState(false),[history,setHistory]=useState([]),[future,setFuture]=useState([]),[zoom,setZoom]=useState(1),[panel,setPanel]=useState<'layers'|'properties'|null>(null),[textEditing,setTextEditing]=useState(null),[conflict,setConflict]=useState(false); const canvas=useRef(null),viewport=useRef(null); diff --git a/apps/studio/src/components/NativeCurvedCopy.tsx b/apps/studio/src/components/NativeCurvedCopy.tsx new file mode 100644 index 0000000..3930daa --- /dev/null +++ b/apps/studio/src/components/NativeCurvedCopy.tsx @@ -0,0 +1,7 @@ +import type {CSSProperties} from 'react'; +import type {Layout,Slot} from '../lib/layout-parameters'; +import {textSize} from '../lib/native-layout'; +export function CurvedCopy({layout,slot,value,style,spacing=0}:{layout:Layout;slot:Slot;value:string;style:CSSProperties;spacing?:number}){ + const letters=Array.from(value),tall=slot.box[3]>slot.box[2]; + return
{letters.map((letter,index)=>{const t=.5+(index/Math.max(1,letters.length-1)-.5)*(1+spacing/Math.max(.1,textSize(layout,slot,value))),x=tall?90-75*Math.sin(Math.PI*t):4+92*t,y=tall?2+94*t:5+75*Math.sin(Math.PI*t),angle=tall?90+Math.cos(Math.PI*t)*18:Math.atan2(slot.box[3]*.75*Math.PI*Math.cos(Math.PI*t),slot.box[2]*.92)*180/Math.PI;return })}
; +} diff --git a/apps/studio/src/components/NativePreview.tsx b/apps/studio/src/components/NativePreview.tsx new file mode 100644 index 0000000..c2d7c01 --- /dev/null +++ b/apps/studio/src/components/NativePreview.tsx @@ -0,0 +1,14 @@ +import type {Layout} from '../lib/layout-parameters'; +import {paperColor,textColor,defaultText,textSize,slotPosition,imageShape,decorationKind} from '../lib/native-layout'; +import {CurvedCopy} from './NativeCurvedCopy'; +/** The catalogue uses the same native type, contours and ornament as the rendered page. */ +export function NativePreview({layout}:{layout:Layout}){ + return
+ {['image','decoration','text'].flatMap(kind=>layout.elements.filter(s=>s.kind===kind).map(s=>{ + const text=defaultText(layout,s),style={fontSize:textSize(layout,s,text)/layout.width*100+'cqw',color:textColor(layout.id,s)}; + return
+ {kind==='text'?(s.direction==='curved'?:
{text}
):kind==='image'?
:
{decorationKind(s)==='lines'&&Array.from({length:s.box[3]>4?3:1},(_,i)=>)}{decorationKind(s)==='vertical-lines'&&<>}
} +
; + }))} +
; +} diff --git a/apps/studio/src/lib/native-layout.ts b/apps/studio/src/lib/native-layout.ts index 2ec77ba..fe174a4 100644 --- a/apps/studio/src/lib/native-layout.ts +++ b/apps/studio/src/lib/native-layout.ts @@ -40,7 +40,7 @@ export function slotPosition(layout:Layout,slot:Slot):CSSProperties { export function textSize(layout:Layout,slot:Slot,text:string):number { const [, ,w,h]=slot.box,lines=text.split('\n'); const length=(s:string)=>Array.from(s).reduce((n,c)=>n+(/[\u3400-\u9fff]/.test(c)?1:/[A-Z]/.test(c)?.8:.6),0); - if(slot.direction==='vertical')return Math.min(w*.9,h/Math.max(1,Array.from(text.replaceAll('\n','')).length)*.95); + if(slot.direction==='vertical')return Math.min(w*.9,h/Math.max(1,Array.from(text.replaceAll('\n','')).length)*.88); if(slot.direction==='rotated')return Math.min(w*.85,h/Math.max(1,length(text))); if(slot.direction==='curved')return Math.min(2.2,w/Math.max(1,length(text))); return Math.min(h/(lines.length*1.15),w/Math.max(1,...lines.map(length)))*.92; diff --git a/apps/studio/src/pages/index.tsx b/apps/studio/src/pages/index.tsx index a70e7bf..877ce42 100644 --- a/apps/studio/src/pages/index.tsx +++ b/apps/studio/src/pages/index.tsx @@ -1,7 +1,6 @@ import Head from 'next/head'; import fixed from '../content/templates/layouts.json'; import standard from '../content/standard-layout.json'; -import type {Layout} from '../lib/layout-parameters'; -import {nativeLayout,paperColor,textColor,defaultText,textSize,slotPosition,imageShape} from '../lib/native-layout'; -function Preview({layout}:{layout:Layout}){return
{layout.elements.map(s=>
{s.kind==='text'?
{defaultText(layout,s)}
:s.kind==='image'?
:
}
)}
} +import {nativeLayout} from '../lib/native-layout'; +import {NativePreview as Preview} from '../components/NativePreview'; export default function StudioEntry(){return
Studio · 可视化排版 API 库

用参数,编排画面。

给 coding agent 文字、图片与创作意图。选择标准图文或固定构图,通过 HTTP 保存渲染描述,得到一个可持续更新的原生页面。

标准排版

参数

一页,一件事。

沿用 Studio 标题、正文与页脚的图文结构,增加可配置图片位。文字、颜色、图片和几何坐标共用一套参数,也可添加原生元素。

layoutId: standard-01

打开原生页面

固定排版 · 27 套

使用文档
} diff --git a/apps/studio/src/styles/globals.css b/apps/studio/src/styles/globals.css index 35afaab..044a073 100644 --- a/apps/studio/src/styles/globals.css +++ b/apps/studio/src/styles/globals.css @@ -81,3 +81,6 @@ .ref-21 .direction-rotated,.ref-24 .direction-angled,.ref-26 .native-text[data-element=T01] .native-copy,.ref-26 .native-text[data-element=T02] .native-copy,.ref-26 .native-text[data-element=T03] .native-copy{font-family:Georgia,"Songti SC",serif;font-weight:400;letter-spacing:-.055em} .studio-library{max-width:1440px;margin:auto;padding:48px 5vw 80px}.library-nav{display:flex;justify-content:space-between;font-size:13px}.studio-library h1{font-size:clamp(32px,5vw,64px);letter-spacing:-.05em;margin:64px 0 16px}.library-intro{max-width:640px;line-height:1.8;color:#69716e}.library-heading{margin:64px 0 24px;display:flex;align-items:baseline;gap:18px}.library-heading h2{margin:0;font-size:24px}.library-heading a{font-size:13px;text-decoration:underline}.library-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:36px 24px}.library-item{min-width:0}.library-item .native-layout{width:100%;pointer-events:none}.library-item h3{font-size:13px;font-weight:500;margin:14px 0 5px}.library-item p{font-size:11px;color:#7b837e;margin:0}.library-item>a:focus-visible{outline:2px solid #48786a;outline-offset:5px}.library-standard{display:grid;grid-template-columns:minmax(200px,320px) 1fr;gap:40px;align-items:center}.library-standard p{max-width:480px;line-height:1.9;color:#69716e}.library-standard code{font-size:13px}.library-standard a{text-decoration:underline}.library-item:hover .native-layout{filter:brightness(1.025)} @media(max-width:950px){.library-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}@media(max-width:650px){.library-grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:26px 16px}.library-standard{grid-template-columns:1fr}.library-standard .native-layout{max-width:280px}.studio-library{padding:28px 20px 60px}.library-nav{gap:12px}} + +/* Keep the cross-axis line box inside narrow vertical title slots. */ +.native-copy.direction-vertical,.native-copy.direction-rotated{line-height:1} diff --git a/scripts/verify-studio-visual.cjs b/scripts/verify-studio-visual.cjs index b75f0ba..b72de1e 100644 --- a/scripts/verify-studio-visual.cjs +++ b/scripts/verify-studio-visual.cjs @@ -12,13 +12,14 @@ const assert=require('node:assert/strict'); const context=await browser.newContext({viewport:{width:1100,height:1000},deviceScaleFactor:1}); await context.route('**/*',async route=>{const u=new URL(route.request().url());if(u.origin===origin)return route.continue({headers:{...route.request().headers(),Authorization:'Bearer '+token}});if(['data:','blob:'].includes(u.protocol))return route.continue();return route.abort()}); const page=await context.newPage(),errors=[],images=[]; - page.on('pageerror',error=>errors.push(error.message)); + page.on('pageerror',error=>errors.push({url:page.url(),message:error.message})); page.on('response',async response=>{if(response.request().resourceType()==='image'){const url=new URL(response.url());images.push({path:url.pathname+url.search,status:response.status(),type:response.headers()['content-type'],bytes:(await response.body().catch(()=>Buffer.alloc(0))).length})}}); - const metrics=[]; - for(let i=1;i<=27;i++){ + const metrics=process.argv.includes('--after-layouts')?JSON.parse(await readFile(dir+'/metrics.json','utf8')).metrics:[]; + for(let i=1;i<=27&&!process.argv.includes('--after-layouts');i++){ const id='ref-'+String(i).padStart(2,'0');await page.goto(base+'/studio/layouts/'+id,{waitUntil:'networkidle'});await page.locator('article.native-layout').screenshot({path:dir+'/'+id+'.png'}); const m=await page.locator('article.native-layout').evaluate(el=>({id:el.dataset.layoutId,elements:el.querySelectorAll('[data-element]').length,width:el.clientWidth,height:el.clientHeight,overflow:[...el.querySelectorAll('.native-copy')].filter(e=>e.scrollHeight>e.clientHeight+2||e.scrollWidth>e.clientWidth+2).map(e=>e.closest('[data-element]').dataset.element)}));metrics.push(m);await writeFile(dir+'/metrics.json',JSON.stringify({metrics,images,errors},null,2)); } + if(!process.argv.includes('--after-layouts'))assert.deepEqual(metrics.filter(m=>m.overflow.length),[],'Default copy must fit every native slot'); await page.goto(base+'/studio',{waitUntil:'networkidle'});assert.equal(await page.locator('.library-item').count(),27);await page.screenshot({path:dir+'/catalog-desktop.png',fullPage:true}); const verify=JSON.parse(await readFile('/tmp/studio-library/verification.json','utf8')); for(const d of verify.results){await page.goto(base+d.url,{waitUntil:'networkidle'});assert.equal(await page.locator('img').count(),1);const current=await page.locator('img').evaluate(img=>({src:img.currentSrc,complete:img.complete,width:img.naturalWidth}));assert.ok(current.complete&¤t.width>0);assert.equal(new URL(current.src).pathname,'/api/preview');await page.locator('article.native-layout').screenshot({path:dir+'/'+d.layoutId+'-saved.png'}); @@ -28,7 +29,8 @@ const assert=require('node:assert/strict'); await page.setViewportSize({width:390,height:844});await page.goto(base+'/studio',{waitUntil:'networkidle'});assert.ok(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));await page.screenshot({path:dir+'/catalog-mobile.png',fullPage:true}); await page.goto(base+verify.results[0].url,{waitUntil:'networkidle'});await page.screenshot({path:dir+'/standard-mobile.png',fullPage:true}); await page.goto(base+'/categories?category='+encodeURIComponent('文档入口'),{waitUntil:'domcontentloaded',timeout:60000});await page.getByRole('navigation',{name:'系统标签分类',exact:true}).waitFor({state:'visible'});await page.screenshot({path:dir+'/document-entries.png',fullPage:true}); - assert.equal(images.filter(r=>new URL(r.path,base).pathname.startsWith('/api/media/')).length,0,'Display must never fetch originals');assert.equal(errors.length,0,JSON.stringify(errors));for(const image of images){assert.equal(new URL(image.path,base).pathname,'/api/preview');assert.equal(image.status,200);assert.equal(image.type,'image/webp');assert.ok(image.bytes>0&&image.bytes<=220000)} + await writeFile(dir+'/metrics.json',JSON.stringify({metrics,images,errors},null,2)); + assert.equal(images.filter(r=>new URL(r.path,base).pathname.startsWith('/api/media/')).length,0,'Display must never fetch originals');assert.equal(errors.length,0,JSON.stringify(errors));for(const image of images){if(new URL(image.path,base).pathname==='/brand-mark.svg'){assert.equal(image.status,200);assert.match(image.type,/image\/svg\+xml/);continue;}assert.equal(new URL(image.path,base).pathname,'/api/preview');assert.equal(image.status,200);assert.equal(image.type,'image/webp');assert.ok(image.bytes>0&&image.bytes<=220000)} await writeFile(dir+'/metrics.json',JSON.stringify({metrics,images,errors},null,2));await browser.close(); console.log(JSON.stringify({pages:27,errors:errors.length,displayImages:images.length,originalImageRequests:0,overflow:metrics.filter(m=>m.overflow.length)})); })().catch(e=>{console.error(e.message);process.exit(1)}); -- 2.52.0 From 5beddf1d8519f9924dfeccc3b9aff8913690b315 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Wed, 28 Oct 2026 16:25:29 +0800 Subject: [PATCH 5/7] Run server acceptance through CI artifacts and clean task temporaries --- .gitea/workflows/studio.yml | 24 ++++++++++++++ scripts/verify-studio-production.sh | 50 +++++++++++++++++++++++++++++ scripts/verify-studio-visual.cjs | 8 ++--- 3 files changed, 78 insertions(+), 4 deletions(-) create mode 100644 scripts/verify-studio-production.sh diff --git a/.gitea/workflows/studio.yml b/.gitea/workflows/studio.yml index fcbecdd..ae2b792 100644 --- a/.gitea/workflows/studio.yml +++ b/.gitea/workflows/studio.yml @@ -5,12 +5,18 @@ on: paths: - 'apps/studio/**' - 'scripts/deploy-studio.sh' + - 'scripts/verify-studio-production.sh' + - 'scripts/verify-studio-library.mjs' + - 'scripts/verify-studio-visual.cjs' - '.gitea/workflows/studio.yml' - 'pnpm-workspace.yaml' pull_request: paths: - 'apps/studio/**' - 'scripts/deploy-studio.sh' + - 'scripts/verify-studio-production.sh' + - 'scripts/verify-studio-library.mjs' + - 'scripts/verify-studio-visual.cjs' - '.gitea/workflows/studio.yml' - 'pnpm-workspace.yaml' workflow_dispatch: @@ -28,3 +34,21 @@ jobs: - name: Release studio if: gitea.ref == 'refs/heads/main' && (gitea.event_name == 'push' || gitea.event_name == 'workflow_dispatch') run: bash scripts/deploy-studio.sh "${{ gitea.sha }}" + - name: Capture deployed baseline diagnostics (does not validate PR code) + if: gitea.event_name == 'pull_request' + run: bash scripts/verify-studio-production.sh --diagnose + - name: Verify the deployed Studio API and native pages + if: gitea.ref == 'refs/heads/main' && (gitea.event_name == 'push' || gitea.event_name == 'workflow_dispatch') + run: bash scripts/verify-studio-production.sh + - name: Save server acceptance evidence + id: acceptance_evidence + if: always() + uses: actions/upload-artifact@v3 + with: + name: studio-production-acceptance + path: artifacts/studio-acceptance/ + if-no-files-found: ignore + retention-days: 7 + - name: Remove temporary acceptance output after upload + if: always() && steps.acceptance_evidence.outcome == 'success' + run: rm -rf artifacts/studio-acceptance diff --git a/scripts/verify-studio-production.sh b/scripts/verify-studio-production.sh new file mode 100644 index 0000000..1f26adc --- /dev/null +++ b/scripts/verify-studio-production.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# Run on ces-control after deployment; never starts a preview web server. +set -Eeuo pipefail +REPO_ROOT=$(git rev-parse --show-toplevel) +RESULT_DIR="$REPO_ROOT/artifacts/studio-acceptance" +MODE=${1:-verify} +[[ "$MODE" == verify || "$MODE" == --diagnose ]] || exit 2 +WIKI_IMAGE=$(docker inspect --format '{{.Config.Image}}' prompt-waterfall-wiki-1) +[[ "$WIKI_IMAGE" =~ ^waterfall-wiki:[0-9a-f]{40}$ ]] || { echo 'Unexpected Wiki image'; exit 1; } +TEST_IMAGE="waterfall-wiki-tests:${WIKI_IMAGE#waterfall-wiki:}" +docker image inspect "$TEST_IMAGE" >/dev/null +mkdir -p "$RESULT_DIR" +chown 1000:1000 "$RESULT_DIR" +run_api(){ + local result_root=$1 script=$2; shift 2 + docker run --rm --volumes-from prompt-waterfall-wiki-1:ro \ + --mount "type=bind,src=$REPO_ROOT,dst=/delivery,readonly" \ + --mount "type=bind,src=$result_root,dst=/tmp/studio-library" \ + -e AUTH_CONFIG_FILE=/auth/config.json -e AUTH_CHECK_BASE_URL=http://8.211.154.134:18131 \ + --entrypoint node "$TEST_IMAGE" dist/auth-cli.cjs run "/delivery/scripts/$script" "$@" +} +# Clean only this task's explicitly named legacy temporary directories. Preserve test data via archive. +if [[ -f /tmp/studio-library/verification.json ]]; then + run_api /tmp/studio-library verify-studio-library.mjs --finalize +fi +if [[ -d /tmp/studio-library-source ]]; then + test -z "$(git -C /tmp/studio-library-source status --porcelain)" || { echo 'Temporary source has unexpected edits; preserved'; exit 1; } + git --git-dir=/tmp/studio-library-delivery.git worktree remove /tmp/studio-library-source +fi +rm -rf /tmp/studio-library-delivery.git /tmp/studio-library-evidence /tmp/studio-library +for old_path in /tmp/studio-library-source /tmp/studio-library-delivery.git /tmp/studio-library-evidence /tmp/studio-library; do + test ! -e "$old_path" || exit 1 +done +printf '{"legacyTemporaryDirectoriesRemoved":true}\n' > "$RESULT_DIR/cleanup.json" +# Always archive only the UUIDs created by this run, including when visual checks fail. +finish(){ + local status=$? + trap - EXIT + if [[ -f "$RESULT_DIR/verification.json" ]]; then + if ! run_api "$RESULT_DIR" verify-studio-library.mjs --finalize; then status=1; fi + fi + exit "$status" +} +trap finish EXIT +run_api "$RESULT_DIR" verify-studio-library.mjs +docker run --rm --volumes-from prompt-waterfall-wiki-1:ro \ + --mount "type=bind,src=$REPO_ROOT,dst=/delivery,readonly" \ + --mount "type=bind,src=$RESULT_DIR,dst=/tmp/studio-library" \ + -e AUTH_CONFIG_FILE=/auth/config.json -e NODE_PATH=/app/node_modules \ + --entrypoint node "$TEST_IMAGE" /delivery/scripts/verify-studio-visual.cjs "$MODE" diff --git a/scripts/verify-studio-visual.cjs b/scripts/verify-studio-visual.cjs index b72de1e..b0562cb 100644 --- a/scripts/verify-studio-visual.cjs +++ b/scripts/verify-studio-visual.cjs @@ -19,7 +19,7 @@ const assert=require('node:assert/strict'); const id='ref-'+String(i).padStart(2,'0');await page.goto(base+'/studio/layouts/'+id,{waitUntil:'networkidle'});await page.locator('article.native-layout').screenshot({path:dir+'/'+id+'.png'}); const m=await page.locator('article.native-layout').evaluate(el=>({id:el.dataset.layoutId,elements:el.querySelectorAll('[data-element]').length,width:el.clientWidth,height:el.clientHeight,overflow:[...el.querySelectorAll('.native-copy')].filter(e=>e.scrollHeight>e.clientHeight+2||e.scrollWidth>e.clientWidth+2).map(e=>e.closest('[data-element]').dataset.element)}));metrics.push(m);await writeFile(dir+'/metrics.json',JSON.stringify({metrics,images,errors},null,2)); } - if(!process.argv.includes('--after-layouts'))assert.deepEqual(metrics.filter(m=>m.overflow.length),[],'Default copy must fit every native slot'); + if(!process.argv.includes('--after-layouts')&&!process.argv.includes('--diagnose'))assert.deepEqual(metrics.filter(m=>m.overflow.length),[],'Default copy must fit every native slot'); await page.goto(base+'/studio',{waitUntil:'networkidle'});assert.equal(await page.locator('.library-item').count(),27);await page.screenshot({path:dir+'/catalog-desktop.png',fullPage:true}); const verify=JSON.parse(await readFile('/tmp/studio-library/verification.json','utf8')); for(const d of verify.results){await page.goto(base+d.url,{waitUntil:'networkidle'});assert.equal(await page.locator('img').count(),1);const current=await page.locator('img').evaluate(img=>({src:img.currentSrc,complete:img.complete,width:img.naturalWidth}));assert.ok(current.complete&¤t.width>0);assert.equal(new URL(current.src).pathname,'/api/preview');await page.locator('article.native-layout').screenshot({path:dir+'/'+d.layoutId+'-saved.png'}); @@ -28,9 +28,9 @@ const assert=require('node:assert/strict'); for(const slug of ['learning-skills-template','layout-selection']){await page.goto(base+'/studio/ideas/'+slug,{waitUntil:'networkidle'});await page.screenshot({path:dir+'/tool-'+slug+'.png',fullPage:true})} await page.setViewportSize({width:390,height:844});await page.goto(base+'/studio',{waitUntil:'networkidle'});assert.ok(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));await page.screenshot({path:dir+'/catalog-mobile.png',fullPage:true}); await page.goto(base+verify.results[0].url,{waitUntil:'networkidle'});await page.screenshot({path:dir+'/standard-mobile.png',fullPage:true}); - await page.goto(base+'/categories?category='+encodeURIComponent('文档入口'),{waitUntil:'domcontentloaded',timeout:60000});await page.getByRole('navigation',{name:'系统标签分类',exact:true}).waitFor({state:'visible'});await page.screenshot({path:dir+'/document-entries.png',fullPage:true}); + await page.goto(base+'/categories?category='+encodeURIComponent('文档入口'),{waitUntil:'domcontentloaded',timeout:60000});await page.getByRole('navigation',{name:'系统标签分类',exact:true}).waitFor({state:'visible'});assert.equal(await page.locator('.discovery-card').count(),3);await page.waitForFunction(()=>Array.from(document.querySelectorAll('.discovery-card img')).every(img=>img.complete&&img.naturalWidth>0),null,{timeout:60000});await page.screenshot({path:dir+'/document-entries.png',fullPage:true}); await writeFile(dir+'/metrics.json',JSON.stringify({metrics,images,errors},null,2)); - assert.equal(images.filter(r=>new URL(r.path,base).pathname.startsWith('/api/media/')).length,0,'Display must never fetch originals');assert.equal(errors.length,0,JSON.stringify(errors));for(const image of images){if(new URL(image.path,base).pathname==='/brand-mark.svg'){assert.equal(image.status,200);assert.match(image.type,/image\/svg\+xml/);continue;}assert.equal(new URL(image.path,base).pathname,'/api/preview');assert.equal(image.status,200);assert.equal(image.type,'image/webp');assert.ok(image.bytes>0&&image.bytes<=220000)} + assert.equal(images.filter(r=>new URL(r.path,base).pathname.startsWith('/api/media/')).length,0,'Display must never fetch originals');if(!process.argv.includes('--diagnose'))assert.equal(errors.length,0,JSON.stringify(errors));for(const image of images){if(new URL(image.path,base).pathname==='/brand-mark.svg'){assert.equal(image.status,200);assert.match(image.type,/image\/svg\+xml/);continue;}assert.equal(new URL(image.path,base).pathname,'/api/preview');assert.equal(image.status,200);assert.equal(image.type,'image/webp');assert.ok(image.bytes>0&&image.bytes<=220000)} await writeFile(dir+'/metrics.json',JSON.stringify({metrics,images,errors},null,2));await browser.close(); - console.log(JSON.stringify({pages:27,errors:errors.length,displayImages:images.length,originalImageRequests:0,overflow:metrics.filter(m=>m.overflow.length)})); + console.log(JSON.stringify({diagnostic:process.argv.includes('--diagnose'),pages:27,errors:errors.length,displayImages:images.length,originalImageRequests:0,overflow:metrics.filter(m=>m.overflow.length)})); })().catch(e=>{console.error(e.message);process.exit(1)}); -- 2.52.0 From ce1963f692569f673fdbc5c6d731ca7ed39b1265 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Wed, 28 Oct 2026 17:27:28 +0800 Subject: [PATCH 6/7] Improve fixed-layout copy contrast and correct SVG title hydration --- apps/studio/src/components/Ref17Cover.tsx | 2 +- apps/studio/src/lib/native-layout.ts | 17 ++++++++++------- apps/studio/src/styles/globals.css | 2 ++ scripts/verify-studio-visual.cjs | 4 ++-- 4 files changed, 15 insertions(+), 10 deletions(-) diff --git a/apps/studio/src/components/Ref17Cover.tsx b/apps/studio/src/components/Ref17Cover.tsx index 5f88822..37556a4 100644 --- a/apps/studio/src/components/Ref17Cover.tsx +++ b/apps/studio/src/components/Ref17Cover.tsx @@ -11,7 +11,7 @@ export function Ref17Cover({template,slide,masthead=template.masthead,guides=fal const bodySize=Math.min(32,...body.map(s=>sW/Math.max(1,Array.from(s).reduce((n,c)=>n+(/[\u3400-\u9fff]/.test(c)?1:.5),0)))); const accent=slide.accent||'#d8ddd4'; return - {slide.label||slide.heading} · 模板试版源自 ref-17 的九个固定构件。图片位使用色块,占位文案可替换,暂无具体工具内容。 + {(slide.label||slide.heading)+' · 模板试版'}源自 ref-17 的九个固定构件。图片位使用色块,占位文案可替换,暂无具体工具内容。 diff --git a/apps/studio/src/lib/native-layout.ts b/apps/studio/src/lib/native-layout.ts index fe174a4..ea19c4f 100644 --- a/apps/studio/src/lib/native-layout.ts +++ b/apps/studio/src/lib/native-layout.ts @@ -9,28 +9,29 @@ export const designUrl=(layoutId:string,id:string)=>`/studio/layouts/${layoutId} export function nativeLayout(layout:Layout):Layout { return {id:layout.id,name:layout.name,width:layout.width,height:layout.height,elements:layout.elements.map(({id,kind,role,box,direction,text,reading})=>({id,kind,role,box,direction,text,reading}))}; } -// Four coordinated inks per composition: paper, ink, image field and accent. +// Each composition has its own paper and inks; layered artwork can add a middle tone. const palettes=[ ['#f3eee4','#293b42','#7c9a9e','#c4d5cb'],['#f5f2e9','#344335','#879782','#d7c39f'],['#efede7','#383d4a','#8996a6','#c5d0d7'], - ['#647c7a','#f5f0e5','#b6c8be','#d9ded0'],['#f5eddf','#5c3b32','#b47d68','#d9bba0'],['#eceee8','#263d43','#688a94','#b4c7c7'], + ['#5e7472','#f5f0e5','#354e4a','#d9ded0'],['#f5eddf','#5c3b32','#b47d68','#d9bba0'],['#eceee8','#263d43','#688a94','#b4c7c7'], ['#f3eddf','#4c4a36','#a1a185','#d8cba9'],['#edf0ef','#23424b','#73929a','#b6cbd2'],['#f5f1e9','#2f3e38','#7f9688','#d1d7ba'], ['#eeece4','#344b57','#526e80','#afc4c9'],['#f4eee8','#4e393b','#a58a90','#d8bdb3'],['#f1f0e9','#424b36','#8c997b','#d1c7aa'], ['#f3ebe5','#5b3e39','#b48e7e','#dec6ab'],['#eef0e7','#384c42','#789485','#c4d1b5'],['#e9edee','#2e4555','#839ba8','#c6d3d4'], ['#f4f0e7','#50483a','#ada18a','#d9d1c0'],['#eeeee5','#394d42','#7e9785','#c8d5bc'],['#f2ede6','#3e424e','#8a92a3','#d0c7c0'], ['#f5eede','#443d31','#a79b7a','#dbca9f'],['#eeefe8','#34463f','#809b89','#c5d3ba'],['#f0ebe5','#51413d','#a48b7f','#d5bfb3'], - ['#64797d','#f8f2e8','#9fb3b5','#cbd4c9'],['#f0efe9','#2f414d','#8199a7','#c5d2d6'],['#f4eee5','#5a443b','#ae927b','#dfc8ac'], + ['#64797d','#142a2e','#b5c7c7','#3d565b','#7c9497'],['#f0efe9','#2f414d','#8199a7','#c5d2d6'],['#f4eee5','#5a443b','#ae927b','#dfc8ac'], ['#ebeee8','#344b41','#829c8b','#c7d5bb'],['#edece8','#3c4354','#8b93aa','#c5cbd7'],['#f2ece2','#3b4e4b','#6f918c','#b8cdc3'] ]; export function layoutPalette(id:string){return palettes[Number(id.slice(4))-1]||['#ffe5df','#303d43','#a9bec0','#f0bca6']} export function paperColor(id:string){return layoutPalette(id)[0]} -export function textColor(id:string,slot:Slot){return (id==='ref-04'&&['T01','T02'].includes(slot.id))||(id==='ref-22'&&slot.id==='T01')||(id==='ref-27'&&slot.id==='T01')||(id==='ref-10'&&slot.id==='T03')?'#f9faf5':layoutPalette(id)[1]} +export function textColor(id:string,slot:Slot){return (id==='ref-04'&&['T01','T02'].includes(slot.id))||(id==='ref-22'&&['T01','T03','T04'].includes(slot.id))||(id==='ref-27'&&slot.id==='T01')||(id==='ref-10'&&slot.id==='T03')?'#f9faf5':layoutPalette(id)[1]} // The screenshot's illegible small print is replaced with editable editorial copy. export function defaultText(layout:Layout,slot:Slot):string { if(slot.text)return slot.text; if(slot.direction==='curved')return 'BULUBLUE DESIGN · FORM AND SPACE · EDITORIAL DESIGN ·'; if(slot.role.includes('日期'))return '2025 / 03 / 25'; + if(slot.role.includes('微字'))return slot.box[2]>60?'FORM / SPACE / DESIGN / 2026':'FORM / '+layout.id.slice(-2); if(slot.role.includes('英文')||slot.role.includes('品牌')||slot.role.includes('标识'))return 'BULUBLUE DESIGN'; - if(slot.role.includes('图注')||slot.role.includes('标签')||slot.box[3]<10)return 'FORM / SPACE / DESIGN'; + if(slot.role.includes('图注')||slot.role.includes('标签')||slot.box[3]<10)return slot.box[2]<25?'FORM / '+layout.id.slice(-2):'FORM / SPACE / DESIGN'; if(slot.direction==='vertical')return '设计灵感 · 日常观察'; return slot.box[2]<25?'留白之间\n观察日常\n记录灵感':'让文字与形状相遇。\n在留白之间,寻找新的秩序。'; } @@ -42,14 +43,16 @@ export function textSize(layout:Layout,slot:Slot,text:string):number { const length=(s:string)=>Array.from(s).reduce((n,c)=>n+(/[\u3400-\u9fff]/.test(c)?1:/[A-Z]/.test(c)?.8:.6),0); if(slot.direction==='vertical')return Math.min(w*.9,h/Math.max(1,Array.from(text.replaceAll('\n','')).length)*.88); if(slot.direction==='rotated')return Math.min(w*.85,h/Math.max(1,length(text))); - if(slot.direction==='curved')return Math.min(2.2,w/Math.max(1,length(text))); - return Math.min(h/(lines.length*1.15),w/Math.max(1,...lines.map(length)))*.92; + if(slot.direction==='curved')return Math.min(2.2,Math.max(w,h)*1.1/Math.max(1,length(text))); + const fitted=Math.min(h/(lines.length*1.15),w/Math.max(1,...lines.map(length)))*.92; + return /微字|脚注|图注|标签/.test(slot.role)?Math.min(fitted,2.2):fitted; } export function imageShape(layout:Layout,slot:Slot):CSSProperties { const n=Number(layout.id.slice(4)),s:CSSProperties={backgroundColor:layoutPalette(layout.id)[slot.id==='I01'?2:3],backgroundImage:'linear-gradient(145deg,#ffffff12,transparent 65%)'}; if([4,5,7,13,19,20].includes(n)||(n===17&&slot.id==='I02'))s.borderRadius='50%'; if(n===4)s.transform='rotate(-9deg)'; if(n===15&&slot.id==='I01')s.borderRadius='100% 0 0 0'; + if(n===22)s.backgroundColor=layoutPalette(layout.id)[slot.id==='I02'?3:slot.id==='I03'?4:2]; if(n===22)s.clipPath=slot.id==='I01'?'polygon(0 0,100% 0,100% 48%,0 100%)':slot.id==='I02'?'polygon(0 30%,100% 0,100% 70%,0 100%)':'polygon(0 50%,100% 0,100% 100%,0 100%)'; if(n===27)s.clipPath=slot.id==='I01'?'polygon(0 0,100% 35%,100% 100%,0 68%)':'polygon(0 40%,100% 0,100% 100%,0 100%)'; if(n===10)s.backgroundColor=layoutPalette(layout.id)[2]; diff --git a/apps/studio/src/styles/globals.css b/apps/studio/src/styles/globals.css index 044a073..943c137 100644 --- a/apps/studio/src/styles/globals.css +++ b/apps/studio/src/styles/globals.css @@ -84,3 +84,5 @@ /* Keep the cross-axis line box inside narrow vertical title slots. */ .native-copy.direction-vertical,.native-copy.direction-rotated{line-height:1} + +.ref-19 .native-text:is([data-element=T01],[data-element=T02],[data-element=T03],[data-element=T04]) .native-copy{font-weight:700;letter-spacing:-.06em} diff --git a/scripts/verify-studio-visual.cjs b/scripts/verify-studio-visual.cjs index b0562cb..810681b 100644 --- a/scripts/verify-studio-visual.cjs +++ b/scripts/verify-studio-visual.cjs @@ -16,13 +16,13 @@ const assert=require('node:assert/strict'); page.on('response',async response=>{if(response.request().resourceType()==='image'){const url=new URL(response.url());images.push({path:url.pathname+url.search,status:response.status(),type:response.headers()['content-type'],bytes:(await response.body().catch(()=>Buffer.alloc(0))).length})}}); const metrics=process.argv.includes('--after-layouts')?JSON.parse(await readFile(dir+'/metrics.json','utf8')).metrics:[]; for(let i=1;i<=27&&!process.argv.includes('--after-layouts');i++){ - const id='ref-'+String(i).padStart(2,'0');await page.goto(base+'/studio/layouts/'+id,{waitUntil:'networkidle'});await page.locator('article.native-layout').screenshot({path:dir+'/'+id+'.png'}); + const id='ref-'+String(i).padStart(2,'0');await page.goto(base+'/studio/layouts/'+id,{waitUntil:'networkidle'});await page.locator('article.native-layout').screenshot({path:dir+'/'+id+'.png',style:'.page-tools{visibility:hidden}'}); const m=await page.locator('article.native-layout').evaluate(el=>({id:el.dataset.layoutId,elements:el.querySelectorAll('[data-element]').length,width:el.clientWidth,height:el.clientHeight,overflow:[...el.querySelectorAll('.native-copy')].filter(e=>e.scrollHeight>e.clientHeight+2||e.scrollWidth>e.clientWidth+2).map(e=>e.closest('[data-element]').dataset.element)}));metrics.push(m);await writeFile(dir+'/metrics.json',JSON.stringify({metrics,images,errors},null,2)); } if(!process.argv.includes('--after-layouts')&&!process.argv.includes('--diagnose'))assert.deepEqual(metrics.filter(m=>m.overflow.length),[],'Default copy must fit every native slot'); await page.goto(base+'/studio',{waitUntil:'networkidle'});assert.equal(await page.locator('.library-item').count(),27);await page.screenshot({path:dir+'/catalog-desktop.png',fullPage:true}); const verify=JSON.parse(await readFile('/tmp/studio-library/verification.json','utf8')); - for(const d of verify.results){await page.goto(base+d.url,{waitUntil:'networkidle'});assert.equal(await page.locator('img').count(),1);const current=await page.locator('img').evaluate(img=>({src:img.currentSrc,complete:img.complete,width:img.naturalWidth}));assert.ok(current.complete&¤t.width>0);assert.equal(new URL(current.src).pathname,'/api/preview');await page.locator('article.native-layout').screenshot({path:dir+'/'+d.layoutId+'-saved.png'}); + for(const d of verify.results){await page.goto(base+d.url,{waitUntil:'networkidle'});assert.equal(await page.locator('img').count(),1);const current=await page.locator('img').evaluate(img=>({src:img.currentSrc,complete:img.complete,width:img.naturalWidth}));assert.ok(current.complete&¤t.width>0);assert.equal(new URL(current.src).pathname,'/api/preview');await page.locator('article.native-layout').screenshot({path:dir+'/'+d.layoutId+'-saved.png',style:'.page-tools{visibility:hidden}'}); await page.getByRole('button',{name:'编辑页面',exact:true}).click();assert.equal(await page.locator('.workbench-top').count(),1);await page.screenshot({path:dir+'/'+d.layoutId+'-editor.png'}); } for(const slug of ['learning-skills-template','layout-selection']){await page.goto(base+'/studio/ideas/'+slug,{waitUntil:'networkidle'});await page.screenshot({path:dir+'/tool-'+slug+'.png',fullPage:true})} -- 2.52.0 From 06999cd8a6d6ce53a2d052d7ca6046b400530033 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Wed, 28 Oct 2026 17:37:46 +0800 Subject: [PATCH 7/7] Keep recurring acceptance cleanup scoped to each test run --- scripts/verify-studio-library.mjs | 13 +++++++++---- scripts/verify-studio-production.sh | 15 ++------------- 2 files changed, 11 insertions(+), 17 deletions(-) diff --git a/scripts/verify-studio-library.mjs b/scripts/verify-studio-library.mjs index c0f2d5d..99579b5 100644 --- a/scripts/verify-studio-library.mjs +++ b/scripts/verify-studio-library.mjs @@ -5,16 +5,19 @@ async function api(path,body,method=body?'POST':'GET',status=200){const r=await if(process.argv.includes('--finalize')){ const {results}=JSON.parse(await (await import('node:fs/promises')).readFile('/tmp/studio-library/verification.json','utf8')); const canonical='8eb8c76500580f7a4b56ba3c',legacy='9ebd8f78d54b4b9db741b4e8'; + let documentFailure;let category; + try{ const entry=(await api('/api/notes/'+canonical)).entry; for(const needle of ['standard-01','DELETE','revision','cropX','兼容工具原说明'])assert.ok(entry.editableMarkdown.includes(needle),needle); for(const id of [canonical,legacy])assert.equal((await fetch(base+'/explore/'+id)).status,200); const old=(await api('/api/notes/'+legacy)).entry;assert.ok(old.archived&&old.editableMarkdown.includes(canonical)); - const category=await api('/api/explore-feed?category='+encodeURIComponent('文档入口')); + category=await api('/api/explore-feed?category='+encodeURIComponent('文档入口')); assert.equal(category.total,3);assert.equal(category.nextCursor,null); assert.deepEqual(category.entries.map(e=>e.id).sort(),['3c98946754c33309d73d5284','4ce3283fd98bb03a8fb39a19',canonical].sort()); + }catch(error){documentFailure=error} for(const item of results){const d=(await api('/api/studio/designs/'+item.id)).design;assert.ok(d.name.startsWith('API 验收'));assert.equal(d.postId,item.postId);if(!d.archived)await api('/api/studio/designs/'+d.id,{revision:d.revision},'DELETE');assert.equal((await api('/api/studio/designs/'+d.id)).design.archived,true)} - const final={canonical:base+'/explore/'+canonical,documentEntries:3,legacyCompatible:true,testRecordsArchived:results.length}; - await writeFile('/tmp/studio-library/final-verification.json',JSON.stringify(final,null,2));console.log(JSON.stringify(final)); + const final={canonical:base+'/explore/'+canonical,documentEntries:category?.total??null,legacyCompatible:!documentFailure,testRecordsArchived:results.length}; + await writeFile('/tmp/studio-library/final-verification.json',JSON.stringify(final,null,2));console.log(JSON.stringify(final));if(documentFailure)throw documentFailure; }else{ const catalog=await api('/studio/api/layouts');assert.equal(catalog.layouts.length,28); const note=(await api('/api/notes/8eb8c76500580f7a4b56ba3c')).entry; @@ -26,6 +29,8 @@ for(const layoutId of ['standard-01','ref-17']){ const p={texts:{T01:{value:'API 验收 · 原生页面',fontFamily:'serif',fontWeight:600}},images:{I01:{assetId:asset,fit:'cover',cropX:35,cropY:65,zoom:1.2}},added:[{id:'user-api-check',kind:'text',role:'扩展正文',box:[10,120,60,9],direction:'horizontal',text:'通过 HTTP 保存并恢复',reading:'custom'}],layers:{'user-api-check':{z:20,rotation:2}}}; let d=(await api('/api/studio/designs',{schemaVersion:1,layoutId,name:'API 验收 '+layoutId,parameters:p},'POST',201)).design; const id=d.id,postId=d.postId; + results.push({layoutId,id,url:d.url,revision:d.revision,postId,parameters:p}); + await writeFile('/tmp/studio-library/verification.json',JSON.stringify({results,preview:{asset,bytes,type:'image/webp'},pages:0},null,2)); assert.deepEqual((await api('/api/studio/designs/'+id)).design.parameters,d.parameters); const html=await fetch(base+d.url).then(r=>r.text());assert.ok(html.includes('API 验收'));assert.ok(html.includes('user-api-check'));assert.ok(html.includes('/api/preview?'));assert.ok(!html.includes('src="/api/media/')); d=(await api('/api/studio/designs/'+id,{name:'API 验收修订 '+layoutId,parameters:p,revision:d.revision},'PATCH')).design; @@ -36,7 +41,7 @@ for(const layoutId of ['standard-01','ref-17']){ assert.deepEqual((await api('/api/studio/designs/'+id)).design.parameters,d.parameters); assert.ok((await api('/api/studio/designs?archived=true&layoutId='+layoutId)).designs.some(v=>v.id===id)); d=(await api('/api/studio/designs/'+id,{revision:3,archived:false},'POST')).design;assert.equal(d.revision,4);assert.equal(d.archived,false);assert.equal((await fetch(base+d.url)).status,200); - results.push({layoutId,id,url:d.url,revision:d.revision,postId,parameters:p}); + Object.assign(results[results.length-1],{url:d.url,revision:d.revision}); // Restore tested URL for visual inspection, then archive only these clearly named test records later. } for(const l of catalog.layouts){const r=await fetch(base+l.url);assert.equal(r.status,200,l.id);const html=await r.text();assert.ok(html.includes('data-layout-id="'+l.id+'"'));assert.ok(!/ "$RESULT_DIR/cleanup.json" +# Result files are uploaded by the workflow and removed after successful upload. +printf '{"temporaryOutputManagedByCI":true}\n' > "$RESULT_DIR/cleanup.json" # Always archive only the UUIDs created by this run, including when visual checks fail. finish(){ local status=$? -- 2.52.0