Refresh Configs with browser editing and source inspection

This commit is contained in:
2026-09-18 11:02:22 -04:00
parent ec51e38312
commit ef8ff69d17
13 changed files with 944 additions and 395 deletions
+66
View File
@@ -0,0 +1,66 @@
export interface ConfigSource {
id: number | null;
name: string;
version: number | null;
kind: 'baseline' | 'config' | 'map';
draft?: boolean;
}
export interface ConfigAssignment {
value: string;
source: ConfigSource;
line: number;
}
export interface ConfigSetting {
key: string;
value: string;
source: ConfigSource;
history: ConfigAssignment[];
}
export interface ConfigInspection {
text: string;
settings: ConfigSetting[];
sources: ConfigSource[];
warnings: string[];
reason?: string;
}
export interface ConfigServer { name: string; game: string | null; map: string | null; }
/** The same lowercase `set` grammar that the overlay composer accepts. */
export function configLines(text: string) {
return text.split(/\r?\n/).flatMap((line, index) => {
const match = /^(\s*set\s+)(\S+)(\s+)(.*)$/.exec(line);
return match ? [{ index, key: match[2], value: match[4], prefix: match[1] + match[2] + match[3] }] : [];
});
}
export function changeConfigValue(text: string, index: number, value: string): string {
// Keep every separator, comment and unrelated line byte-for-byte intact.
const parts = text.split(/(\r?\n)/);
const entry = configLines(text).find(line => line.index === index);
if (entry) parts[index * 2] = entry.prefix + value.replace(/[\r\n]/g, '');
return parts.join('');
}
export function configWarnings(text: string, overlay: boolean): string[] {
const warnings: string[] = [];
const seen = new Map<string, number>();
for (const entry of configLines(text)) {
const key = entry.key.toLowerCase();
if (seen.has(key)) warnings.push(`Line ${entry.index + 1}: ${entry.key} is also assigned on line ${seen.get(key)}.`);
seen.set(key, entry.index + 1);
if (!entry.value.trim() || entry.value.trim().startsWith('//')) warnings.push(`Line ${entry.index + 1}: ${entry.key} has no value.`);
}
if (overlay) text.split(/\r?\n/).forEach((line, index) => {
if (line.trim() && !/^\s*\/\//.test(line) && !/^\s*set\s+\S+\s+/.test(line)) {
warnings.push(`Line ${index + 1} is ignored by overlays. Use set KEY VALUE.`);
}
});
if (/^\s*(?:(?:set|seta)\s+)?sv_maprotation\s+/im.test(text)) {
warnings.push('For recognized maps, the server’s selected map overrides sv_maprotation.');
}
return warnings;
}
+62
View File
@@ -0,0 +1,62 @@
import { composeServerCfg, parseCfg } from './config-compose.js';
import { selectedZombieMap, mapRotation } from './map-aliases.js';
import { configWarnings, type ConfigAssignment, type ConfigInspection, type ConfigSource } from '../../config-workbench.js';
export interface ConfigLayer { source: ConfigSource; text: string; }
/** Trace file composition, including assignments left in place by the composer.
* This is an intended-file preview, not a query of live runtime dvars. */
export function inspectConfig(baseline: ConfigLayer, overlays: ConfigLayer[], game: string | null, map: string | null, mapSlug?: string | null): ConfigInspection {
const sorted = [...overlays].sort((a, b) => a.source.id! - b.source.id!);
const parsed = parseCfg(baseline.text);
type TracedLine = { line: string; history: ConfigAssignment[] };
function assignment(line: string, source: ConfigSource, index: number): ConfigAssignment {
const match = /^\s*(?:(?:set|seta)\s+)(\S+)\s+(.*)$/i.exec(line)
?? /^\s*(sv_maprotation)\s+(.*)$/i.exec(line);
return { value: match?.[2] ?? '', source, line: index + 1 };
}
const lines: TracedLine[] = parsed.order.map((entry, index) => ({
line: entry.line, history: [assignment(entry.line, baseline.source, index)]
}));
const replacements = new Map<string, TracedLine>();
const warnings = configWarnings(baseline.text, false).map(w => `${baseline.source.name}: ${w}`);
for (const overlay of sorted) {
warnings.push(...configWarnings(overlay.text, true).map(w => `${overlay.source.name}: ${w}`));
overlay.text.split(/\r?\n/).forEach((line, index) => {
const match = /^\s*set\s+(\S+)\s+/.exec(line);
if (!match) return;
const previous = replacements.get(match[1]);
replacements.set(match[1], { line, history: [...(previous?.history ?? []), assignment(line, overlay.source, index)] });
});
}
for (const [key, replacement] of replacements) {
const index = parsed.entries.get(key);
if (index === undefined) lines.push(replacement);
else lines[index] = { line: replacement.line, history: [...lines[index].history, ...replacement.history] };
}
const sources = [baseline.source, ...sorted.map(o => o.source)];
const selected = selectedZombieMap(game, map, mapSlug);
if (selected) {
const source: ConfigSource = { id: null, name: 'Server map selection', version: null, kind: 'map' };
sources.push(source);
const rotation = /^\s*(?:(?:set|seta)\s+)?sv_maprotation\s+/i;
const history = lines.filter(entry => rotation.test(entry.line)).flatMap(entry => entry.history);
for (let i = lines.length - 1; i >= 0; i--) if (rotation.test(lines[i].line)) lines.splice(i, 1);
const line = `set sv_maprotation "${mapRotation(selected)}"`;
lines.push({ line, history: [...history, assignment(line, source, 0)] });
}
const byKey = new Map<string, { key: string; history: ConfigAssignment[] }>();
for (const entry of lines) {
const match = /^\s*(?:set|seta)\s+(\S+)\s+/i.exec(entry.line) ?? /^\s*(sv_maprotation)\s+/i.exec(entry.line);
if (!match) continue;
const normalized = match[1].toLowerCase();
const existing = byKey.get(normalized);
byKey.set(normalized, { key: match[1], history: [...(existing?.history ?? []), ...entry.history] });
}
return {
text: composeServerCfg(baseline.text, sorted.map(o => ({ id: o.source.id!, text: o.text })), game, map, mapSlug),
sources, warnings,
settings: [...byKey.values()].map(({ key, history }) => ({ key, history, value: history.at(-1)!.value, source: history.at(-1)!.source }))
.sort((a, b) => a.key.localeCompare(b.key))
};
}
@@ -2,6 +2,7 @@
import { onMount, onDestroy } from 'svelte';
import { artifactVersionLabel } from '$lib/artifact-version.js';
import PluginGithubImport from './PluginGithubImport.svelte';
import ConfigEditor from './ConfigEditor.svelte';
import type { PluginSource } from '$lib/plugin-github.js';
import { goto } from '$app/navigation';
import Card from './primitives/Card.svelte';
@@ -329,6 +330,11 @@
</div>
</Card>
{#if a.kind === 'config' || a.kind === 'baseline'}
<ConfigEditor {id} kind={a.kind} {versions} currentVersionId={a.current_version_id}
{runningBetaId} scopeType={a.scope_type} scopeValue={a.scope_value} onsaved={() => load({ seedForm: false })} />
{/if}
<Card padding="lg">
<div class="panel-head">
<h2>Version history</h2>
+140
View File
@@ -0,0 +1,140 @@
<script lang="ts">
import { onMount } from 'svelte';
import { beforeNavigate } from '$app/navigation';
import ConfigTextEditor from './ConfigTextEditor.svelte';
import ConfigInspector from './ConfigInspector.svelte';
import Button from './primitives/Button.svelte';
interface Version { id: number; version: number; notes: string | null; }
let { id, kind, versions, currentVersionId, runningBetaId, scopeType, scopeValue, onsaved }: {
id: number; kind: 'baseline' | 'config'; versions: Version[]; currentVersionId: number | null;
runningBetaId: number | null; scopeType: string | null; scopeValue: string | null; onsaved: () => Promise<void>;
} = $props();
let selected = $state('');
let text = $state('');
let original = $state('');
let notes = $state('');
let loading = $state(true);
let saving = $state(false);
let error = $state('');
let success = $state('');
let ready = $state(false);
let retry = $state(0);
let loadedVersion = $state('');
let recovered = $state<{ version: string; text: string; notes: string } | null>(null);
const dirty = $derived(text !== original || notes.trim().length > 0);
const storageKey = $derived(`electric-cherry.config-draft.${id}`);
onMount(() => {
selected = String(versions[0]?.id ?? ''); ready = true;
try {
const saved = JSON.parse(localStorage.getItem(storageKey) ?? 'null');
if (saved && typeof saved.text === 'string' && typeof saved.version === 'string' && typeof saved.notes === 'string') recovered = saved;
} catch { /* Draft storage is optional. */ }
});
beforeNavigate(({ cancel }) => { if (dirty && !confirm('Leave this editor? Your draft is kept in this browser when storage is available.')) cancel(); });
function beforeUnload(event: BeforeUnloadEvent) { if (dirty) { event.preventDefault(); event.returnValue = ''; } }
$effect(() => {
const version = selected;
const refresh = retry;
if (!ready || !version) return;
const controller = new AbortController();
loading = true; error = ''; loadedVersion = '';
(async () => {
try {
const response = await fetch(`/api/configs/${id}?version=${version}`, { signal: controller.signal });
const body = await response.json();
if (!response.ok) throw new Error(body.error ?? 'Could not load config contents.');
if (!controller.signal.aborted) { text = body.text; original = body.text; notes = ''; loadedVersion = version; }
} catch (err) { if (!controller.signal.aborted) error = (err as Error).message; }
finally { if (!controller.signal.aborted) loading = false; }
})();
return () => controller.abort();
});
$effect(() => {
if (!ready || loading || !loadedVersion || !dirty) return;
try { localStorage.setItem(storageKey, JSON.stringify({ version: loadedVersion, text, notes })); } catch { /* Editing still works without storage. */ }
});
function clearDraft() { try { localStorage.removeItem(storageKey); } catch { /* Storage may be disabled. */ } recovered = null; }
function changeVersion(event: Event) {
const target = event.currentTarget as HTMLSelectElement;
if (dirty && !confirm('Discard your current edits and open this version?')) { target.value = selected; return; }
clearDraft(); selected = target.value; success = '';
}
function restoreDraft() {
if (!recovered) return;
// Keep the current version as the comparison base, including when the
// recovered draft was started from a version that has since been deleted.
text = recovered.text; notes = recovered.notes; recovered = null;
}
function discard() { if (confirm('Discard your unsaved changes?')) { text = original; notes = ''; clearDraft(); } }
async function importFile(event: Event) {
const input = event.currentTarget as HTMLInputElement;
const file = input.files?.[0];
if (file) { try { text = await file.text(); } catch { error = 'Could not read that file.'; } }
input.value = '';
}
async function save() {
if (saving || loading || runningBetaId !== null || !text.trim()) return;
saving = true; error = ''; success = '';
const form = new FormData();
form.append('content', new Blob([text], { type: 'text/plain' }), 'config.cfg');
if (notes.trim()) form.append('notes', notes.trim());
try {
const response = await fetch(`/api/scripts/${id}/versions`, { method: 'POST', body: form });
const body = await response.json();
if (!response.ok) throw new Error(body.error ?? 'Could not save this version.');
original = text; notes = ''; clearDraft();
success = `v${body.version.version} saved as untested. Open a beta to test and promote it.`;
await onsaved(); selected = String(body.version.id);
} catch (err) { error = (err as Error).message; }
finally { saving = false; }
}
</script>
<svelte:window onbeforeunload={beforeUnload} />
<section class="workbench" aria-label="Config editor">
<div class="editor-panel">
<div class="heading"><div><span class="eyebrow">Edit · preview · version</span><h2>Configuration editor</h2></div>
{#if dirty}<span class="draft-tag">Unsaved draft</span>{/if}
</div>
<div class="version-toolbar"><label>Starting version<select aria-label="Starting version" value={selected} onchange={changeVersion} disabled={saving || loading}>{#each versions as version}<option value={String(version.id)}>v{version.version}{version.id === currentVersionId ? ' · promoted' : ''}{version.id === versions[0]?.id ? ' · latest' : ''}</option>{/each}</select></label>
<label class="file-button">Import .cfg<input type="file" accept=".cfg,text/plain" onchange={importFile} disabled={saving || loading || runningBetaId !== null} /></label>
</div>
{#if recovered && !loading}<div class="recovery"><span>A previous draft is available in this browser.</span><Button size="sm" onclick={restoreDraft} disabled={runningBetaId !== null}>Restore draft</Button><Button size="sm" variant="ghost" onclick={clearDraft}>Dismiss</Button></div>{/if}
{#if success}<div class="success" role="status">{success} <a href="/betas">Open betas →</a></div>{/if}
{#if error}<div class="error" role="alert">{error} {#if !loadedVersion}<Button size="sm" onclick={() => retry++}>Retry</Button>{/if}</div>{/if}
{#if loading}<p class="muted" role="status">Loading config contents…</p>
{:else if loadedVersion}
<ConfigTextEditor bind:text overlay={kind === 'config'} disabled={saving || runningBetaId !== null} />
<label class="notes-label">Version notes<input bind:value={notes} placeholder="What changed, and why?" disabled={saving || runningBetaId !== null} /></label>
<div class="actions"><span class="muted">{runningBetaId !== null ? 'Finish the active beta to save another version.' : 'A new version must pass through a beta before promotion.'}</span>
<Button variant="ghost" size="sm" onclick={discard} disabled={!dirty || saving}>Discard</Button>
<Button variant="primary" onclick={save} loading={saving} disabled={!dirty || !text.trim() || runningBetaId !== null}>Save new version</Button>
</div>
{/if}
</div>
{#if loadedVersion && !loading}<ConfigInspector artifactId={id} draftText={text} {scopeType} {scopeValue} />{/if}
</section>
<style>
.workbench { display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr); gap: var(--space-5); align-items: start; }
.editor-panel { background: var(--surface); border: 1px solid var(--border-strong); border-radius: var(--radius-lg); padding: var(--space-6); min-width: 0; }
.heading, .version-toolbar, .actions, .recovery { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
h2 { font-size: var(--text-lg); margin: 5px 0 0; }
.eyebrow { font-size: var(--text-xs); color: var(--accent-primary-hover); }
.draft-tag { color: var(--state-warning); font-size: var(--text-xs); }
.version-toolbar { margin: 20px 0; align-items: flex-end; }
.version-toolbar > label:first-child { flex: 1; }
label { display: grid; gap: 8px; color: var(--text-secondary); font-size: var(--text-xs); }
select, input:not([type='file']) { width: 100%; box-sizing: border-box; padding: 10px 12px; color: var(--text-primary); background: var(--surface-sunken); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); font: var(--text-sm) var(--font-body); }
input:focus-visible, select:focus-visible, .file-button:focus-within { outline: 2px solid var(--accent-primary); outline-offset: 2px; }
.file-button { position: relative; display: block; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 11px; overflow: hidden; cursor: pointer; }
.file-button input { position: absolute; inset: 0; opacity: 0; width: 100%; cursor: pointer; }
.notes-label { margin-top: 20px; }
.actions { margin-top: 16px; }
.actions > span { flex: 1 1 200px; }
.muted { color: var(--text-secondary); font-size: var(--text-xs); }
.recovery, .success, .error { font-size: var(--text-sm); padding: 12px; border-radius: var(--radius-sm); background: var(--surface-raised); margin-bottom: 16px; }
.recovery { color: var(--state-warning); } .success { color: var(--state-online); } .error { color: var(--state-danger); }
@media (max-width: 1100px) { .workbench { grid-template-columns: 1fr; } }
@media (max-width: 600px) { .editor-panel { padding: 16px; } }
</style>
@@ -0,0 +1,144 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { ConfigInspection, ConfigServer, ConfigSource } from '$lib/config-workbench.js';
import Button from './primitives/Button.svelte';
let { artifactId, draftText, scopeType, scopeValue }: { artifactId?: number; draftText?: string; scopeType?: string | null; scopeValue?: string | null } = $props();
let servers = $state<ConfigServer[]>([]);
let selected = $state('');
let inspection = $state<ConfigInspection | null>(null);
let loading = $state(true);
let error = $state('');
let query = $state('');
let onlyOverrides = $state(false);
let raw = $state(false);
let reload = $state(0);
let ready = $state(false);
const eligible = $derived(servers.filter(s => !artifactId || (scopeType === 'game' ? s.game === scopeValue : scopeType === 'map' ? s.map === scopeValue : false)));
const settings = $derived((inspection?.settings ?? []).filter(s => (!onlyOverrides || s.history.length > 1) && `${s.key} ${s.value} ${s.source.name}`.toLowerCase().includes(query.toLowerCase())));
const overlaps = $derived(inspection?.settings.filter(s => s.history.length > 1).length ?? 0);
function label(source: ConfigSource): string { return `${source.name}${source.draft ? ' · draft' : source.version ? ` · v${source.version}` : ''}`; }
async function loadServers() {
loading = true; error = '';
try {
const response = await fetch('/api/configs/inspect');
const body = await response.json();
if (!response.ok) throw new Error(body.error ?? 'Could not load servers.');
servers = body.servers;
ready = true;
} catch (err) { error = (err as Error).message; }
finally { loading = false; }
}
onMount(loadServers);
$effect(() => { if (!eligible.some(s => s.name === selected)) selected = eligible[0]?.name ?? ''; });
$effect(() => {
const server = selected;
const text = draftText;
const id = artifactId;
const refresh = reload;
if (!ready || !server) { inspection = null; if (ready) loading = false; return; }
const controller = new AbortController();
loading = true; error = ''; inspection = null;
const timer = setTimeout(async () => {
try {
const response = await fetch(id !== undefined && text !== undefined ? '/api/configs/inspect' : `/api/configs/inspect?server=${encodeURIComponent(server)}`, {
signal: controller.signal,
...(id !== undefined && text !== undefined ? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ server, artifactId: id, text }) } : {})
});
const body = await response.json();
if (!response.ok) throw new Error(body.error ?? 'Could not inspect this config.');
if (!controller.signal.aborted) inspection = body.inspection;
} catch (err) { if (!controller.signal.aborted) error = (err as Error).message; }
finally { if (!controller.signal.aborted) loading = false; }
}, text === undefined ? 0 : 350);
return () => { clearTimeout(timer); controller.abort(); };
});
function download() {
if (!inspection) return;
const url = URL.createObjectURL(new Blob([inspection.text], { type: 'text/plain' }));
const anchor = document.createElement('a'); anchor.href = url; anchor.download = `${selected}${artifactId ? '-draft' : ''}.cfg`; anchor.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
</script>
<section class="inspector" aria-label="Effective config inspector" aria-busy={loading}>
<div class="heading"><div><span class="eyebrow">{artifactId ? 'Draft preview' : 'Follow every value to its source'}</span><h2>{artifactId ? 'On this server' : 'Effective configuration'}</h2></div>
<Button size="sm" variant="ghost" onclick={() => ready ? reload++ : loadServers()}>Refresh</Button>
</div>
<p class="intro">{artifactId ? 'Your draft composed with the other promoted configs. Saving creates an untested version.' : 'The intended file from promoted configs and the selected map. Live files and active betas may differ.'}</p>
<label class="server-label">Server<select aria-label="Server" bind:value={selected} disabled={!eligible.length}>
{#each eligible as server}<option value={server.name}>{server.name}</option>{/each}
{#if !eligible.length}<option value="">No matching servers</option>{/if}
</select></label>
{#if error}<div class="error" role="alert">{error}</div>
{:else if loading}<p class="empty" role="status">Composing configuration…</p>
{:else if !eligible.length}<p class="empty">{artifactId ? 'No registered servers match this scope.' : 'Register a server to inspect its configuration.'}</p>
{:else if inspection?.reason}<p class="empty">{inspection.reason}</p>
{:else if inspection}
<div class="sources" aria-label="Composition sources">
{#each inspection.sources as source, index}
{#if index}<span class="arrow" aria-hidden="true">→</span>{/if}
{#if source.id}<a class:draft={source.draft} href="/configs/{source.id}">{label(source)}</a>
{:else}<span class="map-source">{source.name}</span>{/if}
{/each}
</div>
<p class="hint">Overlays apply in creation order; later overlays win for the same key. The selected map takes precedence over map rotation.</p>
<div class="toolbar"><div class="switch"><button class:active={!raw} aria-pressed={!raw} onclick={() => raw = false}>Settings <span>{inspection.settings.length}</span></button><button class:active={raw} aria-pressed={raw} onclick={() => raw = true}>Composed .cfg</button></div><Button size="sm" variant="ghost" onclick={download}>Download</Button></div>
{#if raw}<textarea class="composed" readonly aria-label="Composed config" value={inspection.text} rows="18"></textarea>
{:else}
<div class="filters"><input aria-label="Search effective settings" bind:value={query} placeholder="Search settings or sources…" /><label class="check"><input type="checkbox" bind:checked={onlyOverrides} /> Overrides ({overlaps})</label></div>
<div class="settings">
{#each settings as setting (setting.key)}
<details class="setting"><summary><div class="setting-main"><code>{setting.key}</code><strong>{setting.value}</strong></div><span class="source-label">{label(setting.source)} {#if setting.history.length > 1}<b>{setting.history.length - 1} overridden</b>{/if}</span></summary>
<ol>{#each setting.history as entry, index}<li class:winner={index === setting.history.length - 1}><div><span>{label(entry.source)}</span><small>{entry.source.kind === 'map' ? 'Automatic' : `Line ${entry.line}`} · {index === setting.history.length - 1 ? 'Final value' : 'Overridden'}</small></div><code>{entry.value}</code></li>{/each}</ol>
</details>
{:else}<p class="empty">No matching settings.</p>{/each}
</div>
<p class="hint">Shows set / seta assignments and map rotation. Other commands remain visible in the composed file.</p>
{/if}
{#if inspection.warnings.length}<details class="notes"><summary>{inspection.warnings.length} configuration {inspection.warnings.length === 1 ? 'note' : 'notes'}</summary><ul>{#each inspection.warnings as warning}<li>{warning}</li>{/each}</ul></details>{/if}
{/if}
</section>
<style>
.inspector { min-width: 0; padding: var(--space-6); border: 1px solid var(--border-strong); border-radius: var(--radius-lg); background: var(--surface); }
.heading, .toolbar, .filters { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.eyebrow { font-size: var(--text-xs); color: var(--accent-primary-hover); }
h2 { margin: 5px 0 0; font-size: var(--text-lg); letter-spacing: var(--tracking-tight); }
.intro { color: var(--text-secondary); font-size: var(--text-sm); margin: 12px 0 20px; }
.server-label { display: grid; gap: 8px; font-size: var(--text-xs); color: var(--text-secondary); }
select, input:not([type='checkbox']) { width: 100%; box-sizing: border-box; background: var(--surface-sunken); color: var(--text-primary); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 10px 12px; font: var(--text-sm) var(--font-body); }
select:focus-visible, input:focus-visible, button:focus-visible, summary:focus-visible, a:focus-visible, .composed:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 2px; }
.sources { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-top: 20px; font-size: var(--text-xs); }
.sources a, .map-source { background: var(--surface-raised); border-radius: 6px; padding: 6px 9px; text-decoration: none; }
.sources a.draft { color: var(--state-warning); }
.map-source, .arrow { color: var(--text-secondary); }
.hint { color: var(--text-secondary); font-size: var(--text-xs); line-height: 1.6; }
.toolbar { border-top: 1px solid var(--border-subtle); padding-top: 12px; margin-top: 20px; }
.switch { display: flex; gap: 4px; }
button { border: 0; border-radius: 6px; background: transparent; color: var(--text-secondary); font: var(--text-sm) var(--font-body); padding: 8px 10px; cursor: pointer; }
button.active { color: var(--text-primary); background: var(--surface-raised); }
button span { color: var(--text-secondary); margin-left: 6px; }
.filters { margin: 16px 0 8px; }
.filters > input { flex: 1 1 180px; }
.check { font-size: var(--text-xs); color: var(--text-secondary); display: flex; align-items: center; gap: 6px; }
.settings { max-height: 620px; overflow: auto; }
.setting { border-bottom: 1px solid var(--border-subtle); padding: 14px 0; }
.setting summary { cursor: pointer; list-style: none; }
.setting summary::-webkit-details-marker { display: none; }
.setting-main { display: flex; gap: 12px; justify-content: space-between; flex-wrap: wrap; }
code, strong { font: var(--text-xs)/1.6 var(--font-mono); overflow-wrap: anywhere; white-space: pre-wrap; }
strong { color: var(--accent-primary-hover); }
.source-label { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; font-size: 11px; color: var(--text-secondary); margin-top: 6px; }
.source-label::before { content: '▸'; } .setting[open] .source-label::before { content: '▾'; }
b { font-weight: 400; color: var(--state-warning); }
ol { list-style: none; padding: 0 0 0 14px; margin: 16px 0 0 4px; border-left: 2px solid var(--border-strong); }
ol li { display: flex; justify-content: space-between; gap: 16px; padding: 8px 0; color: var(--text-secondary); font-size: var(--text-xs); }
ol li.winner { color: var(--text-primary); }
small { display: block; font-size: 10px; color: var(--text-secondary); margin-top: 3px; }
.composed { display: block; width: 100%; box-sizing: border-box; margin-top: 16px; padding: 20px; background: var(--surface-sunken); color: var(--text-primary); border: 0; border-radius: var(--radius-sm); min-height: 260px; max-height: 600px; resize: vertical; overflow: auto; font: var(--text-xs)/1.8 var(--font-mono); white-space: pre-wrap; overflow-wrap: anywhere; }
.notes { margin-top: 16px; color: var(--state-warning); font-size: var(--text-xs); } .notes summary { cursor: pointer; } .notes li { margin: 8px 0; }
.empty { color: var(--text-secondary); font-size: var(--text-sm); padding: 24px 0; }
.error { color: var(--state-danger); margin-top: 16px; font-size: var(--text-sm); }
@media (max-width: 600px) { .inspector { padding: 16px; } }
</style>
+215
View File
@@ -0,0 +1,215 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import SectionHeader from './primitives/SectionHeader.svelte';
import Button from './primitives/Button.svelte';
import Badge from './primitives/Badge.svelte';
import EmptyState from './primitives/EmptyState.svelte';
import ConfigTextEditor from './ConfigTextEditor.svelte';
import ConfigInspector from './ConfigInspector.svelte';
interface Version { id: number; version: number; state: 'untested' | 'tested' | 'test-failed'; uploaded_at: string; }
interface Artifact {
id: number; name: string; kind: 'baseline' | 'config'; scope_type: 'game' | 'map' | null;
scope_value: string | null; current_version_id: number | null; currentVersion: Version | null;
latestVersion: Version | null; versionCount: number; servers: string[]; runningBetaId: number | null;
}
let artifacts = $state<Artifact[]>([]);
let scopeOptions = $state<{ games: string[]; mapsByGame: Record<string, string[]> }>({ games: [], mapsByGame: {} });
let loading = $state(true);
let error = $state('');
let query = $state('');
let game = $state('');
let scope = $state('');
let status = $state('');
let view = $state<'library' | 'inspect'>('library');
let dialog: HTMLDialogElement;
let kind = $state<'baseline' | 'config'>('config');
let name = $state('');
let scopeType = $state<'game' | 'map'>('game');
let scopeGame = $state('');
let scopeMap = $state('');
let text = $state('');
let notes = $state('');
let saving = $state(false);
let createError = $state('');
const baselines = $derived(artifacts.filter(a => a.kind === 'baseline'));
const overlays = $derived(artifacts.filter(a => a.kind === 'config'));
const pending = $derived(artifacts.filter(a => a.latestVersion && a.latestVersion.id !== a.current_version_id).length);
const gameOptions = $derived([...new Set([...scopeOptions.games, ...artifacts.filter(a => a.scope_type === 'game').map(a => a.scope_value!).filter(Boolean)])].sort());
const baselineExists = $derived(kind === 'baseline' && baselines.some(a => a.scope_type === 'game' && a.scope_value === scopeGame));
const selectedScope = $derived(kind === 'baseline' || scopeType === 'game' ? scopeGame : scopeMap);
const filtered = $derived(artifacts.filter(a => {
if (query && !`${a.name} ${a.scope_value ?? ''} ${a.servers.join(' ')}`.toLowerCase().includes(query.toLowerCase())) return false;
if (game && !(a.scope_type === 'game' ? a.scope_value === game : (scopeOptions.mapsByGame[game] ?? []).includes(a.scope_value ?? ''))) return false;
if (scope && a.scope_type !== scope) return false;
if (status === 'pending' && (!a.latestVersion || a.latestVersion.id === a.current_version_id)) return false;
if (status === 'promoted' && !a.current_version_id) return false;
if (status === 'beta' && !a.runningBetaId) return false;
if (status === 'failed' && a.latestVersion?.state !== 'test-failed') return false;
return true;
}));
const filteredBaselines = $derived(filtered.filter(a => a.kind === 'baseline').sort((a, b) => (a.scope_value ?? '').localeCompare(b.scope_value ?? '')));
const filteredOverlays = $derived(filtered.filter(a => a.kind === 'config'));
async function load() {
loading = true; error = '';
try {
const responses = await Promise.all([fetch('/api/configs?kind=baseline'), fetch('/api/configs?kind=config'), fetch('/api/scope-options')]);
if (responses.some(r => !r.ok)) throw new Error('Could not load the config library. Please try again.');
const [base, overlay, scopes] = await Promise.all(responses.map(r => r.json()));
artifacts = [...base.scripts, ...overlay.scripts]; scopeOptions = scopes;
} catch (err) { error = (err as Error).message; }
finally { loading = false; }
}
onMount(load);
function openCreate(type: 'config' | 'baseline' = 'config') { kind = type; createError = ''; dialog.showModal(); }
function resetFilters() { query = ''; game = ''; scope = ''; status = ''; }
async function importFile(event: Event) {
const input = event.currentTarget as HTMLInputElement;
const file = input.files?.[0];
if (file) {
try { text = await file.text(); if (!name) name = file.name.replace(/\.[^.]+$/, ''); }
catch { createError = 'Could not read that file.'; }
}
input.value = '';
}
async function create(event: SubmitEvent) {
event.preventDefault();
if (saving || baselineExists || !name.trim() || !selectedScope || !text.trim()) return;
if (kind === 'config' && !/^\s*set\s+\S+\s+/m.test(text)) { createError = 'Add at least one set KEY VALUE line.'; return; }
saving = true; createError = '';
const form = new FormData();
form.append('name', name.trim()); form.append('kind', kind);
form.append('scopeType', kind === 'baseline' ? 'game' : scopeType); form.append('scopeValue', selectedScope);
form.append('text', text); if (notes.trim()) form.append('notes', notes.trim());
try {
const response = await fetch('/api/configs', { method: 'POST', body: form });
const body = await response.json();
if (!response.ok) throw new Error(body.error ?? 'Could not create this config.');
dialog.close(); await goto(`/configs/${body.script.id}`);
} catch (err) { createError = (err as Error).message; }
finally { saving = false; }
}
</script>
<div class="page">
<SectionHeader title="Configs" eyebrow="Fleet configuration" subtitle="Build on a baseline. Fine-tune with overlays. Know where every value comes from.">
{#snippet actions()}<Button variant="primary" onclick={() => openCreate()}>+ New config</Button>{/snippet}
</SectionHeader>
<div class="overview">
<div><span class="metric">{baselines.length}</span><span>Game baselines<small>The starting point for your servers</small></span></div>
<div><span class="metric">{overlays.length}</span><span>Scoped overlays<small>Changes for a game or map</small></span></div>
<div><span class="metric" class:pending={pending > 0}>{pending}</span><span>Awaiting promotion<small>Latest versions outside the promoted set</small></span></div>
</div>
<div class="navigation"><div class="view-switch" aria-label="Configs view"><button class:active={view === 'library'} aria-pressed={view === 'library'} onclick={() => view = 'library'}>Library</button><button class:active={view === 'inspect'} aria-pressed={view === 'inspect'} onclick={() => view = 'inspect'}>Inspect a server <span aria-hidden="true">↗</span></button></div><Button variant="ghost" size="sm" onclick={load} loading={loading}>Refresh library</Button></div>
{#if error}<div class="error" role="alert">{error} <Button size="sm" onclick={load}>Retry</Button></div>{/if}
{#if view === 'inspect'}
<ConfigInspector />
{:else}
<div class="filters">
<input class="search" aria-label="Search configs" bind:value={query} placeholder="Search configs, maps, or servers…" />
<select aria-label="Filter by game" bind:value={game}><option value="">All games</option>{#each gameOptions as option}<option value={option}>{option.toUpperCase()}</option>{/each}</select>
<select aria-label="Filter by scope" bind:value={scope}><option value="">All scopes</option><option value="game">Game</option><option value="map">Map</option></select>
<select aria-label="Filter by status" bind:value={status}><option value="">All statuses</option><option value="pending">Awaiting promotion</option><option value="promoted">Has promoted version</option><option value="beta">Active beta</option><option value="failed">Test failed</option></select>
{#if query || game || scope || status}<Button size="sm" variant="ghost" onclick={resetFilters}>Clear</Button>{/if}
</div>
{#if loading}<div class="loading" role="status">Loading your config library…</div>
{:else if !artifacts.length && !error}
<div class="welcome"><span class="eyebrow">Start with a foundation</span><h2>Your fleet, configured your way.</h2><p>Add a baseline for each game, then layer on small, focused changes for a game or map. Every change gets its own version.</p><Button variant="primary" onclick={() => openCreate('baseline')}>Create your first baseline</Button></div>
{:else if !filtered.length && !error}<EmptyState title="No matching configs" description="Try a different search or clear your filters." />
{:else}
{#if filteredBaselines.length}
<section class="section"><div class="section-heading"><h2>Baselines <span>{filteredBaselines.length}</span></h2><p>One foundation per game</p></div>
<div class="baseline-grid">{#each filteredBaselines as artifact (artifact.id)}
<a class="baseline-card" href="/configs/{artifact.id}"><div class="card-top"><span class="game-label">{artifact.scope_value?.toUpperCase() ?? 'UNSCOPED'}</span><span class="open-arrow" aria-hidden="true">↗</span></div><h3>{artifact.name}</h3><div class="versions">{@render versionBadges(artifact)}</div><div class="card-footer"><span>{artifact.servers.length} matching server{artifact.servers.length === 1 ? '' : 's'}</span><span>{artifact.versionCount} version{artifact.versionCount === 1 ? '' : 's'}</span></div></a>
{/each}</div>
</section>
{/if}
{#if filteredOverlays.length}
<section class="section"><div class="section-heading"><h2>Overlays <span>{filteredOverlays.length}</span></h2><p>Small changes with a clear scope</p></div>
<div class="overlay-list">{#each filteredOverlays as artifact (artifact.id)}
<a class="overlay-row" href="/configs/{artifact.id}"><div class="overlay-main"><span class="overlay-icon" aria-hidden="true">≋</span><div><h3>{artifact.name}</h3><span class="scope-label">{artifact.scope_type ?? 'Unscoped'}{artifact.scope_value ? ` · ${artifact.scope_value}` : ''}</span></div></div><div class="versions">{@render versionBadges(artifact)}</div><span class="servers">{artifact.servers.length} server{artifact.servers.length === 1 ? '' : 's'}</span><span class="open-arrow" aria-hidden="true">↗</span></a>
{/each}</div>
</section>
{/if}
{/if}
{#if artifacts.length}<p class="footnote">Choose a config to edit and preview a draft, or inspect a server to trace its promoted settings.</p>{/if}
{/if}
</div>
{#snippet versionBadges(artifact: Artifact)}
{#if artifact.currentVersion}<Badge variant="online" size="sm">v{artifact.currentVersion.version} promoted</Badge>{:else}<Badge variant="neutral" size="sm">Never promoted</Badge>{/if}
{#if artifact.latestVersion && artifact.latestVersion.id !== artifact.current_version_id}<Badge variant={artifact.latestVersion.state === 'test-failed' ? 'danger' : artifact.latestVersion.state === 'tested' ? 'online' : 'neutral'} size="sm">v{artifact.latestVersion.version} {artifact.latestVersion.state.replace('-', ' ')}</Badge>{/if}
{#if artifact.runningBetaId}<Badge variant="warning" size="sm">β{artifact.runningBetaId} active</Badge>{/if}
{/snippet}
<dialog bind:this={dialog} aria-labelledby="new-config-title" oncancel={event => { if (saving) event.preventDefault(); }}>
<form onsubmit={create}>
<div class="dialog-heading"><div><span class="eyebrow">Create version 1</span><h2 id="new-config-title">New config</h2></div><Button variant="ghost" onclick={() => dialog.close()} disabled={saving} ariaLabel="Close new config">✕</Button></div>
<div class="kind-picker"><button type="button" class:active={kind === 'config'} aria-pressed={kind === 'config'} onclick={() => kind = 'config'} disabled={saving}><strong>Overlay</strong><span>Override selected settings</span></button><button type="button" class:active={kind === 'baseline'} aria-pressed={kind === 'baseline'} onclick={() => kind = 'baseline'} disabled={saving}><strong>Baseline</strong><span>A complete config for a game</span></button></div>
<div class="form-grid"><label>Name<input bind:value={name} placeholder={kind === 'config' ? 'No AFK timeout' : 'T5 baseline'} required disabled={saving} /></label><label>Game<select aria-label="Game" bind:value={scopeGame} onchange={() => scopeMap = ''} required disabled={saving}><option value="" disabled>Choose a game</option>{#each gameOptions as option}<option value={option}>{option.toUpperCase()}</option>{/each}</select></label>
{#if kind === 'config'}<label>Apply to<select aria-label="Apply to" bind:value={scopeType} disabled={saving}><option value="game">Whole game</option><option value="map">One map</option></select></label>{#if scopeType === 'map'}<label>Map<select aria-label="Map" bind:value={scopeMap} required disabled={saving || !scopeGame}><option value="" disabled>Choose a map</option>{#each scopeOptions.mapsByGame[scopeGame] ?? [] as option}<option value={option}>{option}</option>{/each}</select></label>{/if}{/if}
</div>
{#if baselineExists}<p class="warning">This game already has a baseline. Open it from the library to save a new version.</p>{/if}
{#if !gameOptions.length}<p class="warning">Register a server first to make its game and map available here.</p>{/if}
<label class="import">Start from a file <input type="file" accept=".cfg,text/plain" onchange={importFile} disabled={saving} /></label>
<ConfigTextEditor bind:text overlay={kind === 'config'} disabled={saving} />
<label class="notes">Notes<input bind:value={notes} placeholder="What is this config for?" disabled={saving} /></label>
{#if createError}<div class="error" role="alert">{createError}</div>{/if}
<div class="dialog-actions"><p>Saved as untested. Test in a beta before promotion.</p><Button variant="ghost" onclick={() => dialog.close()} disabled={saving}>Cancel</Button><Button type="submit" variant="primary" loading={saving} disabled={baselineExists || !name.trim() || !selectedScope || !text.trim()}>Create {kind === 'baseline' ? 'baseline' : 'overlay'}</Button></div>
</form>
</dialog>
<style>
.page { display: flex; flex-direction: column; gap: var(--space-6); }
.overview { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); background: var(--surface); }
.overview > div { display: flex; align-items: center; gap: 18px; padding: 24px; } .overview > div + div { border-left: 1px solid var(--border-subtle); }
.metric { font-size: 32px; font-weight: 600; letter-spacing: -.04em; } .metric.pending { color: var(--state-warning); }
.overview > div > span:last-child { font-size: var(--text-sm); } small { display: block; color: var(--text-secondary); font-size: 11px; margin-top: 5px; }
.navigation, .view-switch, .section-heading, .card-top, .card-footer, .overlay-row, .overlay-main, .versions { display: flex; align-items: center; gap: 12px; }
.navigation, .section-heading, .card-top, .card-footer { justify-content: space-between; }
.navigation { border-bottom: 1px solid var(--border-subtle); padding-bottom: 12px; }
.view-switch { gap: 4px; }
button { background: transparent; color: var(--text-secondary); font: var(--text-sm) var(--font-body); border: 1px solid transparent; border-radius: var(--radius-sm); padding: 10px 14px; cursor: pointer; }
button.active { color: var(--text-primary); background: var(--surface-raised); } button span { margin-left: 8px; }
button:disabled { opacity: .5; cursor: default; }
button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 3px; }
.filters { display: flex; flex-wrap: wrap; gap: 10px; } .search { flex: 1 1 260px; }
input, select { box-sizing: border-box; min-width: 0; background: var(--surface-sunken); color: var(--text-primary); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 11px 12px; font: var(--text-sm) var(--font-body); }
.section { display: flex; flex-direction: column; gap: 14px; }
h2 { font-size: var(--text-md); margin: 0; letter-spacing: var(--tracking-tight); } h2 span { font-size: var(--text-xs); color: var(--text-secondary); font-weight: 400; margin-left: 8px; }
.section-heading p { margin: 0; font-size: var(--text-xs); color: var(--text-secondary); }
.baseline-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr)); gap: 16px; }
.baseline-card { padding: 24px; background: linear-gradient(140deg, color-mix(in oklab, var(--accent-primary) 7%, var(--surface)), var(--surface)); border: 1px solid var(--border-strong); border-radius: var(--radius-lg); text-decoration: none; color: var(--text-primary); transition: border-color var(--dur-fast); }
.baseline-card:hover, .overlay-row:hover { border-color: var(--accent-primary); }
.game-label { font: 600 var(--text-xs) var(--font-mono); letter-spacing: .08em; color: var(--accent-primary-hover); }
.open-arrow { color: var(--text-secondary); }
h3 { font-size: var(--text-md); margin: 16px 0; font-weight: 600; } .versions { flex-wrap: wrap; gap: 6px; }
.card-footer { border-top: 1px solid var(--border-subtle); padding-top: 16px; margin-top: 24px; font-size: var(--text-xs); color: var(--text-secondary); }
.overlay-list { display: flex; flex-direction: column; gap: 8px; }
.overlay-row { text-decoration: none; color: var(--text-primary); background: var(--surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: 18px 20px; }
.overlay-main { flex: 1; min-width: 0; } .overlay-main h3 { margin: 0 0 5px; font-size: var(--text-sm); }
.overlay-icon { display: grid; place-items: center; width: 36px; height: 36px; flex-shrink: 0; border-radius: 8px; background: var(--surface-raised); color: var(--accent-primary-hover); font-size: 24px; }
.scope-label, .servers { color: var(--text-secondary); font-size: var(--text-xs); } .servers { min-width: 80px; text-align: right; }
.footnote { margin: 0; color: var(--text-secondary); font-size: var(--text-xs); }
.loading { padding: 60px 0; text-align: center; color: var(--text-secondary); }
.welcome { padding: 56px 32px; border-radius: var(--radius-lg); background: var(--surface); border: 1px solid var(--border-strong); text-align: center; }
.welcome h2 { margin-top: 12px; font-size: var(--text-2xl); } .welcome p { color: var(--text-secondary); max-width: 540px; margin: 16px auto 24px; }
.eyebrow { color: var(--accent-primary-hover); font-size: var(--text-xs); }
.error { color: var(--state-danger); padding: 12px; background: var(--surface-raised); border-radius: var(--radius-sm); font-size: var(--text-sm); }
.warning { color: var(--state-warning); font-size: var(--text-sm); }
dialog { box-sizing: border-box; width: min(860px, calc(100vw - 32px)); padding: 0; background: var(--surface); color: var(--text-primary); border: 1px solid var(--border-strong); border-radius: var(--radius-lg); box-shadow: var(--shadow-raised); }
dialog::backdrop { background: rgb(0 0 0 / .7); backdrop-filter: blur(4px); }
form { padding: 28px; display: flex; flex-direction: column; gap: 20px; }
.dialog-heading, .dialog-actions { display: flex; align-items: center; justify-content: space-between; gap: 12px; } .dialog-heading h2 { margin-top: 6px; font-size: var(--text-xl); }
.kind-picker { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.kind-picker button { padding: 16px; border: 1px solid var(--border-strong); text-align: left; }
.kind-picker button.active { border-color: var(--accent-primary); } .kind-picker span { display: block; margin: 6px 0 0; font-size: var(--text-xs); color: var(--text-secondary); }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
label { display: grid; gap: 8px; font-size: var(--text-xs); color: var(--text-secondary); }
.import { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; } .import input { flex: 1; }
.dialog-actions { flex-wrap: wrap; } .dialog-actions p { flex: 1 1 240px; font-size: var(--text-xs); color: var(--text-secondary); }
@media (max-width: 900px) { .overview > div { padding: 16px; gap: 12px; } .overview small { display: none; } .overlay-row { flex-wrap: wrap; } .overlay-main { flex-basis: 50%; } }
@media (max-width: 600px) { .overview { grid-template-columns: 1fr; } .overview > div + div { border-left: 0; border-top: 1px solid var(--border-subtle); } .overview > div { padding: 12px 16px; } .metric { font-size: 24px; min-width: 30px; } .overview small { display: block; } .filters select { flex: 1; } .section-heading p { display: none; } .overlay-row > .versions { order: 3; width: 100%; } .servers { min-width: auto; } .overlay-row > .open-arrow { display: none; } .form-grid { grid-template-columns: 1fr; } form { padding: 18px; } .navigation { gap: 4px; flex-wrap: wrap; } }
</style>
@@ -0,0 +1,60 @@
<script lang="ts">
import { changeConfigValue, configLines, configWarnings } from '$lib/config-workbench.js';
let { text = $bindable(''), overlay = false, disabled = false }: { text?: string; overlay?: boolean; disabled?: boolean } = $props();
let mode = $state<'raw' | 'settings'>('raw');
let search = $state('');
const entries = $derived(configLines(text));
const visible = $derived(entries.filter(e => `${e.key} ${e.value}`.toLowerCase().includes(search.toLowerCase())));
const warnings = $derived(configWarnings(text, overlay));
</script>
<div class="editor">
<div class="toolbar">
<div class="switch" aria-label="Editor view">
<button type="button" class:active={mode === 'raw'} aria-pressed={mode === 'raw'} onclick={() => mode = 'raw'}>Raw .cfg</button>
<button type="button" class:active={mode === 'settings'} aria-pressed={mode === 'settings'} onclick={() => mode = 'settings'}>Settings <span>{entries.length}</span></button>
</div>
<span class="hint">{text.split(/\r?\n/).length} lines</span>
</div>
{#if mode === 'raw'}
<textarea aria-label="Config contents" bind:value={text} {disabled} spellcheck="false" rows="12" placeholder={'// Add your settings\nset g_inactivity "0"'}></textarea>
{:else}
<div class="settings">
<input class="search" aria-label="Find a setting" bind:value={search} placeholder="Find a setting…" />
<p class="hint">Edit values and inline comments. Other commands and formatting stay in the raw file.</p>
{#each visible as entry (entry.index)}
<label class="setting"><span><code>{entry.key}</code><small>Line {entry.index + 1}</small></span>
<input value={entry.value} {disabled} aria-label={`${entry.key}, line ${entry.index + 1}`} oninput={e => text = changeConfigValue(text, entry.index, e.currentTarget.value)} />
</label>
{:else}<p class="hint">{entries.length ? 'No matching settings.' : 'Add set KEY VALUE lines in the raw editor to get started.'}</p>{/each}
</div>
{/if}
</div>
{#if warnings.length}
<details class="warnings"><summary>{warnings.length} configuration {warnings.length === 1 ? 'note' : 'notes'}</summary>
<ul>{#each warnings as warning}<li>{warning}</li>{/each}</ul>
</details>
{/if}
<style>
.editor { border: 1px solid var(--border-strong); border-radius: var(--radius-md); overflow: hidden; background: var(--surface-sunken); }
.toolbar { display: flex; justify-content: space-between; align-items: center; padding: 8px 12px; gap: 12px; border-bottom: 1px solid var(--border-subtle); }
.switch { display: flex; gap: 4px; }
button { font: inherit; font-size: var(--text-sm); color: var(--text-secondary); border: 0; border-radius: var(--radius-sm); padding: 8px 12px; background: transparent; cursor: pointer; }
button.active { color: var(--text-primary); background: var(--surface-raised); }
button span { margin-left: 8px; color: var(--text-secondary); }
button:focus-visible, input:focus-visible, textarea:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: -2px; }
textarea { display: block; width: 100%; height: 360px; min-height: 240px; box-sizing: border-box; resize: vertical; border: 0; padding: var(--space-5); background: transparent; color: var(--text-primary); font: var(--text-sm)/1.8 var(--font-mono); tab-size: 4; }
textarea:disabled, input:disabled { opacity: .65; }
.settings { padding: var(--space-4); max-height: 520px; overflow: auto; }
input { box-sizing: border-box; background: var(--surface); color: var(--text-primary); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 9px 12px; font: var(--text-sm) var(--font-mono); width: 100%; }
.setting { display: grid; grid-template-columns: minmax(140px, 1fr) minmax(0, 1.4fr); align-items: center; gap: 16px; padding: 12px 0; border-top: 1px solid var(--border-subtle); }
.setting span { min-width: 0; overflow-wrap: anywhere; }
code { font: var(--text-sm) var(--font-mono); }
small { display: block; color: var(--text-secondary); font-size: 11px; margin-top: 4px; }
.hint { color: var(--text-secondary); font-size: var(--text-xs); }
.warnings { color: var(--state-warning); font-size: var(--text-sm); padding: 12px 0 0; }
summary { cursor: pointer; }
li { margin: 6px 0; }
@media (max-width: 600px) { .setting { grid-template-columns: 1fr; gap: 8px; } .toolbar > .hint { display: none; } }
</style>