@@ -1,387 +1,167 @@
< script lang = "ts" >
import { onMount } from 'svelte' ;
import Card from '$ui/components/primitives/Card.svelte' ;
import Badge from '$ui/components/primitives/Badge.svelte' ;
import Button from '$ui/components/primitives/Button.svelte' ;
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte' ;
import EmptyState from '$ui/components/primitives/EmptyState.svelte' ;
import Field from '$ui/components/primitives/Field .svelte' ;
import Input from '$ui/components/primitives/Input.svelte ';
import Select from '$ui/components/primitives/Select.svelte ';
import ScriptUploadDrawer from '$ui/components/ScriptUploadDrawer .svelte' ;
import { betaSelectionHref , canTest , mapLabel , scopeLabel , scriptStatus , statusLabels , type LibraryScript , type ScriptStatus } from '$lib/script-library.js ';
import { zombieMaps } from '$lib/map-aliases.js ';
interface ScriptRow {
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' ;
latestVersion : {
id : number ; version : number ; sha256 : string ; size : number ;
state : 'untested' | 'tested' | 'test-failed' ; uploaded_at : string ;
} | null ;
versionCount : number ;
servers : string [];
runningBetaId : number | null ;
}
interface ScopeOptions { games : string []; mapsByGame : Record < string , string [] >; }
let scripts = $state < ScriptRow [] >([]);
let scripts = $state < LibraryScript [] >([]);
let loading = $state ( true );
let flash = $state < string | null >( null );
let uploading = $state ( false );
let scopeOptions = $state < ScopeOptions >({ games : [], mapsByGame : {} } );
let successBanner = $state < { name : string ; scriptId : number ; version : number } | null > ( null );
let newName = $state ( '' );
let newScopeType = $state < 'none' | 'game' | 'map' > ( 'none' );
let newScopeGame = $state ( '' ) ;
let newScopeMap = $state ( '' );
let newFile = $state < File | null >( null );
let newNotes = $state ( '' );
const scopeValue = $derived . by (() => {
if ( newScopeType === 'game' ) return newScopeGame ;
if ( newScopeType === 'map' ) return newScopeMap ;
return '' ;
});
let serverCache = $state < Array < { name : string ; game : string | null ; map : string | null }> > ([]);
const matchingServers = $derived . by (() => {
if ( newScopeType === 'none' ) return null ;
if ( ! scopeValue ) return null ;
return serverCache
. filter (( s ) => ( newScopeType === 'game' ? s.game : s.map ) === scopeValue )
. map (( s ) => s . name );
});
async function load () : Promise < void > {
const [ scriptsRes , scopeRes , serversRes ] = await Promise . all ([
fetch ( '/api/scripts?kind=script' ),
fetch ( '/api/scope-options' ),
fetch ( '/api/servers' )
]);
const body = ( await scriptsRes . json ()) as { scripts : ScriptRow [] };
scripts = body . scripts ;
scopeOptions = ( await scopeRes . json ()) as ScopeOptions ;
const sBody = ( await serversRes . json ()) as { servers : Array < { name : string ; game : string | null ; map : string | null } > };
serverCache = sBody . servers ;
loading = false ;
}
onMount ( load );
function pickFile ( e : Event ) : void {
const t = e . target as HTMLInputElement ;
newFile = t . files ? .[ 0 ] ?? null ;
if ( ! newName && newFile ) newName = newFile . name . replace ( /\.[^.]+$/ , '' );
}
async function createScript ( e : Event ) : Promise < void > {
e . preventDefault ();
if ( uploading || ! newFile || ! newName . trim ()) return ;
if ( newScopeType !== 'none' && ! scopeValue ) {
flash = `Pick a ${ newScopeType } ` ;
return ;
}
uploading = true ; flash = null ;
const form = new FormData ();
form . append ( 'name' , newName . trim ());
form . append ( 'scopeType' , newScopeType );
if ( scopeValue ) form . append ( 'scopeValue' , scopeValue );
if ( newNotes . trim ()) form . append ( 'notes' , newNotes . trim ());
form . append ( 'content' , newFile );
try {
const res = await fetch ( '/api/scripts' , { method : 'POST' , body : form });
const body = ( await res . json ()) as {
ok : boolean ; error? : string ;
script ?: { id : number ; name : string };
versionId? : number ;
};
if ( ! res . ok || ! body . ok ) {
flash = body . error ?? `HTTP ${ res . status } ` ;
return ;
}
successBanner = body . script
? { name : body.script.name , scriptId : body.script.id , version : 1 }
: null ;
newName = '' ; newScopeType = 'none' ;
newScopeGame = '' ; newScopeMap = '' ;
newNotes = '' ; newFile = null ;
await load ();
} finally {
uploading = false ;
}
}
function scopeLabel ( s : ScriptRow ) : string {
if ( ! s . scope_type ) return 'unscoped' ;
return ` ${ s . scope_type } · ${ s . scope_value ?? '?' } ` ;
}
function detailHref ( s : ScriptRow ) : string {
if ( s . kind === 'config' || s . kind === 'baseline' ) return `/configs/ ${ s . id } ` ;
if ( s . kind === 'plugin' ) return `/plugins/ ${ s . id } ` ;
return `/scripts/ ${ s . id } ` ;
}
function stateVariantFor ( s : ScriptRow ) : 'online' | 'warning' | 'danger' | 'neutral' | null {
if ( ! s . latestVersion ) return null ;
if ( s . current_version_id === null ) return 'neutral' ;
if ( s . latestVersion . id !== s . current_version_id ) {
if ( s . latestVersion . state === 'untested' ) return 'neutral' ;
if ( s . latestVersion . state === 'tested' ) return 'online' ;
if ( s . latestVersion . state === 'test-failed' ) return 'danger' ;
}
return null ;
}
function stateLabel ( s : ScriptRow ) : string {
if ( ! s . latestVersion ) return '' ;
if ( s . current_version_id === null ) return 'untested' ;
if ( s . latestVersion . id !== s . current_version_id ) {
return `v ${ s . latestVersion . version } ${ s . latestVersion . state . replace ( '-' , ' ' ) } ` ;
}
return '' ;
}
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 [ newScopeGame ] ?? []). map (( m ) => ({ value : m , label : m }))
]);
let scopeTypeOptions = [
{ value : 'none' , label : 'Unscoped' },
{ value : 'game' , label : 'Game' },
{ value : 'map' , label : 'Map' }
let error = $state ( '' );
let search = $state ( '' );
let status = $state < ScriptStatus | 'all' >( 'all' );
let game = $state ( '' );
let map = $state ( '' );
let sort = $state ( 'attention ' );
let selected = $state < number [] >([] );
let uploader : ScriptUploadDrawer ;
let dragDepth = $state ( 0 );
const tabs : Array < { value : ScriptStatus | 'all' ; label : string } > = [
{ value : 'all' , label : 'All scripts' }, { value : 'untested' , label : 'Needs testing' },
{ value : 'testing' , label : 'Beta active' }, { value : 'failed' , label : 'Test failed' },
{ value : 'tested' , label : 'Test passed' }, { value : 'promoted' , label : 'Promoted' }
];
const priority = { failed : 0 , untested : 1 , testing : 2 , tested : 3 , promoted : 4 , empty : 5 };
const maps = $derived ([... new Set ( scripts . filter ( s => s . scope_type === 'map' && ( ! game || zombieMaps ( game ). some ( m => m . canonical === s . scope_value ))). map ( s => s . scope_value ! ))]. sort ());
const visible = $derived ( scripts . filter ( s => {
const haystack = ` ${ s . name } ${ s . target_rel_path } ${ s . scope_value ?? '' } ${ scopeLabel ( s ) } ` . toLowerCase ();
return search . toLowerCase (). trim (). split ( /\s+/ ). every ( term => haystack . includes ( term ))
&& ( status === 'all' || scriptStatus ( s ) === status )
&& ( ! game || ( s . scope_type === 'game' ? s . scope_value === game : s.scope_type === 'map' && zombieMaps ( game ). some ( m => m . canonical === s . scope_value )))
&& ( ! map || s . scope_type === 'map' && s . scope_value === map );
}). sort (( a , b ) => {
if ( sort === 'recent' ) return ( b . latestVersion ? . uploaded_at ?? '' ). localeCompare ( a . latestVersion ? . uploaded_at ?? '' ) || a . name . localeCompare ( b . name );
if ( sort === 'attention' ) return priority [ scriptStatus ( a )] - priority [ scriptStatus ( b )] || a . name . localeCompare ( b . name );
return a . name . localeCompare ( b . name );
}));
const selectedScripts = $derived ( scripts . filter ( s => selected . includes ( s . id ) && canTest ( s )));
const selectable = $derived ( visible . filter ( canTest ));
const allSelected = $derived ( selectable . length > 0 && selectable . every ( s => selected . includes ( s . id )));
async function load() {
error = '' ;
try {
const res = await fetch ( '/api/scripts?kind=script' );
if ( ! res . ok ) throw new Error ( 'Could not load scripts. Try again.' );
scripts = ( await res . json ()). scripts ;
selected = selected . filter ( id => scripts . some ( s => s . id === id && canTest ( s )));
} catch ( e ) { error = ( e as Error ). message ; }
finally { loading = false ; }
}
onMount (() => { void load (); });
function toggle ( id : number ) { selected = selected . includes ( id ) ? selected . filter ( n => n !== id ) : [... selected , id ]; }
function selectVisible() {
const ids = selectable . map ( s => s . id );
selected = allSelected ? selected . filter ( id => ! ids . includes ( id )) : [... new Set ([... selected , ... ids ])];
}
function betaHref ( rows : LibraryScript []) { return betaSelectionHref ( rows . map ( s => ({ scriptId : s.id , versionId : s.latestVersion ! . id }))); }
function resetFilters() { search = '' ; status = 'all' ; game = '' ; map = '' ; }
function onDrag ( event : DragEvent ) { if ( event . dataTransfer ? . types . includes ( 'Files' )) event . preventDefault (); }
function onDrop ( event : DragEvent ) {
if ( ! event . dataTransfer ? . types . includes ( 'Files' )) return ;
event . preventDefault (); dragDepth = 0 ;
void uploader . addFiles ([... event . dataTransfer . files ]);
}
</ script >
< svelte:window ondragover = { onDrag } ondrop= { onDrop }
ondragenter = { event => { if ( event . dataTransfer ? . types . includes ( 'Files' )) { event . preventDefault (); dragDepth ++ ; } }}
ondragleave= {() => dragDepth = Math . max ( 0 , dragDepth - 1 )} ondragend = {() => dragDepth = 0 } / >
< div class = "page" >
< SectionHeader title = "Scripts" subtitle = "Upload a script, then open a beta to test it before promotion." / >
<!-- Upload form -->
< Card padding = "lg ">
< h2 class = "panel-title" > Upload a script</ h2 >
{ #if successBanner }
< div class = "banner" >
< strong > v{ successBanner . version } uploaded — untested.</ strong >
< span >
Open a beta on < a href = "/scripts/ { successBanner . scriptId } " > { successBanner . name } </ a >
to test it before it can be promoted.
</ span >
</ div >
{ /if }
< form onsubmit = { createScript } class="upload-form" >
< Field label = "Name" >
< Input bind:value = { newName } placeholder="hud tweaks " disabled = { uploading } / >
</ Field >
< Field label = "Notes" hint = "Optional. What changed in this version." >
< Input bind:value = { newNotes } placeholder="what changed " disabled = { uploading } / >
</ Field >
< Field label = "Scope" hint = "Where this script deploys. Unscoped ships wherever a beta targets it." >
< div class = "scope-row" >
< Select bind:value = { newScopeType } options= { scopeTypeOptions } disabled = { uploading } / >
{ #if newScopeType === 'game' || newScopeType === 'map' }
< Select bind:value = { newScopeGame } options= { gameOptions } disabled = { uploading } / >
{ /if }
{ #if newScopeType === 'map' }
< Select bind:value = { newScopeMap } options= { mapOptions } disabled = { uploading || ! newScopeGame } / >
{ /if }
</ div >
{ #if newScopeType !== 'none' && scopeValue }
{ @const matches = matchingServers ?? []}
{ #if matches . length === 0 }
< div class = "preview warn" > No matching servers — the promoted script will land nowhere.</ div >
{ : else }
< div class = "preview ok" >
{ matches . length } matching server{ matches . length === 1 ? '' : 's' } : { matches . join ( ', ' )}
</ div >
{ /if }
{ /if }
</ Field >
< Field label = "File" >
< Input type = "file" onchange = { pickFile } disabled= { uploading } />
</ Field >
< div class = "form-actions" >
< Button
type = "submit"
variant = "primary"
disabled = { uploading || ! newFile || ! newName }
loading= { uploading }
>
Upload script
</ Button >
{ #if flash } < span class = "flash" > { flash } </ span > { /if }
</ div >
</ form >
</ Card >
<!-- Script list -->
< section class = "section" >
< div class = "section-head" >
< h2 > All scripts</ h2 >
< Badge variant = "neutral" size = "sm" > { scripts . length } </ Badge >
< SectionHeader title = "Scripts" eyebrow = "Your script library" subtitle = "Review versions, prepare a beta, and keep releases moving." >
{ # snippet actions ()} < Button variant = "primary" onclick = {() => uploader . open ()} > Upload scripts </ Button > { /snippet }
</ SectionHeader >
< div class = "status-grid" aria-label = "Filter by script status ">
{ #each tabs as tab }
< button class:active = { status === tab . value } aria-pressed= { status === tab . value } onclick = {() => status = tab . value } >
< span > { tab . label } </ span >< strong > { loading ? '—' : scripts . filter ( s => tab . value === 'all' || scriptStatus ( s ) === tab . value ). length } </ strong >
</ button >
{ /each }
</ div >
< section class = "library" aria-label = "Script library" >
< div class = "toolbar" >
< label class = "search" >< span class = "sr-only" > Search scripts </ span >< input type = "search" bind:value = { search } placeholder="Search names , files , or maps …" /></ label >
< label >< span class = "sr-only" > Filter by game </ span >< select bind:value = { game } onchange= {() => map = '' } >< option value = "" > All games </ option >< option value = "t5" > Black Ops I · T5 </ option >< option value = "t6" > Black Ops II · T6 </ option ></ select ></ label >
< label >< span class = "sr-only" > Filter by map</ span >< select bind:value = { map } > <option value = "" > All maps</ option > { #each maps as m } < option value = { m } > { mapLabel ( m )} </option > { /each } </ select ></ label >
< label >< span class = "sr-only" > Sort scripts</ span >< select bind:value = { sort } > <option value = "attention" > Needs attention first</ option >< option value = "recent" > Recently uploaded</ option >< option value = "name" > Name A– Z</ option ></ select ></ label >
</ div >
{ #if loading }
< div class = "loading" > Loading… </ div >
{ :else if scripts . length === 0 }
< EmptyState title = "No scripts yet" description = "Upload one above to get started." / >
< div class = "list-heading" >
< label class = "select-all" >< input type = "checkbox" checked = { allSelected } disabled= { ! selectable . length } onchange = { selectVisible } / > Select visible</ label >
< span > { visible . length } of { scripts . length } scripts </ span >
{ #if search || status ! == 'all' || game || map } < button class = "text-button" onclick = { resetFilters } > Clear filters </ button > { /if }
</ div >
{ #if error } < div class = "error" role = "alert" > { error } < button class = "text-button" onclick = { load } > Retry</button ></ div > { /if }
{ #if loading } < p class = "loading" role = "status" > Loading your library…</ p >
{ :else if ! scripts . length && ! error }
< EmptyState title = "Your next release starts here" description = "Drop script files anywhere, or upload your first script to prepare a beta." />
< div class = "empty-action" >< Button onclick = {() => uploader . open ()} > Upload scripts </ Button ></ div >
{ :else if ! visible . length && ! error }
< EmptyState title = "No scripts match these filters" description = "Try another name, game, map, or status." />
{ : else }
< div class = "rows" >
{ #each scripts as s ( s . id )}
< a class = "row" href = { detailHref ( s )} >
< div class = "row-top" >
< span class = "name" > { s . name } </ span >
< Badge
variant = { s . scope_type === 'game' ? 'info' :
s . scope_type === 'map' ? 'warning' : 'neutral' }
size="sm"
>
{ scopeLabel ( s )}
</ Badge >
{ #if stateVariantFor ( s )}
{ @const v = stateVariantFor ( s ) ! }
< Badge variant = { v } size="sm" > { stateLabel ( s )} </ Badge >
{ /if }
{ #if s . runningBetaId }
< Badge variant = "warning" size = "sm" > β{ s . runningBetaId } running</ Badge >
{ /if }
{ #each visible as s ( s . id )}
{ @const state = scriptStatus ( s )}
< article class = "script-row" class:selected = { selected . includes ( s . id )} >
< input type = "checkbox" aria-label = { `Select ${ s . name } for a beta` } checked= { selected . includes ( s . id )} disabled = { ! canTest ( s )} onchange= {() => toggle ( s . id )} / >
< div class = "script-info" >
< div class = "name-row" >< a class = "name" href = "/scripts/ { s . id } " > { s . name } </ a >< Badge variant = { state === 'failed' ? 'danger' : state === 'testing' ? 'warning' : state === 'promoted' || state === 'tested' ? 'online' : 'neutral' } size="sm" > { statusLabels [ state ]} </ Badge ></ div >
< div class = "path" > { s . target_rel_path } </ div >
< div class = "meta" >< span title = { s . scope_value ?? 'Choose targets explicitly when opening a beta' } > { scopeLabel ( s )} </span >< span > { s . scope_type ? `$ { s . servers . length } scope matches ` : 'Explicit beta targets' } </ span >< span > { s . versionCount } version{ s . versionCount === 1 ? '' : 's' } </ span ></ div >
</ div >
< div class = "row-meta " >
< span class = "path" > { s . target_rel_path } </ span >
< span > v { s . latestVersion ? . version ?? '—' } · { s . versionCount } version { s . versionCount === 1 ? '' : 's ' } </ span >
{ #if s . servers . length }
< span > { s . servers . length } matching server{ s . servers . length === 1 ? '' : 's' } </ span >
{ /if }
< div class = "versions " >
< span > { s . currentVersion ? `Promoted v$ { s . currentVersion . version } ` : 'Never promoted' } </ span >
{ #if s . latestVersion && s . latestVersion . id !== s . current_version_id } < strong > Latest v { s . latestVersion . version } · { s . latestVersion . state === 'untested' ? 'awaiting test' : s . latestVersion . state . replaceAll ( '- ' , ' ' ) }</ strong > { /if }
</ div >
</ a >
< div class = "row-action" >
{ #if s . runningBetaId !== null } < Button size = "sm" href = "/betas/ { s . runningBetaId } " > View beta →</ Button >
{ :else if state === 'failed' } < Button size = "sm" href = "/scripts/ { s . id } " > Review version</ Button >
{ :else if canTest ( s )} < Button size = "sm" href = { betaHref ([ s ])} > Test version →</ Button >
{ : else } < Button size = "sm" onclick = {() => uploader . open ( s . id )} > Upload version </ Button > { /if }
</ div >
</ article >
{ /each }
</ div >
{ /if }
< p class = "hint" > Drop files anywhere to add scripts or recognize new versions. Scope matches describe eligible servers, not installed versions.</ p >
</ section >
{ #if selectedScripts . length }
< div class = "selection-bar" role = "region" aria-label = "Selected scripts" >
< strong > { selectedScripts . length } selected</ strong >< span > Latest versions will be carried into beta setup.</ span >
< Button size = "sm" variant = "ghost" onclick = {() => selected = []} > Clear</Button >
< Button variant = "primary" href = { betaHref ( selectedScripts )} > Test selected →</ Button >
</ div >
{ /if }
</ div >
{ #if dragDepth > 0 } < div class = "drop-overlay" >< strong > Drop your scripts here</ strong >< span > We’ ll recognize existing scripts and check for duplicate versions.</ span ></ div > { /if }
< Script UploadDrawer bind:this = { uploader } { scripts } onuploaded= { load } />
< style >
. page { display : flex ; flex-direction : column ; gap : var ( -- space -6 ); }
. loading { text-align : center ; padding : var ( -- space -12 ); color : var ( -- text - tertiary ); }
. panel-title {
margin : 0 0 var ( -- space -4 );
font-family : var ( -- font - body );
font-size : var ( -- text - md );
font-weight : var ( -- weight - semibold );
color : var ( -- text - primary );
}
. banner {
display : flex ;
flex-direction : column ;
gap : 4 px ;
padding : var ( -- space -3 ) var ( -- space -4 );
margin-bottom : var ( -- space -4 );
background : rgba ( 124 , 212 , 255 , 0.06 );
border : 1 px solid rgba ( 124 , 212 , 255 , 0.22 );
border-radius : var ( -- radius - md );
font-size : var ( -- text - sm );
color : var ( -- text - primary );
}
. banner a { color : var ( -- state - online ); text-decoration : underline ; }
. upload-form {
display : grid ;
gap : var ( -- space -4 );
grid-template-columns : 1 fr 1 fr ;
}
. scope-row {
display : flex ;
gap : var ( -- space -2 );
flex-wrap : wrap ;
}
. scope-row : global ( . wrap ) { flex : 1 1 140 px ; min-width : 140 px ; }
. preview {
margin-top : var ( -- space -2 );
padding : var ( -- space -2 ) var ( -- space -3 );
border-radius : var ( -- radius - md );
font-size : var ( -- text - xs );
font-family : var ( -- font - mono );
}
. preview . ok { background : var ( -- surface - sunken ); color : var ( -- text - secondary ); }
. preview . warn { background : rgba ( 255 , 176 , 32 , 0.06 ); color : var ( -- state - warning ); border : 1 px solid rgba ( 255 , 176 , 32 , 0.28 ); }
. form-actions {
flex-wrap : wrap ;
grid-column : 1 / -1 ;
display : flex ;
align-items : center ;
gap : var ( -- space -4 );
}
. flash {
color : var ( -- state - danger );
font-family : var ( -- font - mono );
font-size : var ( -- text - xs );
}
. section { display : flex ; flex-direction : column ; gap : var ( -- space -3 ); }
. section-head {
display : flex ;
align-items : center ;
gap : var ( -- space -3 );
}
. section-head h2 {
margin : 0 ;
font-family : var ( -- font - body );
font-size : var ( -- text - md );
font-weight : var ( -- weight - semibold );
color : var ( -- text - primary );
}
. rows { display : flex ; flex-direction : column ; gap : var ( -- space -2 ); }
. row {
display : flex ;
flex-direction : column ;
gap : var ( -- space -2 );
padding : var ( -- space -4 ) var ( -- space -5 );
background : color-mix ( in oklab , var ( -- surface ) 88 % , transparent );
border : 1 px solid var ( -- border - subtle );
border-radius : var ( -- radius - md );
color : var ( -- text - primary );
text-decoration : none ;
transition :
background-color var ( -- dur - fast ) var ( -- ease - standard ),
border-color var ( -- dur - fast ) var ( -- ease - standard );
}
. row : hover { background : var ( -- surface - raised ); border-color : var ( -- border - strong ); }
. row : focus-visible { outline : none ; box-shadow : var ( -- focus - ring ); }
. row-top {
display : flex ;
gap : var ( -- space -2 );
align-items : center ;
flex-wrap : wrap ;
}
. name {
font-size : var ( -- text - md );
font-weight : var ( -- weight - semibold );
color : var ( -- text - primary );
letter-spacing : var ( -- tracking - tight );
}
. row-meta {
display : flex ;
gap : var ( -- space -5 );
color : var ( -- text - tertiary );
font-family : var ( -- font - mono );
font-size : var ( -- text - xs );
flex-wrap : wrap ;
}
. path { color : var ( -- text - secondary ); }
@ media ( max-width : 720px ) {
. upload-form { grid-template-columns : 1 fr ; }
}
. page { display : flex ; flex-direction : column ; gap : var ( -- space -5 ); padding-bottom : 90 px ; }
. status-grid { display : grid ; grid-template-columns : repeat ( 6 , 1 fr ); gap : var ( -- space -2 ); }
. status-grid button { text-align : left ; display : flex ; flex-direction : column ; gap : 12 px ; padding : 18 px ; background : var ( -- surface ); border : 1 px solid var ( -- border - subtle ); border-radius : var ( -- radius - md ); color : var ( -- text - secondary ); cursor : pointer ; font : inherit ; }
. status-grid button . active { border-color : var ( -- accent - primary ); background : color-mix ( in srgb , var ( -- accent - primary ) 8 % , var ( -- surface )); }
. status-grid span { font-size : var ( -- text - xs ); } . status-grid strong { color : var ( -- text - primary ); font-size : var ( -- text -2 xl ); }
. toolbar { display : flex ; flex-wrap : wrap ; gap : 10 px ; } . search { flex : 1 1 260 px ; } . toolbar label { min-width : 0 ; }
input [ type = 'search' ], select { width : 100 % ; min-height : 42 px ; padding : 10 px 12 px ; border : 1 px solid var ( -- border - subtle ); border-radius : var ( -- radius - md ); background : var ( -- surface - sunken ); color : var ( -- text - primary ); font : inherit ; font-size : var ( -- text - sm ); }
input [ type = 'checkbox' ] { accent-color : var ( -- accent - primary ); width : 16 px ; height : 16 px ; flex-shrink : 0 ; }
button : focus-visible , input : focus-visible , select : focus-visible , a : focus-visible { outline : 2 px solid var ( -- accent - primary ); outline-offset : 3 px ; }
. list-heading { display : flex ; flex-wrap : wrap ; gap : 18 px ; align-items : center ; padding : 20 px 4 px 12 px ; font-size : var ( -- text - xs ); color : var ( -- text - secondary ); }
. select-all { display : flex ; align-items : center ; gap : 10 px ; } . text-button { background : none ; border : 0 ; color : var ( -- accent - primary ); cursor : pointer ; font : inherit ; }
. rows { display : flex ; flex-direction : column ; gap : 8 px ; } . script-row { display : grid ; grid-template-columns : auto minmax ( 0 , 1 fr ) minmax ( 160 px , auto ) auto ; gap : 20 px ; align-items : center ; padding : 20 px ; background : var ( -- surface ); border : 1 px solid var ( -- border - subtle ); border-radius : var ( -- radius - md ); }
. script-row . selected { border-color : var ( -- accent - primary ); } . name-row { display : flex ; align-items : center ; flex-wrap : wrap ; gap : 10 px ; }
. name { font-weight : 600 ; color : var ( -- text - primary ); text-decoration : none ; overflow-wrap : anywhere ; } . name : hover { color : var ( -- accent - primary ); }
. path { font-family : var ( -- font - mono ); font-size : var ( -- text - xs ); color : var ( -- text - tertiary ); margin-top : 6 px ; overflow-wrap : anywhere ; }
. meta { display : flex ; gap : 14 px ; flex-wrap : wrap ; font-size : var ( -- text - xs ); color : var ( -- text - secondary ); margin-top : 12 px ; }
. versions { display : flex ; flex-direction : column ; gap : 7 px ; font-size : var ( -- text - xs ); color : var ( -- text - secondary ); } . versions strong { font-weight : 500 ; color : var ( -- text - primary ); }
. hint { font-size : var ( -- text - xs ); color : var ( -- text - tertiary ); margin-top : 20 px ; line-height : 1.6 ; }
. selection-bar { position : sticky ; bottom : 20 px ; display : flex ; align-items : center ; flex-wrap : wrap ; gap : 16 px ; padding : 16 px 20 px ; border : 1 px solid var ( -- accent - primary ); border-radius : var ( -- radius - md ); background : var ( -- surface - raised ); box-shadow : 0 8 px 32 px #0005 ; }
. selection-bar span { flex : 1 ; color : var ( -- text - secondary ); font-size : var ( -- text - sm ); }
. drop-overlay { position : fixed ; inset : 16 px ; z-index : 100 ; pointer-events : none ; display : flex ; flex-direction : column ; justify-content : center ; align-items : center ; gap : 16 px ; padding : 24 px ; text-align : center ; background : color-mix ( in srgb , var ( -- surface ) 95 % , transparent ); border : 2 px dashed var ( -- accent - primary ); border-radius : var ( -- radius - md ); }
. drop-overlay strong { font-size : var ( -- text -2 xl ); } . loading , . empty-action { text-align : center ; padding : 24 px ; } . error { padding : 16 px ; color : var ( -- state - danger ); }
. sr-only { position : absolute ; width : 1 px ; height : 1 px ; overflow : hidden ; clip-path : inset ( 50 % ); white-space : nowrap ; }
@ media ( max-width : 1100px ) { . status-grid { grid-template-columns : repeat ( 3 , 1 fr ); } . script-row { grid-template-columns : auto minmax ( 0 , 1 fr ) auto ; } . versions { grid-column : 2 ; } . row-action { grid-column : 3 ; grid-row : 1 / 3 ; } }
@ media ( max-width : 640px ) { . status-grid { grid-template-columns : repeat ( 2 , 1 fr ); } . status-grid button { padding : 12 px ; gap : 6 px ; } . status-grid strong { font-size : 24 px ; } . script-row { gap : 12 px ; padding : 16 px ; grid-template-columns : auto minmax ( 0 , 1 fr ); } . row-action { grid-column : 2 ; grid-row : auto ; } . toolbar label { flex : 1 1 140 px ; } . toolbar . search { flex-basis : 100 % ; } . selection-bar span { display : none ; } }
</ style >