import type { Context } from '@deepseek-ai/cordis' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { svgMarkupOf } from '../svg.js' import { parseDrawioAddress, relativeToWorkspace } from './address.js' export const inject = ['slots', 'sidebarRightTabs'] const TAB_ID = '@civilization/dsh-drawio' const TAB_KIND = 'drawio' interface SessionScope { sessionId: string; cwd?: string } interface FileViewerProps { content?: string; version?: string; truncated?: boolean; path: string; scope: SessionScope } interface OfficialContext extends Context { slots: any; sidebarRightTabs: any } export function apply(ctx: Context): void { const official = ctx as OfficialContext ctx.effect(() => official.sidebarRightTabs.register({ id: TAB_ID, kind: TAB_KIND, // The whole-address glob is what actually wins this file: tab types are // ranked by priority band, then by the length of the matched pattern, so a // bare `*.drawio` (8 characters) loses the claim to any type declaring a // broader address glob in the same `extension` band — e.g. // dsh-better-sidebar's file editor (`dsh-resource://file/**`, 22), which // then renders the XML instead of this canvas. Keeping both patterns makes // the specific one win wherever it matches. patterns: ['dsh-resource://file/**/*.drawio', '*.drawio'], priority: 'extension', title: (address: string) => fileName(parseDrawioAddress(address)?.path ?? 'Draw.io'), }), 'dsh-drawio: official tab definition') ctx.effect(() => official.slots.inject('sidebar.right.pane.tab', () => official.slots.register({ name: 'sidebar.right.pane.tab', key: TAB_ID, }, OfficialDrawioTabBody)), 'dsh-drawio: official tab body') ctx.effect(() => official.slots.inject('sidebar.right.pane.tab.title', () => official.slots.register({ name: 'sidebar.right.pane.tab.title', key: TAB_ID, }, OfficialDrawioTabTitle)), 'dsh-drawio: official tab title') } function OfficialDrawioTabTitle({ useTabInfo }: any): JSX.Element { const { tab } = useTabInfo() const title = tab?.title || (tab?.navigation?.address ? fileName(parseDrawioAddress(tab.navigation.address)?.path ?? 'Draw.io') : 'Draw.io') return <>{title}> } function OfficialDrawioTabBody({ sessionId, useSessions, useTabInfo }: any): JSX.Element { const { tab } = useTabInfo() const rawAddress = tab?.navigation?.address const resource = useMemo(() => parseDrawioAddress(rawAddress, sessionId), [rawAddress, sessionId]) const activeCwd = useSessions((sessions: any) => sessions?.byId?.[sessionId]?.cwd) as string | undefined const resourceCwd = useSessions((sessions: any) => resource?.sessionId ? sessions?.byId?.[resource.sessionId]?.cwd : undefined) as string | undefined const cwd = activeCwd || resourceCwd const effectiveSessionId = sessionId || resource?.sessionId || '' // An absolute address carries no session, so it is openable only when it names // a file under this session's workspace; the host re-checks that boundary. const relativePath = resource?.scope === 'absolute' ? relativeToWorkspace(resource.path, cwd) : null const outsideWorkspace = resource?.scope === 'absolute' && cwd !== undefined && relativePath === null const openPath = resource?.scope === 'absolute' ? relativePath : resource?.path ?? null const [file, setFile] = useState<{ key: string; content: string; version?: string } | null>(null) const [error, setError] = useState(null) const key = `${effectiveSessionId}\0${cwd ?? ''}\0${openPath ?? ''}` useEffect(() => { if (!openPath || !effectiveSessionId || !cwd) return const controller = new AbortController() setError(null) void fsRead({ sessionId: effectiveSessionId, cwd }, openPath, controller.signal) .then(res => { if (!controller.signal.aborted) setFile({ key, content: res.content, version: res.version }) }) .catch(reason => { if (!controller.signal.aborted) setError(messageOf(reason)) }) return () => controller.abort() }, [key, openPath, effectiveSessionId, cwd]) if (!resource || !resource.path) { return ( ) } if (!cwd) { return ( ) } if (outsideWorkspace) { return ( ) } if (!openPath) return if (error) return if (file?.key !== key) return return } function DrawioCanvas({ content, version, truncated, path, scope }: FileViewerProps): JSX.Element { const frame = useRef(null) const latestFile = useRef(content ?? '') const latestEditor = useRef(content ?? '') const latestVersion = useRef(version) const saveTimer = useRef(undefined) const saveGeneration = useRef(0) const [ready, setReady] = useState(false) const [saveState, setSaveState] = useState<'loading' | 'saved' | 'saving' | 'external' | 'conflict' | 'error'>('loading') const [error, setError] = useState(truncated ? '文件过大,DSH 只返回了截断内容,无法安全打开。' : null) const lastUserActivity = useRef(0) const isInteracting = useRef(false) const { dark, accent: accentColor } = useDshTheme() const initialDark = useRef(dark) const src = useMemo(() => `/dsh-drawio/runtime/index.html?embed=1&proto=json&spin=1&offline=1&local=1&ui=min&libraries=1&configure=1&noExitBtn=1&saveAndExit=0&dark=${initialDark.current ? '1' : '0'}`, []) const post = useCallback((message: unknown) => { frame.current?.contentWindow?.postMessage(JSON.stringify(message), window.location.origin) }, []) const reloadFromDisk = useCallback(async () => { try { setSaveState('loading') const result = await fsRead(scope, path) latestFile.current = result.content latestEditor.current = result.content latestVersion.current = result.version post({ action: 'load', xml: result.content, autosave: 1, title: fileName(path), dark, noExitBtn: 1, saveAndExit: 0 }) setSaveState('saved') setError(null) } catch (reason) { setError(messageOf(reason)) } }, [scope, path, dark, post]) const persist = useCallback(async (xml: string) => { const generation = ++saveGeneration.current setSaveState('saving') try { const outcome = await fsWrite(scope, path, xml, latestVersion.current) if (generation !== saveGeneration.current) return latestFile.current = xml latestEditor.current = xml if (outcome?.version) latestVersion.current = outcome.version setSaveState('saved') setError(null) } catch (reason) { if (generation !== saveGeneration.current) return const msg = messageOf(reason) if (msg.includes('modified by an external process') || msg.includes('409')) { setSaveState('conflict') setError('文件已被外部或 AI 修改,当前存在并发冲突,点击可重新载入最新内容') } else { setSaveState('error') setError(msg) } } }, [scope.sessionId, scope.cwd, path]) const scheduleSave = useCallback((xml: string, immediate = false) => { lastUserActivity.current = Date.now() latestEditor.current = xml if (saveTimer.current !== undefined) { window.clearTimeout(saveTimer.current) saveTimer.current = undefined } if (immediate) { void persist(xml) } else { saveTimer.current = window.setTimeout(() => { saveTimer.current = undefined void persist(latestEditor.current) }, 350) } }, [persist]) useEffect(() => { latestFile.current = content ?? '' latestEditor.current = content ?? '' latestVersion.current = version }, [content, version, path]) const markActivity = useCallback(() => { lastUserActivity.current = Date.now() }, []) useEffect(() => { const attached = new WeakSet() const handleFrameAttach = () => { try { const doc = frame.current?.contentDocument if (!doc || attached.has(doc)) return attached.add(doc) const onDown = () => { isInteracting.current = true lastUserActivity.current = Date.now() } const onUp = () => { isInteracting.current = false lastUserActivity.current = Date.now() } const onKey = () => { lastUserActivity.current = Date.now() } // 仅在用户按住拖拽、松手或输入时感知,单纯鼠标移动(hover)不打断 doc.addEventListener('pointerdown', onDown, true) doc.addEventListener('pointerup', onUp, true) doc.addEventListener('keydown', onKey, true) } catch {} } const timer = window.setInterval(handleFrameAttach, 800) return () => window.clearInterval(timer) }, []) const [menuOpen, setMenuOpen] = useState(false) const [exportFormat, setExportFormat] = useState<'png' | 'svg' | 'xmlpng'>('png') const [isTransparent, setIsTransparent] = useState(false) const [isExporting, setIsExporting] = useState(false) const [toast, setToast] = useState(null) const pendingExport = useRef<{ format: 'png' | 'svg' | 'xmlpng'; target: 'workspace' | 'download' | 'clipboard'; targetPath: string } | null>(null) const menuRef = useRef(null) const showToast = useCallback((msg: string) => { setToast(msg) window.setTimeout(() => setToast(current => current === msg ? null : current), 3200) }, []) const startExport = useCallback((target: 'workspace' | 'download' | 'clipboard') => { if (!ready || isExporting) return // The workspace target is SVG-only: DSH's filesystem service writes text // (there is no binary write seam), so a raster export would land as base64 // text. PNG and XML-PNG stay available through download and clipboard. if (target === 'workspace' && exportFormat !== 'svg') { showToast('仅 SVG 支持保存到工作区,PNG / XML-PNG 请使用下载或剪贴板') return } const ext = exportFormat === 'svg' ? '.svg' : exportFormat === 'xmlpng' ? '.drawio.png' : '.png' const base = path.replace(/\.drawio$/i, '') const targetPath = `${base}${ext}` pendingExport.current = { format: exportFormat, target, targetPath, } setIsExporting(true) setMenuOpen(false) post({ action: 'export', format: exportFormat, scale: exportFormat === 'svg' ? 1 : 2, transparent: isTransparent, border: 10, spin: '正在渲染导出图片...', }) }, [ready, isExporting, exportFormat, isTransparent, path, post, showToast]) const handleExportResult = useCallback(async (pending: { format: 'png' | 'svg' | 'xmlpng'; target: 'workspace' | 'download' | 'clipboard'; targetPath: string }, data: string) => { try { if (pending.target === 'download') { const link = document.createElement('a') link.download = fileName(pending.targetPath) link.href = data document.body.appendChild(link) link.click() document.body.removeChild(link) showToast(`已开始下载:${fileName(pending.targetPath)}`) } else if (pending.target === 'clipboard') { try { if (pending.format === 'svg') { const svgText = svgMarkupOf(data) ?? data await navigator.clipboard.writeText(svgText) showToast('已复制 SVG 代码到剪贴板') } else { const res = await fetch(data) const blob = await res.blob() await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]) showToast('已复制图片到剪贴板') } } catch { // 剪贴板受限或报错时降级为自动下载 const link = document.createElement('a') link.download = fileName(pending.targetPath) link.href = data document.body.appendChild(link) link.click() document.body.removeChild(link) showToast(`剪贴板无权限,已自动下载:${fileName(pending.targetPath)}`) } } else if (pending.target === 'workspace') { const result = await fsSaveImage(scope, pending.targetPath, data) showToast(`已保存到工作区:${fileName(result.path)} (${Math.round(result.bytes / 1024)} KB)`) } } catch (reason) { setError(`导出失败:${messageOf(reason)}`) } }, [scope, showToast]) useEffect(() => { const handleOutsideClick = (e: MouseEvent) => { if (menuOpen && menuRef.current && !menuRef.current.contains(e.target as Node)) { setMenuOpen(false) } } window.addEventListener('pointerdown', handleOutsideClick) return () => window.removeEventListener('pointerdown', handleOutsideClick) }, [menuOpen]) useEffect(() => { setReady(false) setSaveState('loading') const receive = (event: MessageEvent) => { if (event.source !== frame.current?.contentWindow || event.origin !== window.location.origin) return let message: { event?: string; xml?: string; format?: string; data?: string } try { message = typeof event.data === 'string' ? JSON.parse(event.data) : event.data } catch { return } if (message.event === 'configure') { post({ action: 'configure', config: { compressXml: false, enableCssDarkMode: true, defaultLibraries: 'general;uml;er;bpmn;flowchart;basic;arrows2', enabledLibraries: null } }) } else if (message.event === 'init') { post({ action: 'load', xml: latestFile.current, autosave: 1, title: fileName(path), dark, noExitBtn: 1, saveAndExit: 0 }) setReady(true) setSaveState('saved') } else if ((message.event === 'autosave' || message.event === 'save') && typeof message.xml === 'string') { scheduleSave(message.xml, message.event === 'save') } else if (message.event === 'export' && typeof message.data === 'string') { const pending = pendingExport.current pendingExport.current = null setIsExporting(false) if (pending) { void handleExportResult(pending, message.data) } } } window.addEventListener('message', receive) return () => window.removeEventListener('message', receive) }, [src, dark, path, post, scheduleSave, handleExportResult]) useEffect(() => { if (!ready || truncated) return let cancelled = false const poll = async () => { // 1. 用户按住鼠标拖拽中,绝不读取 if (isInteracting.current) return // 2. 刚松手或打字后 1.5 秒内(操作缓冲),暂停轮询 if (Date.now() - lastUserActivity.current < 1500) return // 3. 本地正在防抖保存中,不从磁盘读取 if (saveTimer.current !== undefined) return // 4. 浏览器标签页切后台时静默 if (typeof document !== 'undefined' && document.hidden) return try { // 轻量检查版本号 const stat = await fsStat(scope, path) if (cancelled || isInteracting.current || Date.now() - lastUserActivity.current < 1200) return if (stat.version && latestVersion.current && stat.version === latestVersion.current) return const res = await fsRead(scope, path) // 5. 响应返回后二次防护:若这期间用户按下了鼠标或开始拖动,果断丢弃本次结果 if (cancelled || isInteracting.current || Date.now() - lastUserActivity.current < 1200) return if (res.content === latestFile.current || res.content === latestEditor.current) { latestVersion.current = res.version return } latestFile.current = res.content latestEditor.current = res.content latestVersion.current = res.version post({ action: 'load', xml: res.content, autosave: 1, title: fileName(path), dark, noExitBtn: 1, saveAndExit: 0 }) setSaveState('external') window.setTimeout(() => { if (!cancelled) setSaveState('saved') }, 1200) } catch (reason) { if (!cancelled) setError(messageOf(reason)) } } // 空闲时每 2.5 秒正常读取一次 const timer = window.setInterval(() => { void poll() }, 2500) return () => { cancelled = true; window.clearInterval(timer) } }, [ready, truncated, scope.sessionId, scope.cwd, path, dark, post]) useEffect(() => () => { if (saveTimer.current !== undefined) window.clearTimeout(saveTimer.current) if (latestEditor.current !== latestFile.current) void fsWrite(scope, path, latestEditor.current, latestVersion.current, true) }, [scope.sessionId, scope.cwd, path]) if (truncated) return const targetExt = exportFormat === 'svg' ? '.svg' : exportFormat === 'xmlpng' ? '.drawio.png' : '.png' const previewName = `${fileName(path).replace(/\.drawio$/i, '')}${targetExt}` return ( {/* 右上角悬浮导出工具面板 */} setMenuOpen(open => !open)} disabled={!ready || isExporting} style={{ ...styles.exportTrigger, ...(isExporting ? styles.triggerActive : {}) }} title="导出当前画板为图片" > {isExporting ? '正在导出...' : '导出图片'} ▾ {menuOpen ? ( 导出画板图片 {previewName} {(['png', 'svg', 'xmlpng'] as const).map(fmt => ( setExportFormat(fmt)} style={{ ...styles.formatBtn, ...(exportFormat === fmt ? { ...styles.formatBtnActive, borderColor: accentColor } : {}), }} > {fmt === 'png' ? 'PNG (超清)' : fmt === 'svg' ? 'SVG (矢量)' : 'XML-PNG'} ))} {exportFormat !== 'svg' ? ( setIsTransparent(e.target.checked)} style={styles.checkbox} /> 透明背景 ) : null} {exportFormat !== 'svg' ? ( 保存到工作区仅支持 SVG;PNG / XML-PNG 请用下载或剪贴板。 ) : null} startExport('workspace')} disabled={exportFormat !== 'svg'} title={exportFormat === 'svg' ? `保存为 ${targetExt} 到画板同级目录` : '仅 SVG 支持保存到工作区,PNG / XML-PNG 请使用下载或剪贴板'} style={{ ...styles.actionBtn, ...(exportFormat === 'svg' ? { ...styles.actionBtnPrimary, background: accentColor } : { opacity: 0.5, cursor: 'not-allowed' }), }} > 保存到工作区 {exportFormat === 'svg' ? `(${targetExt})` : '(仅 SVG)'} startExport('clipboard')} style={styles.actionBtn} > 复制到剪贴板 startExport('download')} style={styles.actionBtn} > 下载图片文件 ) : null} {/* Toast 提示 */} {toast ? ( {toast} ) : null} {statusText(saveState)} {error ? ( { if (saveState === 'conflict') void reloadFromDisk() setError(null) }} style={styles.error} > {saveState === 'conflict' ? '⚠️ 检测到外部修改 · 点击重新载入' : '保存失败 · 点击关闭'} ) : null} ) } function useDshTheme(): { dark: boolean; accent: string } { const [theme, setTheme] = useState(readDshTheme) useEffect(() => { const refresh = () => { const next = readDshTheme() setTheme((current) => current.dark === next.dark && current.accent === next.accent ? current : next) } const observer = new MutationObserver(refresh) observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'style', 'data-theme'] }) observer.observe(document.body, { attributes: true, attributeFilter: ['class', 'style', 'data-theme'] }) const media = window.matchMedia('(prefers-color-scheme: dark)') media.addEventListener('change', refresh) window.addEventListener('storage', refresh) const timer = window.setInterval(refresh, 1000) return () => { observer.disconnect() media.removeEventListener('change', refresh) window.removeEventListener('storage', refresh) window.clearInterval(timer) } }, []) return theme } function readDshTheme(): { dark: boolean; accent: string } { return { dark: detectDark(), accent: localStorage.getItem('dsh-dream-skin:accent') || accent, } } function detectDark(): boolean { const preferred = localStorage.getItem('dsh-dream-skin:builtin-last') if (preferred === 'dark') return true if (preferred === 'light') return false const value = getComputedStyle(document.documentElement).getPropertyValue('--dsw-alias-container-bg').trim() const numbers = value.match(/[\d.]+/g)?.slice(0, 3).map(Number) if (numbers?.length === 3) return (numbers[0] * .299 + numbers[1] * .587 + numbers[2] * .114) < 128 return document.documentElement.classList.contains('dark') || window.matchMedia('(prefers-color-scheme: dark)').matches } async function fsStat(scope: SessionScope, path: string, signal?: AbortSignal): Promise<{ version: string; size: number }> { return call<{ version: string; size: number }>('stat', scope, { path }, false, signal) } async function fsRead(scope: SessionScope, path: string, signal?: AbortSignal): Promise<{ content: string; version: string }> { return call<{ content: string; version: string }>('read', scope, { path }, false, signal) } async function fsWrite(scope: SessionScope, path: string, content: string, version?: string, keepalive = false): Promise<{ version?: string }> { return call<{ version?: string }>('write', scope, { path, content, ...(version ? { version } : {}) }, keepalive) } async function fsSaveImage(scope: SessionScope, path: string, data: string): Promise<{ path: string; bytes: number }> { return call<{ path: string; bytes: number }>('save-image', scope, { path, data }) } async function call(method: string, scope: SessionScope, extra: Record, keepalive = false, signal?: AbortSignal): Promise { const response = await fetch(`/dsh-drawio/api/${method}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ sessionId: scope.sessionId, ...(scope.cwd ? { cwd: scope.cwd } : {}), ...extra }), keepalive, signal }) const envelope = await response.json().catch(() => null) as { ok?: boolean; value?: T; error?: { message?: string } } | null if (!response.ok || envelope?.ok !== true || envelope.value === undefined) throw new Error(envelope?.error?.message ?? `HTTP ${response.status}`) return envelope.value } function CanvasIcon({ size = 16, className }: { size?: number; className?: string }): JSX.Element { return } function CameraIcon({ size = 16, className }: { size?: number; className?: string }): JSX.Element { return } function SaveIcon({ size = 16, className }: { size?: number; className?: string }): JSX.Element { return } function CopyIcon({ size = 16, className }: { size?: number; className?: string }): JSX.Element { return } function DownloadIcon({ size = 16, className }: { size?: number; className?: string }): JSX.Element { return } function CheckIcon({ size = 16, className }: { size?: number; className?: string }): JSX.Element { return } function CenteredMessage({ title, detail }: { title: string; detail: string }): JSX.Element { return {title}{detail} } const fileName = (path: string): string => path.replace(/\\/g, '/').split('/').pop() || path const messageOf = (reason: unknown): string => reason instanceof Error ? reason.message : String(reason) const statusText = (state: string): string => state === 'loading' ? '正在载入' : state === 'saving' ? '正在保存' : state === 'external' ? '已同步 AI 修改' : state === 'conflict' ? '检测到冲突' : state === 'error' ? '保存失败' : '已保存' const fg = 'var(--dsw-alias-label-primary, currentColor)' const muted = 'var(--dsw-alias-label-secondary, rgba(127,127,127,.9))' const layer = 'var(--dsw-alias-container-bg, Canvas)' const border = 'var(--dsw-alias-border-l3, rgba(127,127,127,.24))' const accent = 'var(--dsw-alias-brand-primary, #ed67ad)' const styles: Record = { root: { position: 'relative', width: '100%', height: '100%', minHeight: 0, overflow: 'hidden', color: fg, background: layer }, frame: { display: 'block', width: '100%', height: '100%', border: 0, background: layer }, toolbar: { position: 'absolute', right: 12, top: 12, zIndex: 10, display: 'flex', flexDirection: 'column', alignItems: 'flex-end' }, exportTrigger: { display: 'flex', alignItems: 'center', gap: 6, padding: '5px 10px', border: `1px solid ${border}`, borderRadius: 6, color: fg, background: layer, boxShadow: 'var(--dsw-alias-shadow-sm, 0 2px 8px rgba(0,0,0,.12))', fontSize: 12, fontWeight: 500, cursor: 'pointer', backdropFilter: 'blur(8px)', transition: 'all 0.15s ease' }, triggerActive: { opacity: 0.75, cursor: 'wait' }, arrowIcon: { fontSize: 10, opacity: 0.6, marginLeft: 2 }, exportMenu: { marginTop: 6, width: 220, padding: 12, border: `1px solid ${border}`, borderRadius: 8, background: layer, boxShadow: 'var(--dsw-alias-shadow-md, 0 6px 20px rgba(0,0,0,.18))', backdropFilter: 'blur(12px)', display: 'flex', flexDirection: 'column', gap: 10 }, menuHeader: { display: 'flex', flexDirection: 'column', gap: 2, paddingBottom: 6, borderBottom: `1px solid ${border}` }, menuTitle: { fontSize: 12, fontWeight: 600, color: fg }, menuSub: { fontSize: 10, color: muted, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, formatRow: { display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 4 }, formatBtn: { padding: '4px 2px', border: `1px solid ${border}`, borderRadius: 4, background: 'transparent', color: muted, fontSize: 10.5, cursor: 'pointer', textAlign: 'center', transition: 'all 0.1s ease' }, formatBtnActive: { color: fg, fontWeight: 600, background: 'var(--dsw-alias-fill-quaternary, rgba(127,127,127,0.12))' }, checkRow: { display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, color: muted, cursor: 'pointer', userSelect: 'none' }, checkbox: { cursor: 'pointer', margin: 0 }, actionCol: { display: 'flex', flexDirection: 'column', gap: 6, paddingTop: 4 }, actionBtn: { display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, padding: '6px 10px', border: `1px solid ${border}`, borderRadius: 5, background: 'transparent', color: fg, fontSize: 11.5, cursor: 'pointer', transition: 'all 0.12s ease' }, actionBtnPrimary: { color: '#ffffff', border: 'none', fontWeight: 500 }, toast: { position: 'absolute', top: 12, left: '50%', transform: 'translateX(-50%)', zIndex: 12, display: 'flex', alignItems: 'center', gap: 6, padding: '6px 14px', border: `1px solid ${border}`, borderRadius: 20, background: layer, color: fg, boxShadow: 'var(--dsw-alias-shadow-md, 0 4px 14px rgba(0,0,0,.16))', fontSize: 12, backdropFilter: 'blur(10px)', pointerEvents: 'none' }, status: { position: 'absolute', right: 10, bottom: 9, zIndex: 2, display: 'flex', alignItems: 'center', gap: 6, padding: '4px 8px', border: `1px solid ${border}`, borderRadius: 999, color: muted, background: layer, boxShadow: 'var(--dsw-alias-shadow-sm, 0 2px 8px rgba(0,0,0,.12))', fontSize: 11, pointerEvents: 'none' }, statusError: { color: 'var(--dsw-alias-danger, #d84f5f)' }, dot: { width: 6, height: 6, borderRadius: 99, background: accent }, error: { position: 'absolute', left: 10, bottom: 9, zIndex: 3, padding: '5px 9px', border: `1px solid ${border}`, borderRadius: 7, color: 'var(--dsw-alias-danger, #d84f5f)', background: layer, cursor: 'pointer', font: 'inherit', fontSize: 11 }, center: { boxSizing: 'border-box', width: '100%', height: '100%', display: 'grid', placeContent: 'center', gap: 6, padding: 24, color: fg, background: layer, textAlign: 'center' }, muted: { color: muted, fontSize: 12 }, }