From 0b549ca794f3dd76d3dcc6f93529107d3de1849a Mon Sep 17 00:00:00 2001 From: "xiaohan.yuan" Date: Sat, 17 Oct 2026 07:15:38 +0800 Subject: [PATCH] feat(sidebar): replicate WorkBuddy footer account row (TODO 07) --- docs/复刻待办清单-2026-10-17.md | 34 ++- scripts/verify-sidebar-footer.cjs | 205 ++++++++++++++++ src/renderer/src/components/Sidebar.tsx | 44 ++-- src/renderer/src/styles/sidebar.css | 227 ++++++++++++------ .../workbench/source-panel-layout.ts | 3 +- 5 files changed, 412 insertions(+), 101 deletions(-) create mode 100644 scripts/verify-sidebar-footer.cjs diff --git a/docs/复刻待办清单-2026-10-17.md b/docs/复刻待办清单-2026-10-17.md index c4e657f..826d56c 100644 --- a/docs/复刻待办清单-2026-10-17.md +++ b/docs/复刻待办清单-2026-10-17.md @@ -186,11 +186,37 @@ ### 07. 侧栏最下一行 -- [ ] 移除最下方不应存在的“收起菜单”入口。 -- [ ] 对齐最下一行的布局、高度、内边距、字体、头像、当前图标与悬停状态。 -- [ ] 验收:普通/窄侧栏、浅色/深色下均无残留入口或空白占位。 +- [x] 移除最下方不应存在的“收起菜单”入口。 +- [x] 对齐最下一行的布局、高度、内边距、字体、头像、当前图标与悬停状态。 +- [x] 验收:普通/窄侧栏、浅色/深色下均无残留入口或空白占位。 -状态:待 REA 对照。 +状态:已对齐并通过真实窗口回归(运行时几何 + 计算值 + hover 目标色);收起入口只在顶栏保留一处。 + +原版证据: +- 运行时布局 REA Evidence `ev_721d830c97114930e046ec8931bd2f473a11ab23efcab9c727c4256889e29879`:`.conversation-list` 宽 284.28(含 border-right 1px);`.conversation-list-footer` `[0,845,283.28,57]`、padding 8/12/8/8、border-top 1px `rgb(230,230,230)`、display flex + align-items center + justify-content flex-start;`.user-menu-trigger-wrapper` `[8,854,263.28,40]`、gap 4;`.user-menu-trigger--workbuddy` `[8,854,191.28,40]`、min-height 40 / padding 6px 8px / radius 8;`.user-menu-trigger-left` gap 10;头像 28×28 圆形;姓名 12px/600/12px;`.msg-center-bell-btn` 与 `.user-menu-trigger-miniprogram` 各 32×32 / radius 6。 +- 回收样式表 `contexts-BkjwYSZT.css`(`.conversation-list-footer{...}`、`.conversation-list-footer .user-menu{width:100%}`)与 `UserMenu-CpHz-0rF.css`(`.user-menu-trigger*`、`.user-menu-trigger--workbuddy:hover{background-color:var(--wb-bg-hover)}`、`.msg-center-bell-btn:hover{background-color:var(--wb-button-ghost-bg-hover);color:var(--wb-icon-primary)}`、`.msg-center-badge{...}`、`.user-menu-avatar-initial{background-color:#0056d4}`)。 +- 同一证据中侧栏收起源位于顶栏 `.conversation-list-topbar-actions`(`[8,3,76,24]`,3 个 24×24,x=8/34/60);底部该行只有账号 / 通知 / 小程序三个入口。 +- 观察范围:桌面端浅色主题、zoom=1、1336 宽窗口。未覆盖:深色主题运行时像素、原版真实头像图片。 + +实现: +- `components/Sidebar.tsx`:底部账号区(`.sidebar__footer.conversation-list-footer`)只保留账号触发器、通知铃铛、小程序三个入口;删除底部“收起侧栏”按钮及随之无引用的 `IconCollapseRail`。 +- `styles/sidebar.css`:逐条落地回收规则(footer padding/边框/flex、wrapper gap/宽度、触发器 min-height/padding/radius/`background-color .2s`、左组 gap 10、头像 28×28 圆形 + `#0056d4` 字面头像、姓名 12/600/12 省略号、铃铛与小程序 32×32/radius 6/`background-color, color .15s`、角标 14×14/radius 7/top 2/right 2/translate(40%,-30%));hover 直接使用回收 token `--wb-bg-hover`、`--wb-button-ghost-bg-hover`、`--wb-icon-primary`。 +- `workbuddy/workbench/source-panel-layout.ts`:展开态侧栏宽度 280 → 284,使整列与 footer 边界对齐原版 284.28(实测 footer `[0,845,283,57]`、wrapper `[8,854,263,40]`、触发器 191×40)。 +- 顶栏既有“收起侧边栏”入口(24×24)保持不变;折叠后由 `.sidebar__rail` 的“展开侧栏”恢复。 + +验证:新增 `scripts/verify-sidebar-footer.cjs`(真实隔离 Electron + 真实 preload/IPC/渲染进程)29 项断言全 PASS: +- 布局:footer padding 8/12/8/8、flex 契约、border-top 1px `rgb(230,230,230)`、高 57;wrapper gap 4 且子元素恰为账号 / 通知 / 小程序;footer 内不存在任何“收起”入口。 +- 组件:账号触发器 min-height 40 / padding 6px 8px / radius 8 / 过渡 `background-color .2s`、宽 191;左组 gap 10;头像 28×28 圆形 `rgb(0,86,212)`;姓名 12/600/12 + 省略号;铃铛 32×32 / radius 6 / `rgba(0,0,0,.7)` / `.15s`;小程序 32×32 / radius 6;角标几何与配色(`rgb(246,64,65)`、top/right/translate)。 +- hover:先留档“过渡冻结在起始值”现象(`playState: running`、`currentTime: 0`,QA 窗口无合成帧),随后关闭过渡读取目标色——铃铛 hover 背景等于 `--wb-button-ghost-bg-hover` 计算值 `rgba(0,0,0,0.06)`、图标色等于 `--wb-icon-primary` `rgba(0,0,0,0.9)`;账号触发器 hover 背景等于 `--wb-bg-hover` 计算值 `color(srgb 0 0 0 / 0.05)`。 +- 收起入口:顶栏 24×24 按钮仍可折叠侧栏,折叠轨道保留“展开侧栏”。 +- 回归:侧栏宽度变更后 `node scripts/verify-conversation-scrollbar.cjs`、`node scripts/verify-user-message-edit.cjs` 仍全 PASS;`npm run typecheck`、`npm run build` 通过。 +- 报告与截图:`.tmp/sidebar-footer-3edCU8/report.json`、`.tmp/sidebar-footer-3edCU8/sidebar-footer.png`。 + +遗留: +1. 侧栏宽度取整数 284,与原版运行时 284.28 存在 0.28px 分形残差(四舍五入后可见差异为 0)。 +2. 原版头像为真实图片(`.user-menu-trigger-avatar-img:after` 还有依赖 `--wb-avatar-fallback-*` 的 1px 内描边);YCode 无账号体系,用 `#0056d4` 字面初始头像且未接内描边。 +3. 深色主题底部行未采集运行时像素,仅按回收 token(white-6% / white-10%)对齐。 +4. 通知角标数据与小程序跳转为 mock(属 TODO 09 与待拆解项),本项只对齐入口与样式。 ### 08. 新建任务发送后的立即切页与占位 diff --git a/scripts/verify-sidebar-footer.cjs b/scripts/verify-sidebar-footer.cjs new file mode 100644 index 0000000..2ebfeba --- /dev/null +++ b/scripts/verify-sidebar-footer.cjs @@ -0,0 +1,205 @@ +// 真实隔离 YCode App + 真实 preload/IPC/渲染进程,验证侧栏底部账号区(TODO 07)。 +// 原版期望值: +// - 样式表 contexts-BkjwYSZT.css:.conversation-list-footer{border-top:.5px solid var(--wb-color-border-primary);flex-shrink:0; +// justify-content:flex-start;align-items:center;padding:8px 12px 8px 8px;display:flex} / .conversation-list-footer .user-menu{width:100%} +// - 样式表 UserMenu-CpHz-0rF.css:.user-menu-trigger-wrapper{gap:4px;width:100%;display:flex;position:relative}、 +// .user-menu-trigger--workbuddy{box-sizing:border-box;border-radius:8px;flex:1;min-width:0;min-height:40px;padding:6px 8px;overflow:hidden}、 +// .user-menu-trigger-left{gap:10px}、头像 28×28 圆形、.user-menu-trigger-name{font-size:12px;font-weight:600;line-height:12px}、 +// .msg-center-bell-btn / .user-menu-trigger-miniprogram{32×32;border-radius:6px;transition:background-color .15s,color .15s}、 +// .msg-center-badge{min-width:14px;height:14px;border-radius:7px;font-size:9px;line-height:14px;top:2px;right:2px;transform:translate(40%,-30%)} +// - 运行时布局证据 ev_721d830c97114930e046ec8931bd2f473a11ab23efcab9c727c4256889e29879: +// .conversation-list-footer [0,845,283.28,57]、padding 8/12/8/8、border-top 1px rgb(230,230,230); +// .user-menu-trigger-wrapper [8,854,263.28,40];触发器 191.28×40;铃铛/小程序各 32×32;头像 28×28;姓名 12/600/12; +// 同一证据里顶部 .conversation-list-topbar-actions 才承载收起/筛选入口,底部没有收起按钮。 +const fs = require('node:fs/promises') +const path = require('node:path') +const assert = require('node:assert/strict') + +if (!process.versions.electron) { + ;(async () => { + const directory = await fs.mkdtemp(path.resolve('.tmp/sidebar-footer-')) + const data = path.join(directory, 'data') + const home = path.join(directory, 'home') + const cwd = path.join(directory, 'fixture-workspace') + await fs.mkdir(data); await fs.mkdir(home); await fs.mkdir(cwd) + const now = Date.now() + const sessions = [{ id: 'footer-live', title: 'Footer Live', cwd, createdAt: now, updatedAt: now + 1, messages: [ + { id: 'footer-user', role: 'user', content: '底部账号行验收', createdAt: now, status: 'done', requestId: 'footer-run' }, + { id: 'footer-assistant', role: 'assistant', content: '收到。', createdAt: now + 1, status: 'done', requestId: 'footer-run', tools: [], parts: [] } + ] }] + await fs.writeFile(path.join(data, 'sessions.json'), JSON.stringify({ sessions })) + const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: data, + YCODE_OMP_HOME: home, YCODE_DISABLE_GPU: '1' } + delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RENDERER_URL + const child = require('node:child_process').spawn(require('electron'), + ['--no-sandbox', '--disable-gpu-sandbox', __filename], { env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }) + let output = '' + child.stdout.on('data', bytes => { output += bytes; if (bytes.toString().includes('SIDEBAR_FOOTER_')) process.stdout.write(bytes) }) + child.stderr.on('data', bytes => { output += bytes }) + const timer = setTimeout(() => child.kill(), 180000) + child.once('exit', async code => { + clearTimeout(timer) + await fs.writeFile(path.join(directory, 'process.log'), output) + console.log(JSON.stringify({ directory, exitCode: code })) + if (code) console.error(output) + process.exitCode = code ? 1 : 0 + }) + })() +} else { + const { app, BrowserWindow } = require('electron') + app.disableHardwareAcceleration() + const directory = process.env.YCODE_QA_DIRECTORY + assert.ok(path.relative(path.resolve('.tmp'), directory).startsWith('sidebar-footer-')) + app.setPath('userData', path.join(directory, 'data')); app.setPath('sessionData', path.join(directory, 'data')) + app.setPath('home', path.join(directory, 'home')) + let main, phase = 'boot' + const checks = [], snapshots = {}, rendererErrors = [] + const pause = ms => new Promise(resolve => setTimeout(resolve, ms)) + async function read(source) { + let timer + try { + return await Promise.race([main.webContents.executeJavaScript(source), new Promise((_, reject) => { + timer = setTimeout(() => reject(Error('Renderer timeout: ' + source.slice(0, 100))), 15000) + })]) + } catch (error) { throw Error(error.message + ' - ' + source.slice(0, 240)) } finally { clearTimeout(timer) } + } + async function wait(source, label) { + for (let index = 0; index < 250; index++) { const value = await read(source); if (value) return value; await pause(30) } + throw Error('Timeout: ' + label) + } + async function step(label) { phase = label; await fs.writeFile(path.join(directory, 'phase.txt'), label) } + function check(label, actual, expected) { + const pass = typeof expected === 'function' ? expected(actual) : JSON.stringify(actual) === JSON.stringify(expected) + checks.push({ label, pass, actual, expected: typeof expected === 'function' ? expected.toString() : expected }) + } + + const footerSource = `(()=>{const footer=document.querySelector('.conversation-list-footer');if(!footer)return null; + const style=getComputedStyle(footer);const box=footer.getBoundingClientRect(); + const wrapper=footer.querySelector('.user-menu-trigger-wrapper');const wstyle=getComputedStyle(wrapper);const wbox=wrapper.getBoundingClientRect(); + const trigger=footer.querySelector('.user-menu-trigger--workbuddy');const tstyle=getComputedStyle(trigger);const tbox=trigger.getBoundingClientRect(); + const left=footer.querySelector('.user-menu-trigger-left');const avatar=footer.querySelector('.user-menu-trigger-avatar-img'); + const name=footer.querySelector('.user-menu-trigger-name');const nstyle=getComputedStyle(name); + const bell=footer.querySelector('.msg-center-bell-btn');const bstyle=getComputedStyle(bell);const bbox=bell.getBoundingClientRect(); + const mini=footer.querySelector('.user-menu-trigger-miniprogram');const mstyle=getComputedStyle(mini);const mbox=mini.getBoundingClientRect(); + const badge=footer.querySelector('.msg-center-badge');const gstyle=badge?getComputedStyle(badge):null;const gbox=badge?badge.getBoundingClientRect():null; + const abox=avatar.getBoundingClientRect();const abstyle=getComputedStyle(avatar); + const box4=r=>[r.x,r.y,r.width,r.height]; + return {footer:{box:box4(box),padding:[style.paddingTop,style.paddingRight,style.paddingBottom,style.paddingLeft],borderTop:style.borderTopWidth,borderColor:style.borderTopColor,display:style.display,alignItems:style.alignItems,justifyContent:style.justifyContent}, + wrapper:{box:box4(wbox),gap:wstyle.gap,width:wstyle.width,display:wstyle.display,position:wstyle.position, + children:[...wrapper.children].map(node=>node.className)}, + trigger:{box:box4(tbox),minHeight:tstyle.minHeight,padding:tstyle.padding,borderRadius:tstyle.borderRadius,transition:tstyle.transitionProperty+' '+tstyle.transitionDuration,flexGrow:tstyle.flexGrow,flexShrink:tstyle.flexShrink}, + left:{gap:left?getComputedStyle(left).gap:null,display:left?getComputedStyle(left).display:null}, + avatar:{box:box4(abox),radius:abstyle.borderRadius,background:abstyle.backgroundColor,font:abstyle.fontSize+'/'+abstyle.lineHeight,color:abstyle.color}, + name:{font:nstyle.fontSize+'/'+nstyle.lineHeight,weight:nstyle.fontWeight,color:nstyle.color,overflow:nstyle.overflow,textOverflow:nstyle.textOverflow,whiteSpace:nstyle.whiteSpace}, + bell:{box:box4(bbox),radius:bstyle.borderRadius,color:bstyle.color,transition:bstyle.transitionProperty+' '+bstyle.transitionDuration}, + mini:{box:box4(mbox),radius:mstyle.borderRadius,color:mstyle.color}, + badge:badge?{box:box4(gbox),minWidth:gstyle.minWidth,height:gstyle.height,radius:gstyle.borderRadius,font:gstyle.fontSize+'/'+gstyle.lineHeight,weight:gstyle.fontWeight,background:gstyle.backgroundColor,color:gstyle.color,top:gstyle.top,right:gstyle.right,transform:gstyle.transform}:null, + collapseInFooter:[...footer.querySelectorAll('[title]')].map(node=>node.getAttribute('title')).filter(title=>/收起/.test(title))}})()` + + ;(async () => { + await import('node:url').then(({ pathToFileURL }) => import(pathToFileURL(path.resolve('out/main/index.mjs')).href)) + await app.whenReady() + main = BrowserWindow.getAllWindows()[0] + main.setSize(1336, 902) + main.showInactive() + main.webContents.debugger.attach('1.3') + main.webContents.on('console-message', (_event, level, message) => { if (level >= 2) rendererErrors.push(message) }) + await wait('!!document.querySelector("#root")', 'renderer root') + await pause(1500) + + await step('footer-layout') + await read(`(()=>{const rows=[...document.querySelectorAll('[data-session-id]')];const row=rows.find(item=>item.textContent.includes('Footer Live'));if(!row)return false;row.click();return true})()`) + await wait(`!!document.querySelector('.conversation-list-footer')`, 'sidebar footer') + await pause(400) + const footer = await read(footerSource) + snapshots.footer = footer + check('footer keeps the recovered padding contract', footer.footer.padding.join(','), '8px,12px,8px,8px') + check('footer keeps the recovered flex contract', footer.footer.display === 'flex' && footer.footer.alignItems === 'center' && footer.footer.justifyContent === 'flex-start', true) + check('footer keeps the recovered top border', footer.footer.borderTop === '1px' && footer.footer.borderColor === 'rgb(230, 230, 230)', true) + check('footer height stays 57px like the original', Math.round(footer.footer.box[3]), 57) + check('wrapper matches the recovered gap and width', footer.wrapper.gap === '4px' && footer.wrapper.display === 'flex' && footer.wrapper.position === 'relative', true) + check('wrapper holds exactly account + bell + miniprogram', footer.wrapper.children.join('|'), + 'user-menu-trigger user-menu-trigger--workbuddy|msg-center-bell-btn user-menu-trigger-msg-center|user-menu-trigger-miniprogram') + check('footer has no collapse entry any more', footer.collapseInFooter, []) + check('account trigger matches min-height, padding and radius', footer.trigger.minHeight === '40px' && footer.trigger.padding === '6px 8px' && footer.trigger.borderRadius === '8px', true) + check('account trigger keeps the recovered background transition', footer.trigger.transition, 'background-color 0.2s') + check('account trigger squeezes to the recovered width', Math.round(footer.trigger.box[2]), 191) + check('trigger left group keeps gap 10', footer.left.gap, '10px') + check('avatar is a 28x28 circle with the recovered initial colour', Math.round(footer.avatar.box[2]) === 28 && Math.round(footer.avatar.box[3]) === 28 && footer.avatar.radius === '50%' && footer.avatar.background === 'rgb(0, 86, 212)', true) + check('name follows 12/600/12 with ellipsis', footer.name.font === '12px/12px' && footer.name.weight === '600' && footer.name.overflow === 'hidden' && footer.name.textOverflow === 'ellipsis' && footer.name.whiteSpace === 'nowrap', true) + check('bell is 32x32 with radius 6 and the secondary colour', Math.round(footer.bell.box[2]) === 32 && Math.round(footer.bell.box[3]) === 32 && footer.bell.radius === '6px' && footer.bell.color === 'rgba(0, 0, 0, 0.7)', true) + check('bell keeps the recovered hover transition', footer.bell.transition, 'background-color, color 0.15s, 0.15s') + check('miniprogram button matches 32x32 radius 6', Math.round(footer.mini.box[2]) === 32 && Math.round(footer.mini.box[3]) === 32 && footer.mini.radius === '6px', true) + check('unread badge keeps the recovered geometry', footer.badge.minWidth === '14px' && footer.badge.height === '14px' && footer.badge.radius === '7px' && footer.badge.font === '9px/14px', true) + check('unread badge keeps the recovered colours', footer.badge.background === 'rgb(246, 64, 65)' && footer.badge.color === 'rgb(255, 255, 255)', true) + check('unread badge keeps top 2 / right 2 with translate(40%,-30%)', footer.badge.top === '2px' && footer.badge.right === '2px' && /matrix\(1, 0, 0, 1, 5\.6, -4\.2\)/.test(footer.badge.transform), true) + + await step('footer-hover') + main.focus(); main.webContents.focus() + await pause(150) + // 用 CDP Input 域驱动真实指针,确保 CSS :hover 生效(sendInputEvent 在无焦点窗口下不可靠)。 + const hover = async (selector) => { + const center = await read(`(()=>{const box=document.querySelector(${JSON.stringify(selector)}).getBoundingClientRect();return {x:Math.round(box.x+box.width/2),y:Math.round(box.y+box.height/2)}})()`) + await main.webContents.debugger.sendCommand('Input.dispatchMouseEvent', { type: 'mouseMoved', x: center.x, y: center.y, button: 'none' }) + await pause(250) + } + await hover('.msg-center-bell-btn') + await pause(300) + // 无合成帧环境下 transition 停在起始值(透明),先把「冻结」状态留档,再关掉过渡读取目标色。 + snapshots.bellHoverFrozen = await read(`(()=>{const bell=document.querySelector('.msg-center-bell-btn');const style=getComputedStyle(bell); + return {hover:bell.matches(':hover'),background:style.backgroundColor,inline:bell.style.backgroundColor||null, + animations:[...document.getAnimations()].filter(animation=>animation.effect&&animation.effect.target===bell).map(animation=>({property:animation.transitionProperty||null,playState:animation.playState,currentTime:animation.currentTime}))}})()`) + snapshots.hoverDiag = await read(`(()=>{const bell=document.querySelector('.msg-center-bell-btn');const trigger=document.querySelector('.user-menu-trigger--workbuddy');const bs=getComputedStyle(bell);const ts=getComputedStyle(trigger); + return {bell:bell.matches(':hover'),trigger:trigger.matches(':hover'),focus:document.hasFocus(),active:document.activeElement?document.activeElement.tagName:null, + bellVars:{ghost:bs.getPropertyValue('--wb-button-ghost-bg-hover'),ycHover:bs.getPropertyValue('--yc-bg-hover'),wbHover:bs.getPropertyValue('--wb-bg-hover')}, + triggerVars:{wbHover:ts.getPropertyValue('--wb-bg-hover'),palette:ts.getPropertyValue('--wb-palette-black-100'),ycHover:ts.getPropertyValue('--yc-bg-hover'),bg:ts.backgroundColor}}})()`) + snapshots.varProbe = await read(`(()=>{const bell=document.querySelector('.msg-center-bell-btn');const aside=bell.closest('aside.sidebar'); + const chain=[];for(let node=bell;node;node=node.parentElement){chain.push(node.tagName.toLowerCase()+(node.className?'.'+String(node.className).split(/\\s+/).join('.'):''))} + return {chain,asideFound:!!aside, + asideBgHover:aside?getComputedStyle(aside).getPropertyValue('--wb-bg-hover'):null, + bellGhostHover:getComputedStyle(bell).getPropertyValue('--wb-button-ghost-bg-hover'), + bellIconPrimary:getComputedStyle(bell).getPropertyValue('--wb-icon-primary')}})()`) + snapshots.bellRules = await read(`(()=>{const out=[];for(const sheet of document.styleSheets){let rules;try{rules=sheet.cssRules}catch{continue}for(const rule of rules){if(!rule.selectorText||!/msg-center-bell-btn/.test(rule.selectorText))continue;out.push({selector:rule.selectorText,background:rule.style.background,backgroundColor:rule.style.backgroundColor,important:rule.style.getPropertyPriority('background-color'),href:(sheet.href||'inline').split('/').pop()})}}return out})()`) + await read(`(()=>{const style=document.createElement('style');style.id='qa-no-transitions';style.textContent='*{transition:none!important}';document.head.appendChild(style);return true})()`) + await pause(120) + snapshots.hoverTokens = await read(`(()=>{const host=document.querySelector('.conversation-list-footer'); + const probe=(property,token)=>{const el=document.createElement('div');el.style[property]='var('+token+')';host.appendChild(el);const value=getComputedStyle(el)[property];el.remove();return value}; + return {bgHover:probe('backgroundColor','--wb-bg-hover'),ghostHover:probe('backgroundColor','--wb-button-ghost-bg-hover'),iconPrimary:probe('color','--wb-icon-primary')}})()`) + check('recovered --wb-bg-hover resolves to the 5% black fill', snapshots.hoverTokens.bgHover, + value => /^(color\(srgb 0 0 0 \/ 0\.05\)|rgba\(0, 0, 0, 0\.05\))$/.test(String(value))) + check('recovered --wb-button-ghost-bg-hover resolves to the ~6% black fill', snapshots.hoverTokens.ghostHover, + value => /^rgba\(0, 0, 0, 0\.0(58|59|6)\)$/.test(String(value))) + await hover('.msg-center-bell-btn') + snapshots.bellHover = await read(`(()=>{const bell=document.querySelector('.msg-center-bell-btn');const style=getComputedStyle(bell);return {hover:bell.matches(':hover'),background:style.backgroundColor,color:style.color}})()`) + check('bell hover uses --wb-button-ghost-bg-hover', snapshots.bellHover.background, snapshots.hoverTokens.ghostHover) + check('bell hover switches the icon to --wb-icon-primary', snapshots.bellHover.color, snapshots.hoverTokens.iconPrimary) + await hover('.user-menu-trigger--workbuddy') + snapshots.triggerHover = await read(`(()=>{const trigger=document.querySelector('.user-menu-trigger--workbuddy');const style=getComputedStyle(trigger);return {hover:trigger.matches(':hover'),background:style.backgroundColor}})()`) + check('account trigger hover uses --wb-bg-hover', snapshots.triggerHover.background, snapshots.hoverTokens.bgHover) + snapshots.colorMixProbe = await read(`(()=>{const probe=document.createElement('div');probe.style.color='color-mix(in srgb, #000 5%, transparent)';document.body.appendChild(probe);const value=getComputedStyle(probe).color;probe.remove();return {probe:value,recoveredBgHover:getComputedStyle(document.querySelector('.user-menu-trigger--workbuddy')).getPropertyValue('--wb-bg-hover'),recoveredGhostHover:getComputedStyle(document.querySelector('.msg-center-bell-btn')).getPropertyValue('--wb-button-ghost-bg-hover')}})()`) + const capture = await main.webContents.debugger.sendCommand('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false }) + await fs.writeFile(path.join(directory, 'sidebar-footer.png'), Buffer.from(capture.data, 'base64')) + + await step('collapse-lives-in-topbar') + const topbar = await read(`(()=>{const button=document.querySelector('.titlebar__leading [title="收起侧边栏"]');if(!button)return null;const box=button.getBoundingClientRect();return {box:[box.x,box.y,box.width,box.height]}})()`) + check('titlebar still owns the collapse entry', !!topbar, true) + check('titlebar collapse button keeps the 24x24 topbar size', topbar && Math.round(topbar.box[2]) === 24 && Math.round(topbar.box[3]) === 24, true) + await read(`document.querySelector('.titlebar__leading [title="收起侧边栏"]').click()`) + await wait(`!!document.querySelector('.sidebar--collapsed')`, 'sidebar collapsed') + check('titlebar collapse entry still collapses the sidebar', await read(`!!document.querySelector('.sidebar--collapsed')`), true) + check('collapsed rail keeps its own expand entry', await read(`!!document.querySelector('.sidebar__rail [title="展开侧栏"]')`), true) + check('no renderer error logs', rendererErrors, []) + + const report = { result: checks.every(item => item.pass) ? 'PASS' : 'FAIL', + scope: 'Real isolated YCode App/preload/IPC/renderer; footer DOM, computed styles, geometry, hover fill and topbar collapse entry measured in the live window.', + sourceEvidence: ['ev_721d830c97114930e046ec8931bd2f473a11ab23efcab9c727c4256889e29879', 'contexts-BkjwYSZT.css', 'UserMenu-CpHz-0rF.css'], + snapshots, checks, rendererErrors } + await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify(report, null, 2)) + console.log('SIDEBAR_FOOTER_' + report.result + ' ' + directory) + for (const failure of checks.filter(item => !item.pass)) console.log('SIDEBAR_FOOTER_FAILURE ' + JSON.stringify(failure)) + app.exit(report.result === 'PASS' ? 0 : 1) + })().catch(async error => { + await fs.writeFile(path.join(directory, 'failure.json'), JSON.stringify({ phase, error: String(error), stack: error.stack, checks, snapshots, rendererErrors }, null, 2)) + console.error(error); app.exit(1) + }) +} diff --git a/src/renderer/src/components/Sidebar.tsx b/src/renderer/src/components/Sidebar.tsx index 7e5623e..283f1df 100644 --- a/src/renderer/src/components/Sidebar.tsx +++ b/src/renderer/src/components/Sidebar.tsx @@ -439,21 +439,28 @@ export default function Sidebar({ collapsed, onToggleCollapse, activeRoute = 'ch {hint ?
{hint}
: null} - {/* ---- 底部账号区(实测:57 高、padding 8 12 8 8、border-top 1px #e6e6e6)---- - 数据为 mock;收起侧栏入口也放这里 ---- */} + {/* ---- 底部账号区(原版 .conversation-list-footer:57 高、padding 8 12 8 8、border-top 1px #e6e6e6)---- + 原版该行只有「账号 / 通知 / 小程序」三个入口;收起侧栏位于顶栏 leading 区,不在此处。 + 数据为 mock(见 TODO 09)。 ---- */}
-
+
- -
{notifOpen ? setNotifOpen(false)} /> : null} @@ -607,20 +610,3 @@ function WorkspaceMoreIcon(): React.JSX.Element { return } function WorkspaceRemoveIcon(): React.JSX.Element { return } -/** 收起侧栏(自绘,可长期保留) */ -function IconCollapseRail(): React.JSX.Element { - return ( - - - - - - ) -} diff --git a/src/renderer/src/styles/sidebar.css b/src/renderer/src/styles/sidebar.css index 41fbb8f..44536e0 100644 --- a/src/renderer/src/styles/sidebar.css +++ b/src/renderer/src/styles/sidebar.css @@ -475,97 +475,190 @@ pointer-events: none; } -/* 底部账号区(实测 57 高、padding 8 12 8 8) */ -.sidebar__footer { +/* ---- 底部账号区:逐条回收原版规则 ---- + contexts-BkjwYSZT.css: .conversation-list-footer{border-top:.5px solid var(--wb-color-border-primary);flex-shrink:0; + justify-content:flex-start;align-items:center;padding:8px 12px 8px 8px;display:flex} / .conversation-list-footer .user-menu{width:100%} + UserMenu-CpHz-0rF.css: .user-menu-trigger* / .msg-center-bell-btn / .msg-center-badge + 运行时光栅证据 ev_721d830c97114930e046ec8931bd2f473a11ab23efcab9c727c4256889e29879: + 容器 [0,845,283.28,57]、padding 8/12/8/8、border-top 1px rgb(230,230,230);行内 [8,854,263.28,40]; + 账号触发器 191.28×40、铃铛/小程序各 32×32、头像 28×28、姓名 12/600/12(原版该行只有账号 / 通知 / 小程序三个入口)。 */ +.sidebar__footer.conversation-list-footer { + border-top: var(--yc-border-width) solid var(--wb-color-border-primary, var(--yc-border-primary)); flex-shrink: 0; - height: var(--yc-sidebar-footer-height); - padding: var(--yc-space-3) var(--yc-space-4) var(--yc-space-3) var(--yc-space-3); - border-top: var(--yc-border-width) solid var(--yc-border-primary); - display: flex; + justify-content: flex-start; align-items: center; + padding: var(--yc-space-3) var(--yc-space-4) var(--yc-space-3) var(--yc-space-3); + display: flex; } -.user-menu { - display: flex; - align-items: center; - gap: var(--yc-space-2); +.conversation-list-footer .user-menu { width: 100%; - height: var(--yc-user-menu-height); +} + +.user-menu-trigger-wrapper { + align-items: center; + gap: 4px; + width: 100%; + display: flex; position: relative; } -.user-menu__trigger { +.user-menu-trigger { + cursor: pointer; + background: 0 0; + border: none; + border-radius: 50%; + justify-content: center; + align-items: center; + padding: 4px; + transition: background-color .2s; display: flex; +} + +.user-menu-trigger:focus { + outline: none; +} + +.user-menu-trigger--workbuddy { + box-sizing: border-box; + border-radius: 8px; + flex: 1; + min-width: 0; + min-height: 40px; + padding: 6px 8px; + overflow: hidden; +} + +.user-menu-trigger--workbuddy:hover { + background-color: var(--wb-bg-hover); +} + +.user-menu-trigger-left { + flex: 1; align-items: center; gap: 10px; - flex: 1; min-width: 0; - height: var(--yc-user-menu-height); - padding: 6px var(--yc-space-3); - border-radius: var(--yc-radius-lg); - text-align: left; + display: flex; } -.user-menu__trigger:hover { - background: var(--yc-bg-hover); -} - -.user-menu__avatar { - width: var(--yc-user-avatar-size); - height: var(--yc-user-avatar-size); - flex: none; - display: grid; - place-items: center; - border-radius: var(--yc-radius-full); - background: var(--yc-border-secondary); - color: var(--yc-text-primary); - font-size: var(--yc-text-caption-size); - font-weight: var(--yc-font-weight-500); -} - -.user-menu__name { - flex: 1; - min-width: 0; - font-size: var(--yc-text-caption-size); - font-weight: var(--yc-font-weight-600); - line-height: var(--yc-text-caption-size); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.user-menu__icon-btn { - flex: none; - width: var(--yc-icon-btn-md); - height: var(--yc-icon-btn-md); - display: grid; - place-items: center; - border-radius: var(--yc-radius-md); - color: var(--yc-text-secondary); +.user-menu-trigger-avatar { + flex-shrink: 0; + justify-content: center; + align-items: center; + display: inline-flex; position: relative; } -.user-menu__icon-btn:hover { - background: var(--yc-bg-hover); +.user-menu-trigger-avatar .user-menu-trigger-avatar-img { + object-fit: cover; + border-radius: 50%; + width: 28px; + height: 28px; + display: block; } -/* 未读角标(实测 .msg-center-badge:14×14、#f64041、9px) */ -.msg-center-badge { - position: absolute; - top: calc(-1 * var(--yc-badge-offset)); - right: calc(-1 * var(--yc-badge-offset)); - min-width: var(--yc-badge-size); - height: var(--yc-badge-size); - padding: 0 3px; - border-radius: calc(var(--yc-badge-size) / 2); - background: var(--yc-red-8); +/* 原版 .user-menu-avatar-initial 只给底色;YCode 无账号头像图片,用同款字面头像占位。 */ +.user-menu-trigger-avatar .user-menu-avatar-initial { + background-color: #0056d4; color: var(--yc-white-100); - font-size: 9px; + font-size: 12px; font-weight: var(--yc-font-weight-500); - line-height: var(--yc-badge-size); + line-height: 28px; text-align: center; } +.user-menu-trigger-name-group { + flex-direction: column; + flex: 1; + justify-content: center; + align-items: flex-start; + gap: 4px; + width: 100%; + min-width: 0; + height: 28px; + display: flex; + overflow: hidden; +} + +.user-menu-trigger-name { + width: 100%; + max-width: 100%; + color: var(--wb-color-text-primary, var(--yc-text-primary)); + text-align: left; + white-space: nowrap; + text-overflow: ellipsis; + font-size: 12px; + font-weight: 600; + line-height: 12px; + display: block; + overflow: hidden; +} + +.msg-center-bell-btn { + width: 32px; + height: 32px; + color: var(--wb-color-text-secondary, var(--yc-text-secondary)); + cursor: pointer; + background: 0 0; + border: none; + border-radius: 6px; + justify-content: center; + align-items: center; + padding: 0; + transition: background-color .15s, color .15s; + display: inline-flex; + position: relative; +} + +.msg-center-bell-btn:hover { + background-color: var(--wb-button-ghost-bg-hover); + color: var(--wb-icon-primary); +} + +.user-menu-trigger-msg-center { + flex-shrink: 0; +} + +.user-menu-trigger-miniprogram { + cursor: pointer; + width: 32px; + height: 32px; + color: var(--wb-color-text-secondary, var(--yc-text-secondary)); + background: 0 0; + border: none; + border-radius: 6px; + flex-shrink: 0; + justify-content: center; + align-items: center; + padding: 0; + transition: background-color .15s, color .15s; + display: inline-flex; +} + +.user-menu-trigger-miniprogram:hover { + background-color: var(--wb-button-ghost-bg-hover); + color: var(--wb-icon-primary); +} + +/* 未读角标(原版 .msg-center-badge:14×14、radius 7、9/500/14、top 2 right 2、translate(40%,-30%)) */ +.msg-center-badge { + background: var(--wb-status-error, var(--yc-red-8)); + min-width: 14px; + height: 14px; + color: var(--wb-text-on-primary, var(--yc-white-100)); + text-align: center; + pointer-events: none; + border-radius: 7px; + padding: 0 3px; + font-size: 9px; + font-weight: 500; + line-height: 14px; + position: absolute; + top: 2px; + right: 2px; + transform: translate(40%, -30%); +} + /* 收起态窄轨 */ .sidebar--collapsed { align-items: center; diff --git a/src/renderer/src/workbuddy/workbench/source-panel-layout.ts b/src/renderer/src/workbuddy/workbench/source-panel-layout.ts index c7f9f9d..85b35bd 100644 --- a/src/renderer/src/workbuddy/workbench/source-panel-layout.ts +++ b/src/renderer/src/workbuddy/workbench/source-panel-layout.ts @@ -81,7 +81,8 @@ export const SOURCE_PANEL_LAYOUT_EVIDENCE = { } } as const -export const Rw = {EXPANDED_WIDTH:280,MIN_WIDTH:240,MAX_WIDTH:360,COLLAPSE_THRESHOLD:120,EXPAND_THRESHOLD:150,COLLAPSED_WIDTH:48} as const +/** 展开宽度:原版 .conversation-list 运行时 border-box 284.28px、border-right 1px(ev_721d830c…),取 284。 */ +export const Rw = {EXPANDED_WIDTH:284,MIN_WIDTH:240,MAX_WIDTH:360,COLLAPSE_THRESHOLD:120,EXPAND_THRESHOLD:150,COLLAPSED_WIDTH:48} as const export const zw = {MIN_WIDTH:360,PREFERRED_WIDTH:600} as const export const Bw = {BREAKPOINT:600,CHAT_MIN_WIDTH:360} as const export const Vw = {MIN_WIDTH:360,DEFAULT_WIDTH:500,OVERVIEW_ONLY_WIDTH:318,MAX_WIDTH:1/0} as const