theme system, acme theme, 9P filesystem browser

- Theme plumbing: registry, localStorage, CSS variable overrides
- Acme theme: Plan 9 palette, Go fonts, 9term-style chat, u:/a: prefixes
- 125% zoom, acme as default
- 9P filesystem browser: clickable welcome tree, directory/file viewer
- /s/ shows sessions only, click opens chat directly
- Session deselect (click again) returns to welcome
- Kill button on session cards, improved stop button visibility
- Tool call display: terminal icon, extract code from JSON
- Refactor hardcoded colors to CSS variables
This commit is contained in:
Levi Neely 2026-04-24 21:14:06 +02:00
parent a199572a74
commit 0dc8337365
8 changed files with 424 additions and 55 deletions

View File

@ -7,6 +7,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Go+Mono&family=Go:wght@400;700&display=swap" rel="stylesheet" />
</head>
<body>
<div id="app"></div>

View File

@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'preact/hooks'
import { Sidebar } from './components/Sidebar'
import { ChatPane } from './components/ChatPane'
import { NewSession } from './components/NewSession'
import { getTheme, getThemes, loadThemeName, saveThemeName, applyTheme } from './lib/theme'
import {
listSessions,
getSessionCfg,
@ -10,6 +11,7 @@ import {
sendCtl,
createSession,
killSession,
browse9p,
} from './lib/api'
import type { IdxEntry } from './lib/api'
import { parseChat } from './lib/chat'
@ -21,6 +23,42 @@ export function App() {
const [cfg, setCfg] = useState<Record<string, string> | null>(null)
const [messages, setMessages] = useState<ChatMessage[]>([])
const [showNew, setShowNew] = useState(false)
const [themeName, setThemeName] = useState(loadThemeName)
const [browsePath, setBrowsePath] = useState<string | null>(null)
const [browseData, setBrowseData] = useState<any>(null)
// Apply theme on mount and when changed.
useEffect(() => { applyTheme(getTheme(themeName)) }, [themeName])
const handleSetTheme = useCallback((name: string) => {
saveThemeName(name)
setThemeName(name)
}, [])
const handleBrowse = useCallback(async (path: string) => {
setSelectedId(null)
setBrowsePath(path)
if (path === '/mnt/') {
// Synthetic root listing
setBrowseData([
{ name: 's', is_dir: true },
{ name: 'a', is_dir: true },
{ name: 'm', is_dir: true },
{ name: 't', is_dir: true },
{ name: 'sk', is_dir: true },
])
} else {
const apiPath = path.startsWith('/mnt/') ? '/' + path.slice(5) : path
const data = await browse9p(apiPath)
setBrowseData(data)
}
}, [])
const handleSelect = useCallback((id: string | null) => {
setSelectedId(id)
setBrowsePath(null)
setBrowseData(null)
}, [])
// Session list — poll every 5s.
useEffect(() => {
@ -89,9 +127,12 @@ export function App() {
<Sidebar
sessions={sessions}
selectedId={selectedId}
onSelect={setSelectedId}
onSelect={handleSelect}
onNew={() => setShowNew(true)}
onKill={handleKill}
themes={getThemes()}
currentTheme={themeName}
onThemeChange={handleSetTheme}
/>
<ChatPane
session={sessions.find(s => s.id === selectedId) ?? null}
@ -99,6 +140,10 @@ export function App() {
messages={messages}
onSend={handleSend}
onStop={handleStop}
browsePath={browsePath}
browseData={browseData}
onBrowse={handleBrowse}
onSelectSession={handleSelect}
/>
{showNew && (
<NewSession onClose={() => setShowNew(false)} onCreate={handleCreate} />

View File

@ -13,6 +13,10 @@ interface Props {
messages: ChatMessage[]
onSend: (text: string) => void
onStop: () => void
browsePath: string | null
browseData: any
onBrowse: (path: string) => void
onSelectSession: (id: string) => void
}
function stateClass(state: string) {
@ -21,7 +25,7 @@ function stateClass(state: string) {
return 'running'
}
export function ChatPane({ session, cfg, messages, onSend, onStop }: Props) {
export function ChatPane({ session, cfg, messages, onSend, onStop, browsePath, browseData, onBrowse, onSelectSession }: Props) {
const listRef = useRef<HTMLDivElement>(null)
const [atBottom, setAtBottom] = useState(true)
@ -42,7 +46,11 @@ export function ChatPane({ session, cfg, messages, onSend, onStop }: Props) {
if (!session || !cfg) {
return (
<main class="main">
<Welcome />
{browsePath && browsePath !== '/mnt/' ? (
<FsBrowser path={browsePath} data={browseData} onNavigate={onBrowse} onSelectSession={onSelectSession} />
) : (
<Welcome onBrowse={onBrowse} />
)}
</main>
)
}
@ -54,6 +62,7 @@ export function ChatPane({ session, cfg, messages, onSend, onStop }: Props) {
<main class="main">
<header class="chat-header">
<div class="chat-path">
<span class="fs-path-link" onClick={() => onBrowse('/mnt/s/')}>↑</span>{' '}
/s/<span class="path-hi">{sid}</span>/chat
</div>
<div class="header-meta">
@ -75,13 +84,13 @@ export function ChatPane({ session, cfg, messages, onSend, onStop }: Props) {
<div key={i} class={`message ${msg.role}`}>
<div class="msg-bubble">
{msg.role === 'user' ? (
<div class="user-text">{msg.content}</div>
<div class="user-text"><span class="msg-prefix">u: </span>{msg.content}</div>
) : msg.role === 'tool' ? (
<ToolCall content={msg.content} />
) : (
<div
class="md"
dangerouslySetInnerHTML={{ __html: marked.parse(msg.content) as string }}
dangerouslySetInnerHTML={{ __html: (marked.parse(msg.content) as string).replace(/^<p>/, '<p><span class="msg-prefix">a: </span>') }}
/>
)}
</div>
@ -107,6 +116,11 @@ function formatToolContent(raw: string): { name: string; body: string } {
if (!m) return { name: '', body: raw }
try {
const obj = JSON.parse(m[2])
// For execute_code, extract just the code/tool from steps
if (m[1] === 'execute_code' && obj.steps) {
const lines = obj.steps.map((s: any) => s.code ?? s.tool ?? JSON.stringify(s)).join('\n')
return { name: m[1], body: lines }
}
return { name: m[1], body: JSON.stringify(obj, null, 2) }
} catch {
return { name: m[1], body: m[2] }
@ -117,20 +131,22 @@ function ToolCall({ content }: { content: string }) {
const { name, body } = formatToolContent(content)
return (
<div class="tool-call">
<span class="tool-arrow">→</span>{' '}
{name && <span class="tool-name">{name}</span>}
<div class="tool-header">
<span class="tool-arrow">🖥️</span>
{name && <span class="tool-name">{name}</span>}
</div>
<pre class="tool-body">{body}</pre>
</div>
)
}
function Welcome() {
function Welcome({ onBrowse }: { onBrowse: (path: string) => void }) {
const tree: [string, string][] = [
['/s/', 'sessions'],
['/m/', 'memory'],
['/t/', 'tools'],
['/a/', 'agents'],
['/sk/', 'skills'],
['/mnt/s/', 'sessions'],
['/mnt/m/', 'memory'],
['/mnt/t/', 'tools'],
['/mnt/a/', 'agents'],
['/mnt/sk/', 'skills'],
]
return (
@ -140,14 +156,57 @@ function Welcome() {
<div class="welcome-tree">
<div class="welcome-tree-title">9p filesystem</div>
{tree.map(([path, desc]) => (
<div class="tree-row" key={path}>
<span class="tree-path">{path}</span>
<div class="tree-row tree-row-link" key={path} onClick={() => onBrowse(path)}>
<span class="tree-path">{path.replace('/mnt', '')}</span>
<span class="tree-sep">·</span>
<span class="tree-desc">{desc}</span>
</div>
))}
</div>
<div class="welcome-hint">select a session or create one</div>
<div class="welcome-hint">select a session or browse the filesystem</div>
</div>
)
}
function FsBrowser({ path, data, onNavigate, onSelectSession }: { path: string; data: any; onNavigate: (p: string) => void; onSelectSession: (id: string) => void }) {
const parent = path === '/mnt/' ? null : path.replace(/[^/]+\/?$/, '') || '/mnt/'
const displayPath = path.startsWith('/mnt') ? path.slice(4) : path
const isSessionDir = path === '/mnt/s/'
// For /s/, filter to directories only and clicking selects the session
const filteredData = isSessionDir && Array.isArray(data)
? data.filter((e: any) => e.is_dir)
: data
return (
<div class="fs-browser">
<div class="fs-path">
{parent && <span class="fs-path-link" onClick={() => onNavigate(parent)}>↑</span>}
{parent && ' '}{displayPath}
</div>
<div class="fs-content">
{filteredData === null ? (
<div class="fs-empty">loading…</div>
) : Array.isArray(filteredData) && filteredData.length > 0 && typeof filteredData[0] === 'object' ? (
filteredData.map((entry: any) => (
<div
key={entry.name}
class={`fs-entry${entry.is_dir ? ' fs-dir' : ''}`}
onClick={() => isSessionDir && entry.is_dir
? onSelectSession(entry.name)
: entry.is_dir ? onNavigate(path + entry.name + '/') : onNavigate(path + entry.name)}
>
<span class="fs-entry-name">{entry.name}{entry.is_dir ? '/' : ''}</span>
{entry.size > 0 && <span class="fs-entry-size">{entry.size}</span>}
</div>
))
) : Array.isArray(filteredData) ? (
<pre class="fs-file">{filteredData.join('\n')}</pre>
) : typeof filteredData === 'object' ? (
<pre class="fs-file">{Object.entries(filteredData).map(([k, v]) => `${k}=${v}`).join('\n')}</pre>
) : (
<pre class="fs-file">{String(filteredData)}</pre>
)}
</div>
</div>
)
}

View File

@ -1,12 +1,16 @@
import type { IdxEntry } from '../lib/api'
import type { Theme } from '../lib/theme'
import { shortId, truncate } from '../lib/chat'
interface Props {
sessions: IdxEntry[]
selectedId: string | null
onSelect: (id: string) => void
onSelect: (id: string | null) => void
onNew: () => void
onKill: (id: string) => void
themes: Theme[]
currentTheme: string
onThemeChange: (name: string) => void
}
function stateClass(state: string) {
@ -15,7 +19,7 @@ function stateClass(state: string) {
return 'running' // thinking, calling: <tool>, etc.
}
export function Sidebar({ sessions, selectedId, onSelect, onNew, onKill }: Props) {
export function Sidebar({ sessions, selectedId, onSelect, onNew, onKill, themes, currentTheme, onThemeChange }: Props) {
return (
<aside class="sidebar">
<div class="sidebar-header">
@ -41,7 +45,7 @@ export function Sidebar({ sessions, selectedId, onSelect, onNew, onKill }: Props
<div
key={s.id}
class={`session-card${s.id === selectedId ? ' selected' : ''}`}
onClick={() => onSelect(s.id)}
onClick={() => onSelect(s.id === selectedId ? null : s.id)}
>
<div class="session-card-row">
<div class={`state-dot ${stateClass(s.state)}`} />
@ -61,6 +65,18 @@ export function Sidebar({ sessions, selectedId, onSelect, onNew, onKill }: Props
</div>
))}
</div>
{themes.length > 1 && (
<div class="sidebar-footer">
<select
class="theme-select"
value={currentTheme}
onChange={e => onThemeChange((e.target as HTMLSelectElement).value)}
>
{themes.map(t => <option key={t.name} value={t.name}>{t.label}</option>)}
</select>
</div>
)}
</aside>
)
}

View File

@ -70,3 +70,11 @@ export async function getBackends(): Promise<string[]> {
export async function killSession(id: string): Promise<void> {
await fetch(`/s/${id}`, { method: 'DELETE' })
}
export async function browse9p(path: string): Promise<any> {
const r = await fetch(path)
if (!r.ok) return null
const ct = r.headers.get('content-type') ?? ''
if (ct.includes('text/plain')) return await r.text()
return await r.json()
}

91
src/lib/theme.ts Normal file
View File

@ -0,0 +1,91 @@
export interface Theme {
name: string
label: string
vars: Record<string, string>
}
const midnight: Theme = {
name: 'midnight',
label: 'Midnight',
vars: {}, // default — uses :root values from CSS
}
const acme: Theme = {
name: 'acme',
label: 'Acme',
vars: {
'--bg': '#ffffea', // acme yellow body
'--bg-elevated': '#eaffff', // acme blue tag bar
'--mono': "'Go Mono', monospace",
'--sans': "'Go', system-ui, sans-serif",
'--bg-soft': '#ffffff', // white inputs
'--bg-card': '#eaffea', // rio pale green window body
'--border': '#888888',
'--border-focus': '#444444',
'--text': '#000000',
'--text-dim': '#333333',
'--text-faint': '#777777',
'--accent': '#268bd2',
'--accent-soft': 'rgba(38, 139, 210, 0.1)',
'--accent-glow': 'rgba(38, 139, 210, 0.2)',
'--accent-fg': '#ffffea',
'--accent-border': 'rgba(38, 139, 210, 0.3)',
'--accent-focus': 'rgba(38, 139, 210, 0.5)',
'--running': '#859900',
'--running-glow': 'rgba(133, 153, 0, 0.35)',
'--idle': '#888888',
'--stopped': '#aaaaaa',
'--danger': '#dc322f',
'--warning': '#b58900',
'--hover': 'rgba(0, 0, 0, 0.05)',
'--hover-strong': 'rgba(0, 0, 0, 0.08)',
'--code-bg': '#ffffff', // white inner code box
'--overlay-bg': 'rgba(0, 0, 0, 0.3)',
'--user-bubble-bg': 'transparent',
'--user-bubble-border': 'transparent',
'--asst-bubble-bg': 'transparent',
'--asst-bubble-border': 'transparent',
'--tool-bubble-bg': '#ffffff',
'--tool-bubble-accent': '#b58900',
'--tool-text': '#000000',
'--tool-arrow-color': '#000000',
'--tool-header-bg': 'transparent',
},
}
const themes: Theme[] = [midnight, acme]
export function getThemes(): Theme[] {
return themes
}
export function getTheme(name: string): Theme {
return themes.find(t => t.name === name) ?? midnight
}
const STORAGE_KEY = 'ollie-theme'
export function loadThemeName(): string {
return localStorage.getItem(STORAGE_KEY) ?? 'acme'
}
export function saveThemeName(name: string) {
localStorage.setItem(STORAGE_KEY, name)
}
export function applyTheme(theme: Theme) {
const el = document.documentElement
// Clear any previously applied theme vars
el.removeAttribute('style')
for (const [k, v] of Object.entries(theme.vars)) {
el.style.setProperty(k, v)
}
el.dataset.theme = theme.name
}

View File

@ -15,6 +15,8 @@
--accent-soft: rgba(34, 211, 238, 0.07);
--accent-glow: rgba(34, 211, 238, 0.18);
--accent-fg: #061419;
--accent-border: var(--accent-border);
--accent-focus: var(--accent-focus);
--running: #34d399;
--running-glow: rgba(52, 211, 153, 0.35);
@ -23,6 +25,21 @@
--danger: #fb7185;
--warning: #fbbf24;
--hover: rgba(255, 255, 255, 0.025);
--hover-strong: rgba(255, 255, 255, 0.04);
--code-bg: #07070f;
--overlay-bg: rgba(0, 0, 0, 0.65);
--user-bubble-bg: var(--accent-soft);
--user-bubble-border: var(--accent-border);
--asst-bubble-bg: var(--bg-card);
--asst-bubble-border: var(--border);
--tool-bubble-bg: transparent;
--tool-bubble-accent: var(--warning);
--tool-text: var(--text-dim);
--tool-arrow-color: var(--warning);
--tool-header-bg: transparent;
--mono: 'JetBrains Mono', 'Cascadia Code', 'Fira Code', ui-monospace, monospace;
--sans: system-ui, -apple-system, 'Segoe UI', sans-serif;
@ -37,6 +54,7 @@
/* ---- Reset ---- */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body, #app { height: 100%; }
html { zoom: 1.25; }
body {
font-family: var(--sans);
font-size: 14px;
@ -58,7 +76,7 @@ input, select, textarea { font-family: inherit; }
.layout {
display: grid;
grid-template-columns: var(--sidebar) 1fr;
height: 100vh;
height: 100%;
overflow: hidden;
}
@ -118,6 +136,26 @@ input, select, textarea { font-family: inherit; }
color: var(--text-faint);
}
.sidebar-footer {
padding: 10px 12px;
border-top: 1px solid var(--border);
flex-shrink: 0;
}
.theme-select {
width: 100%;
background: var(--bg-soft);
border: 1px solid var(--border);
border-radius: var(--r-md);
padding: 6px 10px;
color: var(--text-dim);
font-family: var(--mono);
font-size: 11px;
outline: none;
appearance: none;
cursor: pointer;
}
.theme-select:focus { border-color: var(--accent-focus); }
/* ---- Session Card ---- */
.session-card {
padding: 10px 10px 10px 12px;
@ -130,13 +168,13 @@ input, select, textarea { font-family: inherit; }
}
.session-card:hover {
background: rgba(255, 255, 255, 0.025);
background: var(--hover);
border-color: var(--border);
}
.session-card.selected {
background: var(--accent-soft);
border-color: rgba(34, 211, 238, 0.22);
border-color: var(--accent-border);
}
.session-card-row {
@ -224,14 +262,14 @@ input, select, textarea { font-family: inherit; }
border-radius: 3px;
border: 1px solid var(--border);
color: var(--text-dim);
background: rgba(255, 255, 255, 0.025);
background: var(--hover);
white-space: nowrap;
line-height: 1.5;
}
.badge.accent {
color: var(--accent);
border-color: rgba(34, 211, 238, 0.22);
background: rgba(34, 211, 238, 0.05);
border-color: var(--accent-border);
background: var(--accent-soft);
}
.badge.warn {
color: var(--warning);
@ -264,7 +302,7 @@ input, select, textarea { font-family: inherit; }
.btn-ghost:hover:not(:disabled) {
border-color: var(--border-focus);
color: var(--text);
background: rgba(255, 255, 255, 0.04);
background: var(--hover-strong);
}
.btn-primary {
@ -287,7 +325,7 @@ input, select, textarea { font-family: inherit; }
display: flex;
flex-direction: column;
overflow: hidden;
height: 100vh;
height: 100%;
background: var(--bg);
}
@ -436,20 +474,23 @@ input, select, textarea { font-family: inherit; }
.message.user .msg-bubble {
max-width: 72%;
background: var(--accent-soft);
border: 1px solid rgba(34, 211, 238, 0.16);
background: var(--user-bubble-bg);
border: 1px solid var(--user-bubble-border);
border-bottom-right-radius: var(--r-sm);
color: var(--text);
}
.message.assistant .msg-bubble {
max-width: 100%;
width: 100%;
background: var(--bg-card);
border: 1px solid var(--border);
max-width: 80%;
background: var(--asst-bubble-bg);
border: 1px solid var(--asst-bubble-border);
border-bottom-left-radius: var(--r-sm);
}
.msg-prefix {
display: none;
}
.user-text {
white-space: pre-wrap;
word-break: break-word;
@ -482,7 +523,7 @@ input, select, textarea { font-family: inherit; }
}
.md pre {
background: #07070f;
background: var(--code-bg);
border: 1px solid var(--border);
border-radius: var(--r-md);
padding: 14px 16px;
@ -499,8 +540,8 @@ input, select, textarea { font-family: inherit; }
}
.md :not(pre) > code {
background: rgba(34, 211, 238, 0.08);
border: 1px solid rgba(34, 211, 238, 0.15);
background: var(--accent-soft);
border: 1px solid var(--accent-border);
border-radius: 3px;
padding: 0.1em 0.4em;
color: var(--accent);
@ -524,36 +565,40 @@ input, select, textarea { font-family: inherit; }
.message.tool .msg-bubble {
max-width: 100%;
width: 100%;
background: transparent;
background: var(--tool-bubble-bg);
border: 1px solid var(--border);
border-left: 3px solid var(--warning);
border-radius: var(--r-sm);
padding: 6px 12px;
border-radius: var(--r-md);
padding: 0;
overflow: hidden;
}
.tool-call {
font-family: var(--mono);
font-size: 12px;
color: var(--text-dim);
color: var(--tool-text);
}
.tool-header {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
}
.tool-arrow {
color: var(--warning);
font-weight: 600;
font-size: 1.3em;
line-height: 1;
}
.tool-name {
color: var(--accent);
font-weight: 500;
color: var(--tool-text);
font-weight: 600;
}
.tool-body {
margin: 6px 0 0;
padding: 8px 12px;
background: #07070f;
border: 1px solid var(--border);
border-radius: var(--r-sm);
font-size: 11.5px;
margin: 0;
padding: 8px 14px;
background: var(--code-bg);
font-size: 12px;
line-height: 1.55;
white-space: pre-wrap;
word-break: break-all;
color: var(--text-dim);
color: var(--text);
overflow-x: auto;
}
@ -612,7 +657,7 @@ input, select, textarea { font-family: inherit; }
max-height: 160px;
transition: border-color 0.13s;
}
.prompt-textarea:focus { border-color: rgba(34, 211, 238, 0.35); }
.prompt-textarea:focus { border-color: var(--accent-focus); }
.prompt-textarea::placeholder { color: var(--text-faint); }
.prompt-textarea:disabled { opacity: 0.45; cursor: not-allowed; }
@ -620,7 +665,7 @@ input, select, textarea { font-family: inherit; }
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.65);
background: var(--overlay-bg);
backdrop-filter: blur(6px);
display: flex;
align-items: center;
@ -685,7 +730,7 @@ input, select, textarea { font-family: inherit; }
}
.field input:focus,
.field select:focus { border-color: rgba(34, 211, 238, 0.35); }
.field select:focus { border-color: var(--accent-focus); }
.field select {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%233e3e54' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
@ -705,3 +750,103 @@ input, select, textarea { font-family: inherit; }
gap: 10px;
justify-content: flex-end;
}
/* ---- Acme theme overrides ---- */
[data-theme="acme"] .message-list {
padding: 8px 16px 8px;
}
[data-theme="acme"] .message {
margin-bottom: 4px;
}
[data-theme="acme"] .message.user {
justify-content: flex-start;
}
[data-theme="acme"] .msg-bubble {
padding: 4px 8px;
border-radius: 0;
}
[data-theme="acme"] .message.user .msg-bubble {
max-width: 100%;
}
[data-theme="acme"] .message.assistant .msg-bubble {
max-width: 100%;
}
[data-theme="acme"] .message.tool .msg-bubble {
border-radius: var(--r-sm);
margin: 4px 0;
}
[data-theme="acme"] .tool-body {
padding: 4px 10px;
}
[data-theme="acme"] .tool-header {
padding: 4px 10px;
}
[data-theme="acme"] .msg-prefix {
display: inline;
font-family: var(--mono);
font-weight: 600;
color: #000000;
}
/* ---- Filesystem browser ---- */
.fs-browser {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.fs-path {
font-family: var(--mono);
font-size: 12px;
padding: 0 20px;
height: 52px;
display: flex;
align-items: center;
gap: 8px;
border-bottom: 1px solid var(--border);
color: var(--text-faint);
background: var(--bg-elevated);
}
.fs-path-link {
cursor: pointer;
color: var(--accent);
font-weight: 600;
}
.fs-path-link:hover { text-decoration: underline; }
.fs-content {
flex: 1;
overflow-y: auto;
padding: 8px 0;
}
.fs-empty {
padding: 16px;
font-family: var(--mono);
font-size: 12px;
color: var(--text-faint);
}
.fs-entry {
display: flex;
justify-content: space-between;
padding: 4px 16px;
font-family: var(--mono);
font-size: 13px;
cursor: pointer;
color: var(--text);
}
.fs-entry:hover { background: var(--hover); }
.fs-dir .fs-entry-name { color: var(--accent); font-weight: 500; }
.fs-entry-size {
color: var(--text-faint);
font-size: 11px;
}
.fs-file {
padding: 12px 16px;
font-family: var(--mono);
font-size: 12px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-all;
color: var(--text);
}
.tree-row-link { cursor: pointer; }
.tree-row-link:hover .tree-path { text-decoration: underline; }

View File

@ -6,6 +6,10 @@ export default defineConfig({
server: {
proxy: {
'/s/': 'http://localhost:8080',
'/a/': 'http://localhost:8080',
'/m/': 'http://localhost:8080',
'/t/': 'http://localhost:8080',
'/sk/': 'http://localhost:8080',
'/backends': 'http://localhost:8080',
'/openapi.json': 'http://localhost:8080',
},