Show canvas subsets in the workspace tree
Unified application CI and deployment / static-checks (push) Successful in 6s
Remote development preview source sync / sync-source (push) Successful in 9s
Unified application CI and deployment / verify-and-release (push) Has been skipped

This commit is contained in:
2026-11-04 16:52:46 +08:00
parent f020551d02
commit 2bc44c6a9e
4 changed files with 139 additions and 27 deletions
+2 -2
View File
@@ -1,4 +1,4 @@
/** The approved mark is copied from @endoleap/brand during the remote build. */
export function Brand({ compact = false }: { compact?: boolean }) {
return <span className="endoleap-brand"><img src="/brand/logo-light.svg" className="endoleap-mark endoleap-mark-light" alt="" width={32} height={32}/><img src="/brand/logo-dark.svg" className="endoleap-mark endoleap-mark-dark" alt="" width={32} height={32}/><span className="endoleap-wordmark">啡跃{!compact && <small>Endoleap</small>}</span></span>;
export function Brand({ compact = false, markOnly = false }: { compact?: boolean; markOnly?: boolean }) {
return <span className="endoleap-brand"><img src="/brand/logo-light.svg" className="endoleap-mark endoleap-mark-light" alt="" width={32} height={32}/><img src="/brand/logo-dark.svg" className="endoleap-mark endoleap-mark-dark" alt="" width={32} height={32}/>{!markOnly && <span className="endoleap-wordmark">啡跃{!compact && <small>Endoleap</small>}</span>}</span>;
}
@@ -1,4 +1,4 @@
import { ThemeToggle } from "../../studio/components/ThemeToggle";
import { ThemeToggle } from "../../components/ThemeToggle";
import { Brand } from "../../studio/components/Brand";
import Head from 'next/head';
import {useEffect,useRef,useState} from 'react';
@@ -44,7 +44,7 @@ export default function InfiniteCanvas(){
function exportTask(id:string){try{const task=agentTask(doc,id),url=URL.createObjectURL(new Blob([JSON.stringify(task,null,2)],{type:'application/json'}));const a=document.createElement('a');a.href=url;a.download=`canvas-task-${id}.json`;a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);setTaskFeedback('Agent 任务已导出')}catch(e){setTaskFeedback(e instanceof Error?e.message:'无法导出任务')}}
function exportDoc(){const url=URL.createObjectURL(new Blob([JSON.stringify(doc,null,2)],{type:'application/json'}));const a=document.createElement('a');a.href=url;a.download='studio-canvas.json';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000)}
return <main className={s.app}><Head><title>无限画布 · 啡跃 Endoleap</title><meta name="description" content="在无限画布上组织灵感、参考素材与镜头提示词"/></Head>
<header className={s.header}><a href="/workspace" className={s.back} aria-label="返回 Studio"><ArrowLeft size={18}/></a><a href="/workspace" className={s.brand}><Brand compact/></a><i/><span className={s.crumb}>工作空间 <ChevronRight size={13}/></span><strong>未命名的灵感宇宙</strong><span className={s.badge}>本机草稿</span><div className={s.spacer}/><CanvasAgentPanel ref={agent} document={doc} email={email} onLoad={next=>{setDoc(next);setPast([]);setFuture([]);setEditing('');select('')}} onFeedback={setTaskFeedback}/><ThemeToggle/><a href="/account">{email||'登录账户'}</a><button className={s.export} onClick={exportDoc}><Download size={15}/>导出项目</button></header>
<header className={s.header}><a href="/workspace" className={s.back} aria-label="返回工作台"><ArrowLeft size={18}/></a><a href="/workspace" className={s.brand}><Brand compact/></a><i/><span className={s.crumb}>工作空间 <ChevronRight size={13}/></span><strong>未命名的灵感宇宙</strong><span className={s.badge}>本机草稿</span><div className={s.spacer}/><CanvasAgentPanel ref={agent} document={doc} email={email} onLoad={next=>{setDoc(next);setPast([]);setFuture([]);setEditing('');select('')}} onFeedback={setTaskFeedback}/><ThemeToggle/><a href="/account">{email||'登录账户'}</a><button className={s.export} onClick={exportDoc}><Download size={15}/>导出项目</button></header>
<div className={s.workspace}><nav className={s.rail} aria-label="画布工具"><button title="选择工具" aria-label="选择工具" aria-pressed={tool==='select'} onClick={()=>setTool('select')}><MousePointer2/></button><button title="移动画布" aria-label="移动画布" aria-pressed={tool==='hand'} onClick={()=>setTool('hand')}><Hand/></button><hr/><button title="添加笔记" aria-label="添加笔记" onClick={()=>add('text')}><Type/></button><button title="上传图片" aria-label="上传图片" onClick={()=>file.current?.click()}><ImagePlus/></button><button title="添加提示词" aria-label="添加提示词" onClick={()=>add('prompt')}><Sparkles/></button><button title="导入项目" aria-label="导入项目" onClick={()=>json.current?.click()}><Upload/></button><div className={s.spacer}/><span>∞</span></nav>
<section className={s.canvasArea}>{!doc.cards.length&&<div className={s.startPanel}><span>YOUR NEXT IDEA STARTS HERE</span><h2>给想法,一个无限的空间。</h2><p>双击画布添加节点,或选择一个起点。</p><div><button onClick={()=>add('text')}><Type size={19}/>灵感笔记</button><button onClick={()=>file.current?.click()}><ImagePlus size={19}/>参考图片</button><button onClick={()=>add('prompt')}><Sparkles size={19}/>创作提示词</button></div><button onClick={()=>{commit(example);select('');setPan({x:0,y:0});setZoom(.7)}}>从示例开始 ↗</button><a href="/studio/api" target="_blank" rel="noreferrer">连接你的 coding agent · 接入文档 ↗</a></div>}<div className={s.canvasTitle}><span className={s.eyebrow}>YOUR IDEAS, CONNECTED</span><h1>无限画布<span> / Infinite canvas</span></h1><p>从一个灵感,连接所有可能。</p></div>
<div className={s.viewport} style={{backgroundPosition:`${pan.x}px ${pan.y}px`,backgroundSize:`${24*zoom*Math.max(1,2**Math.ceil(Math.log2(1/zoom)))}px ${24*zoom*Math.max(1,2**Math.ceil(Math.log2(1/zoom)))}px`}} ref={viewport} onDoubleClick={e=>{if(e.target===e.currentTarget){setMenu(null);add('text')}}} onContextMenu={e=>{e.preventDefault();const id=(e.target as HTMLElement).closest('[data-node-id]')?.getAttribute('data-node-id');select(id||'');setMenu({x:Math.min(e.clientX,window.innerWidth-250),y:Math.min(e.clientY,window.innerHeight-330)})}} onWheel={e=>{const target=e.target as HTMLElement;if(drag.current)return;if(target.closest('textarea,input,select'))return;if(e.ctrlKey||e.metaKey){scaleAt(zoom*Math.exp(-e.deltaY*.002),e.clientX,e.clientY)}else setPan(p=>({x:p.x-e.deltaX,y:p.y-e.deltaY}))}} onPointerDown={e=>{setMenu(null);setRefMenu('');if(e.target===e.currentTarget){setEditing('');select('')};if(e.target===e.currentTarget||(tool==='hand'&&!(e.target as HTMLElement).closest('input,textarea,button,select'))||e.button===1){e.currentTarget.setPointerCapture(e.pointerId);drag.current={id:'pan',x:e.clientX,y:e.clientY,ox:pan.x,oy:pan.y,before:doc}}}} onPointerMove={e=>{const d=drag.current;if(!d)return;if(d.id==='pan')setPan({x:d.ox+e.clientX-d.x,y:d.oy+e.clientY-d.y});else {if(!d.resize&&Math.hypot(e.clientX-d.x,e.clientY-d.y)<4)return;e.currentTarget.setPointerCapture(e.pointerId);setDoc({...d.before,cards:d.before.cards.map(c=>c.id===d.id?(d.resize?{...c,width:Math.max(260,Math.min(1200,d.ox+(e.clientX-d.x)/zoom)),height:Math.max(300,Math.min(1200,d.oy+(e.clientY-d.y)/zoom))}:{...c,x:d.ox+(e.clientX-d.x)/zoom,y:d.oy+(e.clientY-d.y)/zoom}):c)})}}} onPointerUp={e=>{const d=drag.current;if(d?.id==='pan'&&tool==='select'&&Math.hypot(e.clientX-d.x,e.clientY-d.y)<4){select('');setMenu({x:Math.min(e.clientX,window.innerWidth-250),y:Math.min(e.clientY,window.innerHeight-230)})}if(d&&d.id!=='pan'&&doc!==d.before){setPast(p=>[...p.slice(-29),d.before]);setFuture([])}drag.current=null}} onPointerCancel={()=>{if(drag.current?.id!=='pan'&&drag.current)setDoc(drag.current.before);drag.current=null}}>
+111 -21
View File
@@ -1,8 +1,9 @@
import Head from 'next/head';
import {useEffect, useRef, useState} from 'react';
import {ArrowLeft, ArrowUpRight, BookOpen, Bot, ChevronDown, House, LayoutGrid, Palette, PanelLeft, Plus, Search, Settings, Sparkles, Workflow, X} from 'lucide-react';
import {ArrowLeft, ArrowUpRight, BookOpen, Bot, Check, ChevronDown, ChevronRight, Layers, LogOut, User, House, LayoutGrid, Palette, PanelLeft, Plus, Search, Settings, Sparkles, Workflow, X} from 'lucide-react';
import {Brand} from '../components/Brand';
import {ThemeToggle} from '../components/ThemeToggle';
import {parseCanvasDocument, type CanvasNode} from '@/lib/canvas-document';
import s from '@/styles/workspace.module.css';
const tools = [
@@ -17,6 +18,7 @@ const purposes = [
{label: '学习与研究', detail: '梳理资料,记录研究过程。', icon: BookOpen},
];
type Space = {name: string; purpose: string};
type Subcanvas = {id: string; name: string; nodes: string[]};
const personal = {name: '个人空间', purpose: ''};
export default function Workspace() {
@@ -31,6 +33,13 @@ export default function Workspace() {
const [name, setName] = useState('');
const [error, setError] = useState('');
const [notice, setNotice] = useState('');
const [nodes, setNodes] = useState<CanvasNode[]>([]);
const [subcanvases, setSubcanvases] = useState<Subcanvas[]>([]);
const [selectedCanvas, setSelectedCanvas] = useState('');
const [expanded, setExpanded] = useState(true);
const [subsetName, setSubsetName] = useState('');
const [subsetNodes, setSubsetNodes] = useState<string[]>([]);
const subsetDialog = useRef<HTMLDialogElement>(null);
const dialog = useRef<HTMLDialogElement>(null);
const switcher = useRef<HTMLDetailsElement>(null);
const search = useRef<HTMLInputElement>(null);
@@ -73,6 +82,60 @@ export default function Workspace() {
switcher.current?.removeAttribute('open');
}
const subsetKey = 'studio.subcanvases:' + email + ':' + active.name;
useEffect(() => {
if (!email) return;
setSelectedCanvas('');
setQuery('');
try {
const draft = localStorage.getItem('studio.infinite-canvas.v1');
setNodes(draft ? parseCanvasDocument(draft).cards : []);
} catch {
setNodes([]);
}
try {
const saved = JSON.parse(localStorage.getItem(subsetKey) || '[]');
setSubcanvases(Array.isArray(saved) ? saved : []);
} catch {
setSubcanvases([]);
}
}, [email, subsetKey]);
function openSubsetDialog(edit?: Subcanvas) {
setError('');
setSubsetName(edit?.name ?? '');
setSubsetNodes(edit?.nodes ?? []);
subsetDialog.current?.showModal();
}
function saveSubset(event: React.FormEvent) {
event.preventDefault();
const name = subsetName.trim();
if (!name || !subsetNodes.length) return setError('请输入名称并选择至少一个节点。');
const id = selectedCanvas || window.crypto?.randomUUID?.() || String(Date.now());
const subset = {id, name, nodes: subsetNodes};
const next = [...subcanvases.filter(item => item.id !== id), subset];
try {
localStorage.setItem(subsetKey, JSON.stringify(next));
setSubcanvases(next);
setSelectedCanvas(id);
setExpanded(true);
subsetDialog.current?.close();
} catch {
setError('浏览器无法保存子画布。');
}
}
async function signOut() {
try {
const response = await fetch('/api/account/logout', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: '{}'});
if (!response.ok) throw Error();
window.location.assign('/account?next=/workspace');
} catch {
setNotice('暂时无法退出登录,请重试。');
}
}
function openDialog() {
setStep('purpose');
setPurpose('');
@@ -96,14 +159,17 @@ export default function Workspace() {
}
}
const visible = tools.filter(tool => (tool.label + tool.detail).includes(query.trim()));
const subset = subcanvases.find(item => item.id === selectedCanvas);
const visibleNodes = subset ? nodes.filter(node => subset.nodes.includes(node.id)) : nodes;
const visibleSubsets = subcanvases.filter(item => item.name.toLowerCase().includes(query.trim().toLowerCase()));
const canvasName = '无限画布';
return <div className={s.app}>
<Head><title>工作台 · 啡跃</title></Head>
<nav className={s.rail} aria-label="全局导航">
<a className={s.logo} href="/" aria-label="啡跃首页"><Brand compact/></a>
<a className={s.logo} href="/" aria-label="啡跃首页"><Brand markOnly/></a>
<button type="button" aria-label="切换工作台导航" title="切换工作台导航" aria-current="page" aria-expanded={sidebar} onClick={() => setSidebar(!sidebar)}><LayoutGrid size={18}/></button>
<a href="/infinite_canvas" aria-label="无限画布" title="无限画布"><Workflow size={18}/></a>
<button type="button" aria-label="搜索工作台" title="搜索工作台" disabled={state !== 'ready'} onClick={() => {setSidebar(true); requestAnimationFrame(() => search.current?.focus());}}><Search size={18}/></button>
<button type="button" aria-label="搜索画布" title="搜索画布" disabled={state !== 'ready'} onClick={() => {setSidebar(true); requestAnimationFrame(() => search.current?.focus());}}><Search size={18}/></button>
<a href="/graphic" aria-label="图形设计" title="图形设计"><Palette size={18}/></a>
<a href="/studio/api" aria-label="Agent 接入" title="Agent 接入"><Bot size={18}/></a>
<a className={s.settings} href="/account" aria-label="账户设置" title="账户设置"><Settings size={18}/></a>
@@ -113,38 +179,62 @@ export default function Workspace() {
<details ref={switcher} className={s.switcher}>
<summary><span className={s.avatar}>{Array.from(active.name)[0]}</span><strong>{active.name}</strong><ChevronDown size={14}/></summary>
{state === 'ready' && <div className={s.menu}>
{[personal, ...spaces].map(space => <button type="button" key={space.name} aria-pressed={space.name === active.name} onClick={() => {
try { selectSpace(space); } catch { setNotice('浏览器无法保存空间切换。'); }
}}>{space.name}</button>)}
<button type="button" onClick={openDialog}><Plus size={16}/>添加 Workspace</button>
<div className={s.menuIdentity}><span className={s.avatar}>{Array.from(active.name)[0]}</span><div><strong>{active.name}</strong><small>当前空间 · 保存在此浏览器</small></div></div>
<div className={s.menuSection}><small>{email}</small>
{[personal, ...spaces].map(space => <button type="button" key={space.name} aria-pressed={space.name === active.name} onClick={() => {
try { selectSpace(space); } catch { setNotice('浏览器无法保存空间切换。'); }
}}><span className={s.avatar}>{Array.from(space.name)[0]}</span><span>{space.name}</span>{space.name === active.name && <Check size={16}/>}</button>)}
<button className={s.menuAdd} type="button" onClick={() => {switcher.current?.removeAttribute('open'); openDialog();}}><Plus size={16}/>添加 Workspace</button>
</div>
<div className={s.menuSection}><a href="/account"><User size={16}/>账户管理</a><button type="button" onClick={() => void signOut()}><LogOut size={16}/>退出登录</button></div>
</div>}
</details>
<a className={s.avatar} href="/account" aria-label="账户设置" title={email || '账户设置'}>{email ? email.charAt(0).toUpperCase() : <Settings size={15}/>}</a>
</div>
<div className={s.panelTitle}><strong>工作台</strong>{state === 'ready' && <button type="button" onClick={openDialog} aria-label="添加 Workspace" title="添加 Workspace"><Plus size={18}/></button>}</div>
<label className={s.search}><Search size={16}/><input ref={search} type="search" value={query} onChange={e => setQuery(e.target.value)} placeholder="搜索工具…" aria-label="搜索工具"/></label>
<nav className={s.links} aria-label="空间工具">
<a href="/workspace" aria-current="page"><LayoutGrid size={16}/>创作概览</a>
<p>创作工具</p>
{visible.map(({label, href, icon: Icon}) => <a key={href} href={href}><Icon size={16}/>{label}</a>)}
</nav>
<div className={s.panelTitle}><strong>画布</strong><button type="button" disabled={!nodes.length} onClick={() => {setSelectedCanvas(''); openSubsetDialog();}} aria-label="新建子画布" title="新建子画布"><Plus size={18}/></button></div>
<label className={s.search}><Search size={16}/><input ref={search} type="search" value={query} onChange={e => setQuery(e.target.value)} placeholder="搜索画布…" aria-label="搜索画布名称"/></label>
{state === 'ready' && <nav className={s.links} aria-label="画布树">
<p>本机画布</p>
<div className={s.treeRoot}>
<button type="button" aria-label="展开或折叠子画布" aria-expanded={expanded || !!query} disabled={!!query} onClick={() => setExpanded(!expanded)}>{expanded || query ? <ChevronDown size={15}/> : <ChevronRight size={15}/>}</button>
<button type="button" aria-current={!subset ? 'page' : undefined} onClick={() => setSelectedCanvas('')}><Layers size={16}/>{canvasName}</button>
</div>
{(expanded || query) && <ul className={s.children}>{visibleSubsets.map(item => <li key={item.id}><button type="button" aria-current={item.id === selectedCanvas ? 'page' : undefined} onClick={() => setSelectedCanvas(item.id)}><LayoutGrid size={15}/>{item.name}</button></li>)}</ul>}
{query && !visibleSubsets.length && !canvasName.includes(query.trim()) && <p role="status">没有匹配的画布。</p>}
</nav>}
<small className={s.local}>空间设置保存在此浏览器</small>
</aside>}
<main className={s.main}>
<header className={s.top}>
<div><button type="button" title="切换导航栏" aria-label="切换导航栏" aria-expanded={sidebar} aria-controls={sidebar ? 'workspace-navigation' : undefined} onClick={() => setSidebar(!sidebar)}><PanelLeft size={18}/></button><span>{active.name} / 创作概览</span></div>
<div><button type="button" title="切换导航栏" aria-label="切换导航栏" aria-expanded={sidebar} aria-controls={sidebar ? 'workspace-navigation' : undefined} onClick={() => setSidebar(!sidebar)}><PanelLeft size={18}/></button><span>{active.name} / {subset?.name ?? canvasName}</span></div>
<ThemeToggle/>
</header>
{state === 'loading' ? <p className={s.status} role="status">正在打开工作台…</p> : state === 'error' ? <p className={s.status} role="alert">暂时无法读取账户。 <a href="/account?next=/workspace">重新登录</a></p> :
<div className={s.content}>
<section className={s.intro}><Sparkles size={20}/><p>{active.purpose || '个人空间'}</p><h1>{active.name}</h1><p>从画布开始整理想法,或打开图形编辑器继续创作。</p><a className={s.primary} href="/infinite_canvas">打开画布 <ArrowUpRight size={16}/></a></section>
<section aria-labelledby="workspace-tools">
<h2 id="workspace-tools">创作工具</h2>
<div className={s.tools}>{visible.map(({label, detail, href, icon: Icon}) => <a href={href} key={href}><Icon size={20}/><span><strong>{label}</strong><small>{detail}</small></span><ArrowUpRight size={16}/></a>)}</div>
{!visible.length && <p role="status">没有找到匹配的工具。</p>}
<section className={s.intro}>
<Layers size={20}/>
<h1>{subset?.name ?? canvasName}</h1>
<p>{subset ? `子画布 · ${visibleNodes.length} 个节点 · 属于无限画布` : `${active.name} · ${nodes.length} 个节点`}</p>
{subset ? <button className={s.primary} type="button" onClick={() => openSubsetDialog(subset)}>编辑子画布</button> : <a className={s.primary} href="/infinite_canvas">打开画布 <ArrowUpRight size={16}/></a>}
</section>
<section aria-labelledby="workspace-nodes">
<h2 id="workspace-nodes">画布内容</h2>
{visibleNodes.length ? <div className={s.tools}>{visibleNodes.map(node => <article className={s.node} key={node.id}><strong>{node.title || '未命名节点'}</strong>{node.image && <img src={node.image} alt=""/>}{node.text && <p>{node.text}</p>}</article>)}</div> : <p>{subset ? '原画布中已没有这些节点。' : '画布还是空的。添加节点后,就可以在这里创建子画布。'}</p>}
</section>
<nav className={s.shortcuts} aria-label="其他创作工具">{tools.slice(1).map(({label, href}) => <a key={href} href={href}>{label} <ArrowUpRight size={14}/></a>)}</nav>
</div>}
</main>
<dialog className={s.dialog} ref={subsetDialog} aria-labelledby="subcanvas-title">
<div className={s.dialogTop}><h2 id="subcanvas-title">{subset ? '编辑子画布' : '新建子画布'}</h2><button type="button" aria-label="关闭" onClick={() => subsetDialog.current?.close()}><X size={20}/></button></div>
<form onSubmit={saveSubset}>
<p>从无限画布选择节点。原画布会保留这些节点。</p>
<label htmlFor="subcanvas-name">子画布名称</label>
<input id="subcanvas-name" value={subsetName} maxLength={40} onChange={event => setSubsetName(event.target.value)}/>
<fieldset className={s.members}><legend>包含的节点</legend>{nodes.map(node => <label key={node.id}><input type="checkbox" checked={subsetNodes.includes(node.id)} onChange={event => setSubsetNodes(event.target.checked ? [...subsetNodes, node.id] : subsetNodes.filter(id => id !== node.id))}/>{node.title || '未命名节点'}</label>)}</fieldset>
{error && <p className={s.error} role="alert">{error}</p>}
<button className={s.primary} type="submit">保存子画布</button>
</form>
</dialog>
<dialog className={s.dialog} ref={dialog} aria-labelledby="workspace-dialog-title">
<div className={s.dialogTop}>
{step === 'name' && <button type="button" aria-label="返回用途选择" onClick={() => setStep('purpose')}><ArrowLeft size={20}/></button>}
@@ -5,8 +5,7 @@
.rail > :hover, .links a:hover, .top button:hover, .panelTitle button:hover, .dialogTop button:hover, .menu button:hover { background: var(--row-hover); }
.rail [aria-current], .links a[aria-current], .menu button[aria-pressed=true] { background: var(--selection); color: var(--foreground-strong); }
.logo { margin-bottom: 14px; }
.logo :global(.endoleap-wordmark) { display: none; }
.logo :global(.endoleap-mark) { width: 26px; height: 26px; }
.logo :global(.endoleap-brand) :global(.endoleap-mark) { width: 26px; height: 26px; }
.settings { margin-top: auto; }
.panel { width: 248px; padding: 0 12px 14px; display: flex; flex-direction: column; background: var(--surface); }
.identity, .panelTitle { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
@@ -61,3 +60,26 @@
.dialog .error { color: var(--danger); }
.notice { position: fixed; right: 16px; bottom: 16px; z-index: 4; max-width: calc(100vw - 32px); overflow-wrap: anywhere; padding: 14px 18px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); box-shadow: 0 8px 24px var(--shadow); }
@media (max-width: 760px) { .panel { position: fixed; left: 50px; width: min(248px, calc(100% - 50px)); z-index: 3; box-shadow: 8px 0 20px var(--shadow); } .rail { z-index: 4; } .top { position: sticky; top: 0; z-index: 4; margin-left: 8px; } .app:has(.panel) .top { z-index: 2; } .tools { grid-template-columns: 1fr; } }
.panel { z-index: 3; }
.menu { width: min(286px, calc(100vw - 76px)); padding: 10px; }
.menuIdentity { display: flex; gap: 10px; align-items: center; padding: 4px 4px 10px; }
.menuIdentity small, .menuSection small { display: block; color: var(--muted-foreground); overflow-wrap: anywhere; margin: 4px 0; }
.menuSection { border-top: 1px solid var(--border); padding: 6px 0; }
.menuSection a { display: flex; align-items: center; gap: 9px; padding: 8px 9px; border-radius: 6px; }
.menuSection a:hover, .treeRoot button:hover, .children button:hover { background: var(--row-hover); }
.menuSection button > svg:last-child:not(:first-child) { margin-left: auto; }
.menuSection .menuAdd { justify-content: center; background: var(--surface-muted); margin-top: 4px; }
.treeRoot { display: flex; gap: 2px; }
.treeRoot button, .children button { display: flex; align-items: center; gap: 8px; min-height: 34px; border-radius: 6px; text-align: left; overflow-wrap: anywhere; }
.treeRoot button:last-child, .children button { flex: 1; width: 100%; padding: 5px 8px; }
.treeRoot button[aria-current], .children button[aria-current] { background: var(--selection); }
.children { list-style: none; margin: 2px 0 0 21px; padding: 0 0 0 7px; border-left: 1px solid var(--border); }
.children svg, .treeRoot svg { flex-shrink: 0; }
.node { min-width: 0; padding: 16px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); overflow-wrap: anywhere; }
.node p { white-space: pre-wrap; }
.node img { max-height: 200px; object-fit: contain; margin-top: 8px; }
.shortcuts { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 28px; }
.shortcuts a { display: inline-flex; align-items: center; gap: 4px; padding: 6px; }
.members { display: grid; gap: 8px; max-height: 30vh; overflow: auto; margin: 0 0 16px; border: 1px solid var(--border); }
.members label { display: flex; align-items: center; gap: 8px; overflow-wrap: anywhere; }
.members input[type=checkbox] { width: auto; min-height: 0; margin: 0; accent-color: var(--primary); }