Files
electric-cherry/src/lib/ui/components/ArtifactDetail.svelte
T

1083 lines
39 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { serverTitle } from '$ui/server-labels.js';
import RelativeTime from '$ui/components/RelativeTime.svelte';
import { page } from '$app/stores';
import { contextHref } from '$lib/navigation-context.js';
import { onMount } from 'svelte';
import { pollVisible } from '$ui/poll.js';
import { confirmAction } from '$ui/confirm.js';
import { fleetServers as sharedFleet } from '$ui/stores/fleet.js';
import ServerIdentity from './ServerIdentity.svelte';
import ListFooter from './primitives/ListFooter.svelte';
import StatusBadge from './primitives/StatusBadge.svelte';
import { betaSelectionHref, mapLabel } from '$lib/script-library.js';
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';
import Badge from './primitives/Badge.svelte';
import Button from './primitives/Button.svelte';
import SectionHeader from './primitives/SectionHeader.svelte';
import Breadcrumbs from './primitives/Breadcrumbs.svelte';
import EmptyState from './primitives/EmptyState.svelte';
import Field from './primitives/Field.svelte';
import Input from './primitives/Input.svelte';
import Select from './primitives/Select.svelte';
type Family = 'script' | 'config' | 'plugin';
interface Props {
id: number;
family: Family;
}
let { id, family }: Props = $props();
const basePath = $derived(`/${family}s`);
const crumbLabel = $derived(family.charAt(0).toUpperCase() + family.slice(1) + 's');
interface Artifact {
id: number;
name: string;
target_rel_path: string;
scope_type: 'game' | 'map' | null;
scope_value: string | null;
current_version_id: number | null;
kind: 'script' | 'config' | 'plugin' | 'baseline';
created_at: string;
}
interface Version {
id: number;
version: number;
sha256: string;
size: number;
release_version: string | null;
source: PluginSource | null;
notes: string | null;
state: 'untested' | 'tested' | 'test-failed';
uploaded_at: string;
}
interface ScopeOptions {
games: string[];
mapsByGame: Record<string, string[]>;
}
let artifact = $state<Artifact | null>(null);
let versions = $state<Version[]>([]);
let matchedServers = $state<string[]>([]);
let installedVersions = $state<
Array<{ server: string; version: number; versionId: number; recordedAt: string }>
>([]);
interface ScopeImpact {
before: string[];
after: string[];
added: string[];
removed: string[];
affected: string[];
composed: boolean;
detached: string[];
}
let scopeReviewOpen = $state(false);
let scopeImpact = $state<ScopeImpact | null>(null);
let scopePreviewError = $state('');
let scopePreviewLoading = $state(false);
let scopeComplete = $state<{
count: number;
composed: boolean;
detached: number;
href: string;
} | null>(null);
let runningBetaId = $state<number | null>(null);
let loading = $state(true);
let flash = $state<string | null>(null);
let scopeType = $state<'none' | 'game' | 'map'>('none');
let scopeGame = $state('');
let scopeMap = $state('');
let updatingScope = $state(false);
let scopeOptions = $state<ScopeOptions>({ games: [], mapsByGame: {} });
let versionFile = $state<File | null>(null);
let versionNotes = $state('');
let releaseVersion = $state('');
let uploadingVersion = $state(false);
let scopeDialog = $state<HTMLDialogElement | null>(null);
let uploadDialog = $state<HTMLDialogElement | null>(null);
const fleetServers = $derived($sharedFleet);
let loadError = $state('');
let limit = $state(25);
let request = 0;
const editedScopeValue = $derived.by(() => {
if (scopeType === 'game') return scopeGame;
if (scopeType === 'map') return scopeMap;
return '';
});
const currentVersion = $derived(
artifact?.current_version_id
? (versions.find((v) => v.id === artifact!.current_version_id) ?? null)
: null
);
const latestVersion = $derived(versions[0] ?? null);
$effect(() => {
const type = scopeType;
const value = editedScopeValue;
const artifactId = id;
if (!scopeReviewOpen) return;
scopeImpact = null;
scopePreviewError = '';
if (type !== 'none' && !value) {
scopePreviewLoading = false;
return;
}
const controller = new AbortController();
scopePreviewLoading = true;
void (async () => {
try {
const response = await fetch(
`/api/scripts/${artifactId}/scope?${new URLSearchParams({ type, value })}`,
{ signal: controller.signal }
);
const body = await response.json();
if (!response.ok) throw new Error(body.error ?? 'Could not preview scope.');
if (!controller.signal.aborted) scopeImpact = body;
} catch (error) {
if (!controller.signal.aborted) scopePreviewError = (error as Error).message;
} finally {
if (!controller.signal.aborted) scopePreviewLoading = false;
}
})();
return () => controller.abort();
});
function scopeLabel(s: Artifact): string {
if (!s.scope_type) return 'unscoped';
return s.scope_type === 'map'
? mapLabel(s.scope_value ?? '')
: s.scope_value === 't5'
? 'Black Ops I'
: s.scope_value === 't6'
? 'Black Ops II'
: (s.scope_value ?? 'Unscoped');
}
async function load({ seedForm = true }: { seedForm?: boolean } = {}): Promise<void> {
const requestedId = id;
const current = ++request;
try {
const [detailRes, scopeRes] = await Promise.all([
fetch('/api/scripts/' + requestedId),
seedForm ? fetch('/api/scope-options') : Promise.resolve(null)
]);
if (!detailRes.ok)
throw new Error(
detailRes.status === 404
? 'This file no longer exists.'
: 'Could not refresh file details.'
);
const body = await detailRes.json();
const scopes = scopeRes?.ok ? await scopeRes.json() : null;
if (requestedId !== id || current !== request) return;
artifact = body.script;
versions = body.versions;
matchedServers = body.matchedServers;
runningBetaId = body.runningBetaId;
installedVersions = body.installedVersions ?? [];
if (scopes) scopeOptions = scopes;
if (seedForm) seedScopeForm();
loadError = '';
} catch (error) {
if (requestedId === id && current === request) loadError = (error as Error).message;
} finally {
if (requestedId === id && current === request) loading = false;
}
}
function seedScopeForm(): void {
if (!artifact) return;
scopeType = artifact.scope_type ?? 'none';
if (scopeType === 'game') {
scopeGame = artifact.scope_value ?? '';
scopeMap = '';
} else if (scopeType === 'map') {
scopeMap = artifact.scope_value ?? '';
const owner = fleetServers.find((sv) => sv.map === scopeMap);
scopeGame =
owner?.game ??
Object.keys(scopeOptions.mapsByGame).find((game) =>
scopeOptions.mapsByGame[game].includes(scopeMap)
) ??
'';
} else {
scopeGame = '';
scopeMap = '';
}
}
$effect(() => {
void id;
loading = true;
artifact = null;
limit = 25;
void load();
});
onMount(() => pollVisible(() => load({ seedForm: false }), 10000, false));
function openScopeModal(): void {
if (runningBetaId !== null) return;
seedScopeForm();
flash = null;
scopeReviewOpen = true;
scopeDialog?.showModal();
}
function cancelScopeModal(): void {
seedScopeForm();
scopeReviewOpen = false;
scopeDialog?.close();
}
function openUploadModal(): void {
if (runningBetaId !== null) return;
releaseVersion = '';
versionFile = null;
versionNotes = '';
flash = null;
uploadDialog?.showModal();
}
function cancelUploadModal(): void {
versionFile = null;
versionNotes = '';
uploadDialog?.close();
}
function onDialogClick(dialog: HTMLDialogElement | null, e: MouseEvent): void {
if (!dialog) return;
if (e.target === dialog && !uploadingVersion && !updatingScope) {
const bounds = dialog.getBoundingClientRect();
if (
e.clientX < bounds.left ||
e.clientX > bounds.right ||
e.clientY < bounds.top ||
e.clientY > bounds.bottom
)
dialog.close();
}
}
function pickVersionFile(e: Event): void {
const t = e.target as HTMLInputElement;
versionFile = t.files?.[0] ?? null;
}
async function uploadVersion(e: Event): Promise<void> {
e.preventDefault();
if (!versionFile || uploadingVersion) return;
uploadingVersion = true;
flash = null;
const form = new FormData();
form.append('content', versionFile);
if (family === 'plugin') form.append('releaseVersion', releaseVersion.trim());
if (versionNotes.trim()) form.append('notes', versionNotes.trim());
try {
const res = await fetch(`/api/scripts/${id}/versions`, { method: 'POST', body: form });
const body = (await res.json()) as {
ok: boolean;
error?: string;
betaRequired?: boolean;
};
if (!res.ok || !body.ok) {
flash = body.error ?? `HTTP ${res.status}`;
return;
}
versionFile = null;
versionNotes = '';
uploadDialog?.close();
await load();
} catch (error) {
flash = (error as Error).message;
} finally {
uploadingVersion = false;
}
}
async function saveScope(e: Event): Promise<void> {
e.preventDefault();
if (updatingScope || !scopeImpact || scopePreviewLoading) return;
if (scopeType !== 'none' && !editedScopeValue) {
flash = `Pick a ${scopeType}`;
return;
}
updatingScope = true;
flash = null;
try {
const res = await fetch(`/api/scripts/${id}/scope`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
scopeType: scopeType === 'none' ? null : scopeType,
scopeValue: scopeType === 'none' ? null : editedScopeValue
})
});
const body = (await res.json()) as {
ok: boolean;
error?: string;
kind?: string;
detached?: string[];
};
if (!res.ok || !body.ok) {
flash = body.error ?? `HTTP ${res.status}`;
return;
}
scopeComplete = {
count: scopeImpact.affected.length,
composed: scopeImpact.composed,
detached: body.detached?.length ?? 0,
href:
scopeImpact.affected.length === 1
? '/maintenance?kind=drift&server=' +
encodeURIComponent(scopeImpact.affected[0])
: '/maintenance?kind=drift'
};
scopeReviewOpen = false;
scopeDialog?.close();
await load();
} catch (error) {
flash = (error as Error).message;
} finally {
updatingScope = false;
}
}
async function deleteArtifact(): Promise<void> {
if (
!(await confirmAction({
title: 'Delete ' + family + '?',
message: 'Delete ' + (artifact?.name ?? 'this file') + ' and its saved versions?',
confirmLabel: 'Delete',
danger: true
}))
)
return;
try {
const response = await fetch('/api/scripts/' + id, { method: 'DELETE' });
const body = await response.json();
if (!response.ok || !body.ok) throw new Error(body.error ?? 'Could not delete file.');
await goto(basePath);
} catch (error) {
flash = (error as Error).message;
}
}
let gameOptions = $derived([
{ value: '', label: '— Pick a game —', disabled: true },
...scopeOptions.games.map((g) => ({ value: g, label: g }))
]);
let mapOptions = $derived([
{ value: '', label: '— Pick a map —', disabled: true },
...(scopeOptions.mapsByGame[scopeGame] ?? []).map((m) => ({ value: m, label: m }))
]);
let scopeTypeOptions = [
{ value: 'none', label: 'Unscoped' },
{ value: 'game', label: 'Game' },
{ value: 'map', label: 'Map' }
];
</script>
<svelte:head><title>{artifact?.name ?? crumbLabel} · Electric Cherry</title></svelte:head>
{#if loadError}<div class="ui-error" role="alert">
{loadError}<Button size="sm" onclick={() => load()}>Retry</Button>
</div>{/if}
{#if loading}
<div class="loading">Loading…</div>
{:else if !artifact && !loadError}
<EmptyState title="Not found" description="This {family} may have been deleted." />
{:else if artifact}
{@const a = artifact}
<div class="page">
<SectionHeader title={a.name} subtitle={a.target_rel_path}>
{#snippet crumbs()}
<Breadcrumbs items={[{ label: crumbLabel, href: basePath }, { label: a.name }]} />
{/snippet}
</SectionHeader>
{#if scopeComplete}<div class="scope-complete" role="status">
<strong>Scope saved.</strong>
{scopeComplete.composed
? `Intended configuration may change on ${scopeComplete.count} eligible servers.`
: scopeComplete.detached
? `Inventory tracking detached on ${scopeComplete.detached} servers; live files remain.`
: `${scopeComplete.count} servers are in the old or new scope.`} Maintenance detects
resulting drift and rebuilds eligible servers when automation is enabled.
<a href={scopeComplete.href}>View drift recovery →</a>
</div>{/if}
{#if latestVersion}<Card padding="lg"
><div class="release-summary">
<div>
<h2>Latest {artifactVersionLabel(latestVersion)}</h2>
<StatusBadge
state={latestVersion.id === a.current_version_id
? 'promoted'
: latestVersion.state}
/>
<p>{latestVersion.notes || 'No version notes provided.'}</p>
</div>
{#if runningBetaId !== null}<Button
variant="primary"
href={contextHref(`/betas/${runningBetaId}`, $page.url)}
>View active beta</Button
>{:else if latestVersion.id !== a.current_version_id}<Button
variant="primary"
href={contextHref(
betaSelectionHref([{ scriptId: id, versionId: latestVersion.id }]),
$page.url
)}>Test latest version</Button
>{:else}<span class="dim">Latest version is promoted</span>{/if}
</div></Card
>{/if}
{#if runningBetaId}
<div class="warn-banner">
Beta <a href={contextHref(`/betas/${runningBetaId}`, $page.url)}
><strong>#{runningBetaId}</strong></a
>
is running for this {family}. Rollback or promote it before making changes.
</div>
{/if}
<Card padding="lg">
<div class="panel-head">
<h2>Details</h2>
<div class="head-actions">
<Button
variant="secondary"
size="sm"
onclick={openScopeModal}
disabled={runningBetaId !== null}
>
Edit scope
</Button>
<Button
variant="secondary"
size="sm"
onclick={openUploadModal}
disabled={runningBetaId !== null}
>
Upload new version
</Button>
</div>
</div>
<details class="file-settings">
<summary>Target file, scope, and eligible servers</summary>
<div class="kv-grid">
<div class="kv">
<span class="k">Target file</span>
<span class="v mono">{a.target_rel_path}</span>
</div>
<div class="kv">
<span class="k">Scope</span>
<span class="v">
<Badge
variant={a.scope_type === 'game'
? 'info'
: a.scope_type === 'map'
? 'warning'
: 'neutral'}
size="sm"
>
{scopeLabel(a)}
</Badge>
</span>
</div>
<div class="kv">
<span class="k">Eligible servers</span>
<span class="v mono">
{#if !a.scope_type}
<span class="dim">Unscoped — deploy from a beta</span>
{:else if matchedServers.length === 0}
<span class="warn">No matching servers</span>
{:else}
{#each matchedServers as name}<a
class="matched-server"
href={'/servers/' + encodeURIComponent(name)}
><ServerIdentity {name} /></a
>{/each}
{/if}
</span>
</div>
<div class="kv">
<span class="k">Promoted version</span>
<span class="v mono">
{#if currentVersion}
{artifactVersionLabel(currentVersion)} · {currentVersion.sha256.slice(
0,
8
)}
<Badge variant="online" size="sm">Promoted</Badge>
{:else}
<span class="dim">Never promoted — test a version first</span>
{/if}
</span>
</div>
</div>
</details>
</Card>
{#if installedVersions.length}<details class="installed">
<summary>Recorded installed files · {installedVersions.length} servers</summary>
<p>
Last recorded file writes, not a verification of the currently running process.
</p>
<ul>
{#each installedVersions as installed}<li>
<a href={'/servers/' + encodeURIComponent(installed.server)}
>{serverTitle(installed.server)}</a
>
· v{installed.version} · <RelativeTime value={installed.recordedAt} />
</li>{/each}
</ul>
</details>{:else if family !== 'config'}<p class="dim">
No installed file versions recorded. Eligibility does not mean a file is installed.
</p>{/if}
{#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>
<Badge variant="neutral" size="sm">{versions.length}</Badge>
</div>
{#if versions.length === 0}
<EmptyState
title="No versions yet"
description="Upload a first version to get started."
/>
{:else}
<div class="version-list">
{#each versions.slice(0, limit) as v (v.id)}
<div class="version-row" class:current={a.current_version_id === v.id}>
<span class="v-num">{artifactVersionLabel(v)}</span>
<span class="v-notes"
>{v.notes ?? ''}
{#if v.source}<a
href={v.source.release_url}
target="_blank"
rel="noreferrer">GitHub · {v.source.asset_name} ↗</a
>{/if}
</span>
<span class="v-when"><RelativeTime value={v.uploaded_at} /></span>
{#if a.current_version_id === v.id}
<Badge variant="online" size="sm">Promoted</Badge>
{:else}
<StatusBadge state={v.state} size="sm" />
{#if runningBetaId === null}<Button
size="sm"
href={contextHref(
betaSelectionHref([{ scriptId: id, versionId: v.id }]),
$page.url
)}>Test version</Button
>{/if}
{/if}
<details class="version-technical">
<summary>File details</summary><span>SHA-256: {v.sha256}</span><span
>Size: {v.size.toLocaleString()} bytes</span
>
</details>
</div>
{/each}
</div>
{/if}
<ListFooter total={versions.length} bind:limit label="versions" />
</Card>
{#if family === 'plugin'}
<div id="github-releases">
<PluginGithubImport
pluginId={id}
targetPath={a.target_rel_path}
disabled={runningBetaId !== null}
onimport={() => load()}
/>
</div>
{/if}
<Card padding="lg">
<div class="panel-head"><h2>Danger zone</h2></div>
<Button variant="danger" onclick={deleteArtifact}>Delete {family}</Button>
</Card>
{#if flash}<div class="alert" role="alert">{flash}</div>{/if}
<!-- Scope dialog -->
<dialog
aria-label="Edit scope"
onclose={() => (scopeReviewOpen = false)}
oncancel={(event) => {
if (updatingScope) event.preventDefault();
}}
bind:this={scopeDialog}
onclick={(e) => onDialogClick(scopeDialog, e)}
>
<form class="dlg" onsubmit={saveScope}>
<header class="dlg-head">
<h3>Edit scope</h3>
<button
type="button"
class="close"
disabled={updatingScope}
onclick={cancelScopeModal}
aria-label="Close">×</button
>
</header>
<Field label="Scope">
<div class="scope-row">
<Select
ariaLabel="Scope type"
bind:value={scopeType}
options={scopeTypeOptions}
disabled={updatingScope}
/>
{#if scopeType === 'game' || scopeType === 'map'}
<Select
ariaLabel="Scope game"
bind:value={scopeGame}
onchange={() => (scopeMap = '')}
options={gameOptions}
disabled={updatingScope}
/>
{/if}
{#if scopeType === 'map'}
<Select
ariaLabel="Scope map"
bind:value={scopeMap}
options={mapOptions}
disabled={updatingScope || !scopeGame}
/>
{/if}
</div>
</Field>
{#if scopePreviewLoading}<p role="status">
Reviewing eligible servers…
</p>{:else if scopePreviewError}<p role="alert">
{scopePreviewError}
</p>{:else if scopeImpact}<section
class="scope-impact"
aria-label="Scope change review"
>
<strong
>{scopeImpact.after.length} eligible · {scopeImpact.added.length} added ·
{scopeImpact.removed.length} removed</strong
>{#if scopeImpact.added.length}<p>
Added: {scopeImpact.added.join(', ')}
</p>{/if}{#if scopeImpact.removed.length}<p>
Removed: {scopeImpact.removed.join(', ')}
</p>{/if}
<p>
{scopeImpact.composed
? 'This changes intended configuration composition.'
: scopeType === 'none'
? `${scopeImpact.detached.length} recorded installations will become untracked. Maintenance will detect orphaned files as drift.`
: 'Scope describes eligibility for promoted files.'}
</p>
<p>
When automation is enabled, Maintenance rebuilds servers with drift
after verifying a safe server state. Rebuilds delete the existing
instance and all of its files.
</p>
</section>{/if}
<div class="dlg-actions">
<Button variant="ghost" onclick={cancelScopeModal} disabled={updatingScope}
>Cancel</Button
>
<Button
type="submit"
variant="primary"
disabled={updatingScope || scopePreviewLoading || !scopeImpact}
loading={updatingScope}
>
Save reviewed scope
</Button>
</div>
{#if flash}<div class="alert alert-danger" role="alert">{flash}</div>{/if}
</form>
</dialog>
<!-- Upload dialog -->
<dialog
aria-label="Upload new version"
oncancel={(event) => {
if (uploadingVersion) event.preventDefault();
}}
bind:this={uploadDialog}
onclick={(e) => onDialogClick(uploadDialog, e)}
>
<form class="dlg" onsubmit={uploadVersion}>
<header class="dlg-head">
<h3>Upload new version</h3>
<button
type="button"
class="close"
disabled={uploadingVersion}
onclick={cancelUploadModal}
aria-label="Close">×</button
>
</header>
<Field label="File">
<Input
type="file"
accept={family === 'plugin' ? '.dll' : undefined}
onchange={pickVersionFile}
disabled={uploadingVersion}
/>
</Field>
{#if family === 'plugin'}
<Field
label="Release version"
hint="Optional. Use the publisher’s version or release tag, such as v3.1.0."
>
<Input
bind:value={releaseVersion}
placeholder="v3.1.0"
disabled={uploadingVersion}
/>
</Field>
{/if}
<Field label="Notes" hint="Optional. What changed since the previous version.">
<Input
bind:value={versionNotes}
placeholder="what changed"
disabled={uploadingVersion}
/>
</Field>
<div class="dlg-actions">
<Button variant="ghost" onclick={cancelUploadModal} disabled={uploadingVersion}
>Cancel</Button
>
<Button
type="submit"
variant="primary"
disabled={uploadingVersion || !versionFile}
loading={uploadingVersion}
>
Upload version
</Button>
</div>
{#if flash}<div class="alert alert-danger" role="alert">{flash}</div>{/if}
</form>
</dialog>
</div>
{/if}
<style>
.page {
display: flex;
flex-direction: column;
gap: var(--space-6);
}
.loading {
text-align: center;
padding: var(--space-16);
color: var(--text-tertiary);
}
.warn-banner {
padding: var(--space-3) var(--space-4);
background: rgba(255, 176, 32, 0.06);
border: 1px solid rgba(255, 176, 32, 0.28);
border-radius: var(--radius-md);
color: var(--text-primary);
font-size: var(--text-sm);
}
.warn-banner a {
color: var(--state-warning);
text-decoration: underline;
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
margin-bottom: var(--space-4);
}
.panel-head h2 {
margin: 0;
font-family: var(--font-body);
font-size: var(--text-md);
font-weight: var(--weight-semibold);
color: var(--text-primary);
}
.head-actions {
display: flex;
gap: var(--space-2);
}
.kv-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(240px, 100%), 1fr));
gap: var(--space-3);
}
.kv {
display: flex;
flex-direction: column;
gap: 4px;
padding: var(--space-3) var(--space-4);
background: var(--surface-sunken);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
}
.k {
font-size: var(--text-xs);
text-transform: uppercase;
letter-spacing: var(--tracking-label);
color: var(--text-tertiary);
font-weight: var(--weight-semibold);
}
.v {
font-size: var(--text-sm);
color: var(--text-primary);
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
word-break: break-word;
}
.mono {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
}
.dim {
color: var(--text-tertiary);
}
.warn {
color: var(--state-warning);
}
.v a {
color: var(--accent-primary-hover);
}
.version-list {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.version-row {
display: grid;
grid-template-columns: minmax(100px, auto) minmax(0, 1fr) auto auto auto;
gap: var(--space-3);
align-items: center;
padding: var(--space-2) var(--space-3);
background: color-mix(in oklab, var(--surface) 88%, transparent);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
font-family: var(--font-mono);
font-size: var(--text-xs);
}
.version-row.current {
border-color: rgba(124, 212, 255, 0.35);
background: rgba(124, 212, 255, 0.04);
}
.v-num {
color: var(--text-primary);
font-weight: var(--weight-semibold);
overflow-wrap: anywhere;
}
.v-when {
color: var(--text-tertiary);
font-variant-numeric: tabular-nums;
}
.version-technical {
grid-column: 1 / -1;
color: var(--text-secondary);
overflow-wrap: anywhere;
}
.version-technical span {
display: block;
margin-top: 8px;
}
.scope-complete {
padding: 16px;
border: 1px solid var(--state-online);
border-radius: var(--radius-md);
font-size: var(--text-sm);
}
.release-summary {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 16px;
}
.release-summary h2 {
font-size: var(--text-lg);
margin: 0 0 8px;
}
.release-summary p,
.installed p,
.scope-impact p {
color: var(--text-secondary);
font-size: var(--text-sm);
overflow-wrap: anywhere;
}
.scope-impact {
border: 1px solid var(--border-strong);
border-radius: var(--radius-md);
padding: 12px;
font-size: var(--text-sm);
}
.installed {
font-size: var(--text-sm);
}
.file-settings {
font-size: var(--text-sm);
}
.file-settings .kv-grid {
margin-top: 16px;
}
.v-notes {
color: var(--text-secondary);
font-family: var(--font-body);
font-size: var(--text-sm);
}
.v-notes {
white-space: pre-wrap;
overflow-wrap: anywhere;
max-height: 160px;
overflow-y: auto;
}
.v-notes a {
display: block;
color: var(--accent-primary-hover);
margin-top: var(--space-2);
}
.alert {
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-md);
color: var(--state-danger);
background: rgba(255, 86, 112, 0.06);
border: 1px solid rgba(255, 86, 112, 0.28);
font-family: var(--font-mono);
font-size: var(--text-xs);
}
.alert-danger {
background: rgba(255, 86, 112, 0.08);
}
/* Dialogs */
dialog {
background: var(--surface);
color: var(--text-primary);
border: 1px solid var(--border-strong);
border-radius: var(--radius-lg);
padding: 0;
max-width: 560px;
width: calc(100vw - 40px);
box-shadow: var(--shadow-raised);
}
dialog[open] {
animation: dlg-in var(--dur-base) var(--ease-standard);
}
@keyframes dlg-in {
from {
opacity: 0;
transform: translateY(6px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
dialog::backdrop {
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
.dlg {
display: flex;
flex-direction: column;
gap: var(--space-4);
padding: var(--space-6);
}
.dlg-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.dlg h3 {
margin: 0;
font-family: var(--font-body);
font-size: var(--text-md);
font-weight: var(--weight-semibold);
color: var(--text-primary);
}
.close {
appearance: none;
background: transparent;
border: none;
color: var(--text-tertiary);
font-size: var(--text-xl);
line-height: 1;
cursor: pointer;
padding: 4px 8px;
border-radius: var(--radius-sm);
}
.close:hover {
color: var(--text-primary);
background: var(--surface-raised);
}
.scope-row {
display: flex;
gap: var(--space-2);
flex-wrap: wrap;
}
.scope-row :global(.wrap) {
flex: 1 1 140px;
min-width: 140px;
}
.dlg-actions {
display: flex;
gap: var(--space-2);
justify-content: flex-end;
}
@media (max-width: 1000px) {
.version-row {
grid-template-columns: 1fr;
}
}
@media (max-width: 720px) {
.panel-head,
.head-actions,
.dlg-actions {
flex-wrap: wrap;
}
.dlg {
padding: var(--space-4);
}
}
.matched-server {
display: block;
padding: 5px 0;
color: var(--text-primary);
text-decoration: none;
font-family: var(--font-body);
}
</style>