fix: polish workspace files and editor shell

This commit is contained in:
2026-06-19 23:37:10 +08:00
parent a6412fa070
commit 5c979174f1
23 changed files with 497 additions and 249 deletions
@@ -17,13 +17,15 @@
let currentPluginId = null;
let currentComponent = null;
let currentAPI = null;
let currentPropsKey = '';
$: activePluginId = pluginId || viewPluginId;
$: activeComponent = componentId;
$: propsKey = JSON.stringify(componentProps || {});
// React to changes — reload on view change
$: if (activePluginId && activeComponent) {
loadAndMount(activePluginId, activeComponent);
loadAndMount(activePluginId, activeComponent, propsKey);
} else if (!activePluginId) {
cleanup();
loadState = 'idle';
@@ -58,6 +60,7 @@
currentPluginId = null;
currentComponent = null;
currentAPI = null;
currentPropsKey = '';
}
function unpackBackendResult(result) {
@@ -67,9 +70,9 @@
return { value: result, error: '' };
}
async function loadAndMount(pId, compId) {
async function loadAndMount(pId, compId, nextPropsKey) {
// If same plugin+component and already mounted, skip
if (currentPluginId === pId && currentComponent === compId && loadState === 'loaded') {
if (currentPluginId === pId && currentComponent === compId && currentPropsKey === nextPropsKey && loadState === 'loaded') {
return;
}
@@ -80,6 +83,7 @@
errorText = '';
currentPluginId = pId;
currentComponent = compId;
currentPropsKey = nextPropsKey;
try {
// Get plugin frontend info
@@ -181,7 +185,7 @@
{:else if loadState === 'error'}
<div class="host-state error">
<Icon name="warning" size={24} className="error-icon" />
<Icon name="warning" size={24} class="error-icon" />
<p class="error-title">Plugin View Error</p>
<div class="error-details">
<p><strong>Plugin:</strong> {currentPluginId || 'unknown'}</p>
@@ -209,17 +213,20 @@
bind:this={mountContainer}
data-plugin-id={currentPluginId}
data-component={currentComponent}
style="flex:1;min-width:0;min-height:0;height:100%;display:flex;flex-direction:column;position:relative;"
></div>
{/if}
</div>
<style>
.plugin-bundle-host {
flex: 1;
width: 100%;
min-height: 200px;
height: 100%;
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
}
.host-state {
@@ -305,7 +312,12 @@
}
.plugin-mount-container {
flex: 1;
min-width: 0;
min-height: 0;
height: 100%;
display: flex;
flex-direction: column;
position: relative;
}
@@ -166,7 +166,7 @@
{@const isDangerous = dangerousPermissions.includes(perm)}
<span class="tag" class:dangerous={isDangerous}>
{perm}
{#if isDangerous}<Icon name="warning" size={12} className="danger-icon" />{/if}
{#if isDangerous}<Icon name="warning" size={12} class="danger-icon" />{/if}
</span>
{/each}
</div>
@@ -362,7 +362,7 @@
}
.check { color: #4ecca3; margin-left: 2px; }
.danger-icon { color: #e94560; margin-left: 2px; vertical-align: middle; }
:global(.danger-icon) { color: #e94560; margin-left: 2px; vertical-align: middle; }
.info {
color: #ffc857;
@@ -268,7 +268,7 @@
<div class="loading">Scanning plugin directories...</div>
{:else if error}
<div class="error">
<Icon name="warning" size={24} className="error-icon" />
<Icon name="warning" size={24} class="error-icon" />
<div class="error-message">{error}</div>
<button class="retry-btn" on:click={loadAll} type="button">⟳ Retry</button>
</div>
@@ -454,7 +454,7 @@
padding: 2rem; text-align: center; color: #a0a0b8;
}
.error { color: #e94560; }
.error-icon { color: #e94560; margin-bottom: 0.5rem; }
:global(.error-icon) { color: #e94560; margin-bottom: 0.5rem; }
.error-message {
font-family: monospace; font-size: 0.85rem; margin-bottom: 1rem; word-break: break-word;
}
+8 -8
View File
@@ -81,7 +81,7 @@
<aside class="sidebar">
<div class="sidebar-header">
<Icon name="logo" size={20} className="sidebar-logo" />
<Icon name="logo" size={20} class="sidebar-logo" />
<span class="sidebar-title">Verstak</span>
</div>
@@ -92,7 +92,7 @@
on:click={() => handleNav(item.id)}
type="button"
>
<Icon name={item.icon} size={16} className="nav-icon" />
<Icon name={item.icon} size={16} class="nav-icon" />
<span class="nav-label">{item.label}</span>
</button>
{/each}
@@ -107,7 +107,7 @@
on:click={() => handleSidebarItem(item)}
type="button"
>
<Icon name={item.icon || 'plugin'} size={16} className="nav-icon icon-plugin" />
<Icon name={item.icon || 'plugin'} size={16} class="nav-icon icon-plugin" />
<span class="nav-label">{item.title || item.id}</span>
</button>
{/each}
@@ -121,7 +121,7 @@
<div class="sidebar-footer">
{#if errorMessage}
<span class="sidebar-error">
<Icon name="warning" size={10} className="sidebar-error-icon" />
<Icon name="warning" size={10} class="sidebar-error-icon" />
Plugin UI error
</span>
{/if}
@@ -152,7 +152,7 @@
border-bottom: 1px solid #0f3460;
}
.sidebar-logo {
:global(.sidebar-logo) {
width: 1.2rem;
height: 1.2rem;
color: #4ecca3;
@@ -218,13 +218,13 @@
color: #e0e0f0;
}
.nav-icon {
:global(.nav-icon) {
width: 1.2rem;
height: 1.2rem;
flex-shrink: 0;
color: currentColor;
}
.nav-icon.icon-plugin {
:global(.nav-icon.icon-plugin) {
color: #a78bfa;
}
@@ -261,7 +261,7 @@
color: #e94560;
margin-bottom: 0.25rem;
}
.sidebar-error-icon {
:global(.sidebar-error-icon) {
color: #e94560;
}
</style>
+4 -4
View File
@@ -135,7 +135,7 @@
{#if error}
<div class="error-box">
<Icon name="warning" size={14} className="error-icon" />
<Icon name="warning" size={14} class="error-icon" />
<span class="error-text">{error}</span>
</div>
{/if}
@@ -191,7 +191,7 @@
{#each recentVaults as path}
<li>
<button class="recent-item" on:click={() => openRecent(path)} type="button" disabled={opening}>
<Icon name="vault" size={16} className="recent-icon" />
<Icon name="vault" size={16} class="recent-icon" />
<span class="recent-path">{path}</span>
</button>
</li>
@@ -247,7 +247,7 @@
font-size: 0.85rem;
color: #e94560;
}
.error-icon { flex-shrink: 0; }
:global(.error-icon) { flex-shrink: 0; }
.error-text { word-break: break-word; }
.actions {
display: flex;
@@ -369,7 +369,7 @@
opacity: 0.5;
cursor: not-allowed;
}
.recent-icon { flex-shrink: 0; }
:global(.recent-icon) { flex-shrink: 0; }
.recent-path {
overflow: hidden;
text-overflow: ellipsis;
+4 -4
View File
@@ -48,7 +48,7 @@
<div class="view-container">
<div class="error-boundary">
<div class="error-fallback">
<Icon name="warning" size={24} className="error-icon" />
<Icon name="warning" size={24} class="error-icon" />
<p class="error-title">Plugin UI failed</p>
<p class="error-text">{renderError}</p>
</div>
@@ -58,7 +58,7 @@
<div class="view-container">
<div class="view" class:degraded={pluginStatus === 'degraded'}>
<div class="view-header">
<Icon name={currentView.icon || 'logo'} size={20} className="view-icon" />
<Icon name={currentView.icon || 'logo'} size={20} class="view-icon" />
<h2>{currentView.title}</h2>
{#if hasFrontend}
<span class="frontend-badge">frontend bundle</span>
@@ -128,7 +128,7 @@
color: #e0e0f0;
flex: 1;
}
.view-icon {
:global(.view-icon) {
width: 1.3rem;
height: 1.3rem;
color: #a78bfa;
@@ -212,7 +212,7 @@
text-align: center;
padding: 2rem;
}
.error-icon {
:global(.error-icon) {
color: #e94560;
}
.error-title {
@@ -54,8 +54,10 @@
<style>
.workbench-host {
flex: 1;
min-width: 0;
min-height: 0;
height: 100%;
display: flex;
flex-direction: column;
background: #1a1a2e;
+1 -1
View File
@@ -51,7 +51,7 @@
<PluginBundleHost
pluginId={tool.pluginId}
componentId={tool.component}
componentProps={{ workspaceNodeId: currentNodeId, workspaceNode: currentNode }}
componentProps={{ workspaceNodeId: currentNodeId, workspaceNode: currentNode, workspaceRootPath: currentNode.path || '' }}
/>
</div>
</div>
+15 -11
View File
@@ -7,6 +7,7 @@
<script>
import { onMount } from 'svelte';
import * as App from '../../../wailsjs/go/api/App';
import Icon from '../ui/Icon.svelte';
export let nodes = [];
export let node = null;
@@ -66,11 +67,11 @@
return nodes.some(n => n.parentId === id);
}
function icon(type) {
if (type === 'space') return '\u{1F310}';
if (type === 'case') return '\u{1F4CB}';
if (type === 'folder') return '\u{1F4C1}';
return '\u{1F4C4}';
function iconName(type) {
if (type === 'space') return 'space';
if (type === 'case') return 'case';
if (type === 'folder') return 'folder';
return 'dot';
}
async function selectNode(id) {
@@ -121,7 +122,7 @@
<div class="wt-error">{localError}</div>
{:else}
{#each roots() as node (node.id)}
<svelte:self {node} {nodes} {currentNodeId} {expandedNodes} depth={1} {icon} {toggle} {hasKids} {selectNode} {openCreate} />
<svelte:self {node} {nodes} {currentNodeId} {expandedNodes} depth={1} {toggle} {hasKids} {selectNode} {openCreate} />
{/each}
{/if}
@@ -129,7 +130,7 @@
<div class="wt-create">
<div class="wt-create-header">
<span>New {newNodeType}</span>
<button class="wt-btn" on:click={cancelCreate} type="button">\u2715</button>
<button class="wt-btn" on:click={cancelCreate} type="button">x</button>
</div>
<input type="text" bind:value={newNodeTitle} placeholder="Name..." disabled={creating} />
<div class="wt-create-actions">
@@ -143,11 +144,13 @@
<div class="wt-node" class:selected={node.id === $activeWorkspaceNodeId} class:archived={node.status === 'archived'} class:sleeping={node.status === 'sleeping'}>
<div class="wt-row" style="padding-left: {depth * 1.0 + 0.4}rem;">
{#if hasKids(node.id)}
<button class="wt-expand" on:click={() => toggle(node.id)} type="button">{expandedNodes[node.id] ? '\u25BE' : '\u25B8'}</button>
<button class="wt-expand" on:click={() => toggle(node.id)} type="button" aria-label={expandedNodes[node.id] ? 'Collapse' : 'Expand'}>
<Icon name={expandedNodes[node.id] ? 'chevronDown' : 'chevronRight'} size={12} class="wt-expand-icon" />
</button>
{:else}
<span class="wt-expand-spacer"></span>
{/if}
<span class="wt-icon">{icon(node.type)}</span>
<span class="wt-icon"><Icon name={iconName(node.type)} size={13} class="wt-node-icon" /></span>
<button class="wt-label" on:click={() => selectNode(node.id)} type="button">{node.title}</button>
{#if node.type !== 'case'}
<button class="wt-btn wt-btn-small" on:click={() => openCreate(node.id, 'case')} title="Add child" type="button">+</button>
@@ -155,7 +158,7 @@
</div>
{#if expandedNodes[node.id]}
{#each childrenOf(node.id) as child (child.id)}
<svelte:self node={child} {nodes} {currentNodeId} {expandedNodes} depth={depth + 1} {icon} {toggle} {hasKids} {selectNode} {openCreate} />
<svelte:self node={child} {nodes} {currentNodeId} {expandedNodes} depth={depth + 1} {toggle} {hasKids} {selectNode} {openCreate} />
{/each}
{/if}
</div>
@@ -178,7 +181,8 @@
.wt-expand { width: 1rem; height: 1rem; min-height: 0; display: flex; align-items: center; justify-content: center; font-size: 0.65rem; color: #666; background: none; border: none; cursor: pointer; padding: 0; flex-shrink: 0; }
.wt-expand:hover { color: #e0e0f0; }
.wt-expand-spacer { width: 1rem; flex-shrink: 0; }
.wt-icon { font-size: 0.8rem; flex-shrink: 0; }
.wt-icon { width: 0.9rem; height: 0.9rem; display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; color: #a0a0b8; }
:global(.wt-node-icon), :global(.wt-expand-icon) { display: block; }
.wt-label { flex: 1; min-height: 0; justify-content: flex-start; background: none; border: none; color: #e0e0f0; font-size: 0.78rem; text-align: left; cursor: pointer; padding: 0.1rem 0.2rem; border-radius: 3px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.wt-label:hover { color: #4ecca3; }
.wt-node.archived .wt-label { text-decoration: line-through; opacity: 0.5; }
+17 -5
View File
@@ -154,9 +154,9 @@
status: 'initialized',
currentNodeId: 'case-alpha',
nodes: [
{ id: 'space-main', parentId: '', type: 'space', title: 'Main Space', status: 'active', order: 1 },
{ id: 'case-alpha', parentId: 'space-main', type: 'case', title: 'Alpha Case', status: 'active', order: 1 },
{ id: 'case-beta', parentId: 'space-main', type: 'case', title: 'Beta Case', status: 'active', order: 2 }
{ id: 'space-main', parentId: '', type: 'space', title: 'Main Space', path: 'Main Space', status: 'active', order: 1 },
{ id: 'case-alpha', parentId: 'space-main', type: 'case', title: 'Alpha Case', path: 'Main Space/Alpha Case', status: 'active', order: 1 },
{ id: 'case-beta', parentId: 'space-main', type: 'case', title: 'Beta Case', path: 'Main Space/Beta Case', status: 'active', order: 2 }
]
};
}
@@ -176,7 +176,12 @@
'Docs/todo.txt': { type: 'file', content: 'Buy groceries\nWrite tests', modifiedAt: new Date().toISOString() },
'Docs/readme.md': { type: 'file', content: '# Hello World\n\nThis is a **test** document.\n\n- item 1\n- item 2', modifiedAt: new Date().toISOString() },
'Notes': { type: 'folder', modifiedAt: new Date().toISOString() },
'Notes/Overview.md': { type: 'file', content: '# Notes Overview\n\nMy notes content here.', modifiedAt: new Date().toISOString() }
'Notes/Overview.md': { type: 'file', content: '# Notes Overview\n\nMy notes content here.', modifiedAt: new Date().toISOString() },
'Main Space': { type: 'folder', modifiedAt: new Date().toISOString() },
'Main Space/Alpha Case': { type: 'folder', modifiedAt: new Date().toISOString() },
'Main Space/Alpha Case/alpha-only.txt': { type: 'file', content: 'alpha file', modifiedAt: new Date().toISOString() },
'Main Space/Beta Case': { type: 'folder', modifiedAt: new Date().toISOString() },
'Main Space/Beta Case/beta-only.txt': { type: 'file', content: 'beta file', modifiedAt: new Date().toISOString() }
};
}
@@ -379,6 +384,12 @@
'(function(){',
'var DefaultEditor={',
'mount:function(c,p,api){',
'if(!document.getElementById("mock-default-editor-styles")){',
'var style=document.createElement("style");',
'style.id="mock-default-editor-styles";',
'style.textContent=".de-root{display:flex;flex-direction:column;height:100%;min-height:0;overflow:hidden}.de-toolbar{display:flex;align-items:center;gap:.5rem;padding:.5rem .75rem;border-bottom:1px solid #16213e;flex-shrink:0;background:#12122a}.de-toolbar-mode{font-size:.75rem;color:#4ecca3;padding:.15rem .5rem;border-radius:3px;background:#1a2a3a}.de-toolbar-context{font-size:.7rem;color:#8b8ba8}.de-editor-wrap{flex:1;display:flex;min-height:0;overflow:hidden}.de-textarea{flex:1;width:100%;height:100%;resize:none;border:0;outline:0;padding:.75rem;font-family:monospace;font-size:.85rem;line-height:1.6;background:#0d0d1a;color:#e0e0e0}.de-preview{flex:1;height:100%;padding:.75rem 1rem;overflow-y:auto;background:#0d0d1a;line-height:1.7;font-size:.9rem}.de-notes-badge{font-size:.65rem;padding:.1rem .4rem;border-radius:3px;background:#2a1a3a;color:#b388ff}";',
'document.head.appendChild(style);',
'}',
'c.innerHTML="";',
'c.className="de-root";',
'var req=p.request||{};',
@@ -442,13 +453,14 @@
"c.innerHTML='';",
"c.className='files-root';",
"c.setAttribute('data-plugin-id','verstak.files');",
"var root=String((p&&(p.workspaceRootPath||(p.workspaceNode&&p.workspaceNode.path)))||'').split('/').filter(Boolean).join('/');",
"var list=document.createElement('div');",
"list.className='files-list';",
"list.setAttribute('data-files-list','');",
"c.appendChild(list);",
"function load(){",
"list.textContent='Loading...';",
"api.files.list('').then(function(entries){",
"api.files.list(root).then(function(entries){",
"list.innerHTML='';",
"if(!entries||!entries.length){list.textContent='Empty folder';return;}",
"entries.forEach(function(e){",
+50 -16
View File
@@ -1,6 +1,6 @@
<script>
/**
* Icon.svelte — Renders inline SVG icons.
* Icon.svelte — Renders bundled Lucide SVG icons.
*
* Usage:
* <Icon name="puzzle" size={18} />
@@ -8,26 +8,60 @@
*
* Rules:
* - NO emoji, NO unicode pictographic symbols
* - ALL icons must be SVG registered in ../ui/icons.js
* - If name is not found, renders a default dot icon
* - NO dependency on system icon fonts
* - If name is not found, renders a default circle icon
*/
import { iconPaths } from './icons.js';
import Briefcase from 'lucide-svelte/icons/briefcase';
import ChevronDown from 'lucide-svelte/icons/chevron-down';
import ChevronRight from 'lucide-svelte/icons/chevron-right';
import Circle from 'lucide-svelte/icons/circle';
import FlaskConical from 'lucide-svelte/icons/flask-conical';
import Folder from 'lucide-svelte/icons/folder';
import LayoutGrid from 'lucide-svelte/icons/layout-grid';
import PanelsTopLeft from 'lucide-svelte/icons/panels-top-left';
import Pencil from 'lucide-svelte/icons/pencil';
import Plug from 'lucide-svelte/icons/plug';
import Puzzle from 'lucide-svelte/icons/puzzle';
import Settings from 'lucide-svelte/icons/settings';
import Shield from 'lucide-svelte/icons/shield';
import TriangleAlert from 'lucide-svelte/icons/triangle-alert';
export let name = 'dot';
export let size = 16;
export let className = '';
$: icon = iconPaths[name] || iconPaths.dot;
const icons = {
case: Briefcase,
chevronDown: ChevronDown,
chevronRight: ChevronRight,
dot: Circle,
edit: Pencil,
flask: FlaskConical,
folder: Folder,
gear: Settings,
logo: PanelsTopLeft,
plugin: Plug,
puzzle: Puzzle,
space: LayoutGrid,
vault: Shield,
warning: TriangleAlert,
};
const aliases = {
'🧪': 'flask',
danger: 'warning',
settings: 'gear',
};
$: iconKey = aliases[name] || name || 'dot';
$: IconComponent = icons[iconKey] || icons.dot;
$: iconClass = [className, $$restProps.class].filter(Boolean).join(' ');
</script>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox={icon.viewBox}
width={size}
height={size}
class={className}
>
{#each icon.paths as p}
<path d={p.d} {...p.attrs} />
{/each}
</svg>
<svelte:component
this={IconComponent}
size={size}
{...$$restProps}
class={iconClass}
aria-hidden="true"
/>
-128
View File
@@ -1,128 +0,0 @@
/**
* icons.js Centralised SVG icon set for Verstak.
*
* RULE: Icons MUST be SVG only. No emoji, no unicode symbols, no font-icons.
* The Wails WebKitGTK webview does NOT render colour emoji.
*
* Each icon is an object: { viewBox, paths }
* - viewBox: string, default '0 0 24 24'
* - paths: array of <path> attributes or objects with { d, ...attrs }
*
* Usage in Svelte:
* import { iconPaths } from '../lib/ui/icons.js';
* <svg viewBox={iconPaths.puzzle.viewBox} width="16" height="16">
* {#each iconPaths.puzzle.paths as p}
* <path d={p.d} {...p.attrs} />
* {/each}
* </svg>
*/
export const iconPaths = {
/** Plugin Manager — puzzle piece */
puzzle: {
viewBox: '0 0 24 24',
paths: [
{ d: 'M4 5a3 3 0 0 1 3-3h4v2a2 2 0 0 0 4 0V2h4a2 2 0 0 1 2 2v4h-2a2 2 0 0 0 0 4h2v4a2 2 0 0 1-2 2h-4v-2a2 2 0 0 0-4 0v2H7a3 3 0 0 1-3-3v-3h2a2 2 0 0 0 0-4H4V5Z',
attrs: { fill: 'currentColor', stroke: 'none' },
},
],
},
/** Platform Test / Diagnostics — flask/test-tube */
flask: {
viewBox: '0 0 24 24',
paths: [
{ d: 'M9 2v5.586l-5.293 5.293A1 1 0 0 0 4 14v4a4 4 0 0 0 4 4h8a4 4 0 0 0 4-4v-4a1 1 0 0 0-.293-.707L15 7.586V2H9Zm2 0v6a1 1 0 0 0 .293.707l5 5V14a1 1 0 0 1-1 1h-1l-2-2-2 2H8a1 1 0 0 1-1-1v-.293l3-3V2h3Z',
attrs: { fill: 'currentColor', stroke: 'none' },
},
],
},
/** Verstak logo — stack/tray */
logo: {
viewBox: '0 0 24 24',
paths: [
{ d: 'M5 3h14a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2Zm0 2v3h14V5H5Z',
attrs: { fill: 'currentColor', stroke: 'none' },
},
{ d: 'M5 12h14a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2Zm0 2v3h14v-3H5Z',
attrs: { fill: 'currentColor', stroke: 'none' },
},
],
},
/** Default fallback — circle */
dot: {
viewBox: '0 0 24 24',
paths: [
{ d: 'M12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2Zm0 6a4 4 0 1 1 0 8 4 4 0 0 1 0-8Z',
attrs: { fill: 'currentColor', stroke: 'none' },
},
],
},
/** Vault — safe / shield */
vault: {
viewBox: '0 0 24 24',
paths: [
{ d: 'M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4Zm0 2.18L19 6.4v4.6c0 4.5-3.07 8.68-7 9.82-3.93-1.14-7-5.32-7-9.82V6.4l7-3.22ZM11 8v2H9v2h2v4h2v-4h2v-2h-2V8h-2Z',
attrs: { fill: 'currentColor', stroke: 'none' },
},
],
},
/** Settings — gear */
gear: {
viewBox: '0 0 24 24',
paths: [
{ d: 'M19.14 13l.57-1.43 1.79-.5-1-2.29-1.64.73-.29-.28-.73-1.64 2.29-1-1-2.29-1.79.5-.57 1.43-2.17.17-.57-1.43-1.79-.5-1 2.29 1.64.73-.29.28-.73 1.64-2.29-1-1 2.29 1.79.5.57 1.43-.57 1.43-1.79.5 1 2.29 1.64-.73.29.28.73 1.64-2.29 1 1 2.29 1.79-.5.57-1.43 2.17-.17.57 1.43 1.79.5 1-2.29-1.64-.73.29-.28.73-1.64 2.29 1 1-2.29-1.79-.5-.57-1.43ZM12 9a3 3 0 1 1 0 6 3 3 0 0 1 0-6Z',
attrs: { fill: 'currentColor', stroke: 'none' },
},
],
},
/** Warning / error — triangle */
warning: {
viewBox: '0 0 24 24',
paths: [
{ d: 'M1 21h22L12 2 1 21Zm12-3h-2v-2h2v2Zm0-4h-2v-4h2v4Z',
attrs: { fill: 'currentColor', stroke: 'none' },
},
],
},
/** General plugin — extension/puzzle alternative */
plugin: {
viewBox: '0 0 24 24',
paths: [
{ d: 'M11 2H5v6.172a3 3 0 0 0 0 5.656V20a1 1 0 0 0 1 1h4v-2H7v-4a1 1 0 0 0-1-1H5v-2h1a1 1 0 0 0 1-1V8h2V4h2a1 1 0 0 0 1-1V2h-1Z',
attrs: { fill: 'currentColor', stroke: 'none' },
},
{ d: 'M17 2a3 3 0 0 1 3 3v1h-4V5a1 1 0 0 0-1-1h-2v2h2v2h-2v2h4V8h2v2a3 3 0 0 1-3 3h-1v2h1a2 2 0 0 1 2 2v2h2v2a2 2 0 0 1-2 2h-4a2 2 0 0 1-2-2v-2h2v-2h-2v-2h4a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1h-2v2h-2V4a2 2 0 0 1 2-2h3Z',
attrs: { fill: 'currentColor', stroke: 'none' },
},
],
},
};
/**
* Render an SVG icon string inline.
* Returns an SVG string suitable for {@html } or innerHTML.
* @param {string} name - icon key
* @param {number|string} size - width/height
* @param {string} className - optional CSS class
*/
export function svgIcon(name, size = 16, className = '') {
const icon = iconPaths[name];
if (!icon) return svgIcon('dot', size, className);
const paths = icon.paths
.map(p => {
const attrs = p.attrs
? Object.entries(p.attrs).map(([k, v]) => `${k}="${v}"`).join(' ')
: '';
return `<path d="${p.d}"${attrs ? ' ' + attrs : ''}/>`;
})
.join('');
const cls = className ? ` class="${className}"` : '';
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${icon.viewBox}" width="${size}" height="${size}"${cls}>${paths}</svg>`;
}