@@ -1,48 +1,122 @@
< script lang = "ts" >
< script lang = "ts" >
import { onMount } from 'svelte' ;
import { onMount } from 'svelte' ;
import { builtInPlans , requirement , requirementKinds , requirementLabels , parseBetaPlan , type BetaPlan , type RequirementKind } from '$lib/beta-plan.js' ;
import { builtInPlans , requirement , requirementKinds , requirementLabels , parseBetaPlan , type BetaPlan , type RequirementKind } from '$lib/beta-plan.js' ;
import { coverageSummary , needsSessionTelemetry , presetDescriptions , requirementCategory , requirementSummary } from '$lib/beta-plan-summary.js' ;
let { plan = $bindable < BetaPlan >() } : { plan : BetaPlan } = $props ();
let { plan = $bindable < BetaPlan >() } : { plan : BetaPlan } = $props ();
let presets = $state ( builtInPlans );
let presets = $state ( builtInPlans );
let selectedPreset = $state ( '' );
let presetName = $state ( '' );
let presetName = $state ( '' );
let addKind = $state < RequirementKind >( 'round' );
let addKind = $state < RequirementKind >( 'round' );
let message = $state ( '' );
let message = $state ( '' );
let error = $state ( '' );
let saving = $state ( false );
let saving = $state ( false );
onMount ( async () => {
let editingId = $state ( '' );
try { const response = await fetch ( '/api/betas/presets' ); if ( response . ok ) presets = ( await response . json ()). presets ; }
let appliedPresetName = $state ( '' );
catch { message = 'Saved presets could not be loaded.' ; }
const matchingPreset = $derived ( presets . find ( p => p . name === appliedPresetName && JSON . stringify ( p . plan ) === JSON . stringify ( plan ))
} );
?? presets . find ( p => JSON . stringify ( p . plan ) === JSON . stringify ( plan )) );
const savedPresets = $derived ( presets . filter ( p => ! builtInPlans . some ( b => b . name === p . name )));
const failureSummary = $derived ( plan . failureAction === 'fail' ? 'Fail and hold for review' : plan . failureAction === 'pause' ? 'Pause for investigation' : 'Roll back automatically' );
const rolloutSummary = $derived ( plan . waveSize ? ` ${ plan . waveSize } server ${ plan . waveSize === 1 ? '' : 's' } per wave` : 'Deploy to all selected servers' );
async function loadPresets() {
const response = await fetch ( '/api/betas/presets' );
if ( ! response . ok ) throw new Error ( 'Saved presets could not be loaded. Built-in presets are still available.' );
const body = await response . json ();
presets = body . presets . map (( p : { name : string ; plan : unknown }) => ({ name : p.name , plan : parseBetaPlan ( p . plan ) }));
}
onMount (() => { void loadPresets (). catch ( e => error = ( e as Error ). message ); });
function applyPreset ( name : string ) {
const preset = presets . find ( p => p . name === name );
if ( ! preset ) return ;
try {
// Validation returns an independent plain object, even when the source is a Svelte proxy.
plan = parseBetaPlan ( preset . plan );
appliedPresetName = name ;
editingId = '' ; error = '' ;
message = ` ${ name } applied. ${ plan . requirements . length } requirement ${ plan . requirements . length === 1 ? '' : 's' } loaded.` ;
} catch ( e ) { error = ( e as Error ). message ; }
}
function addRequirement() {
editingId = ` ${ Date . now (). toString ( 36 ) } - ${ Math . random (). toString ( 36 ). slice ( 2 ) } ` ;
plan . requirements = [... plan . requirements , requirement ( addKind , editingId )];
message = ` ${ requirementLabels [ addKind ] } added.` ;
}
async function savePreset() {
async function savePreset() {
message = '' ; saving = true ;
message = '' ; error = '' ; saving = true ;
try {
try {
const validated = parseBetaPlan ( plan );
const validated = parseBetaPlan ( plan );
const response = await fetch ( '/api/betas/presets' , { method : 'POST' , headers : { 'Content-Type' : 'application/json' }, body : JSON.stringify ({ name : presetName , plan : validated }) });
const response = await fetch ( '/api/betas/presets' , { method : 'POST' , headers : { 'Content-Type' : 'application/json' }, body : JSON.stringify ({ name : presetName , plan : validated }) });
const result = await response . json ();
const result = await response . json ();
if ( ! response . ok ) throw new Error ( result . error );
if ( ! response . ok ) throw new Error ( result . error );
presets = ( await ( await fetch ( '/api/betas/presets' )). json ()). presets ;
await loadPresets () ;
message = 'Preset saved.' ;
appliedPresetName = presetName . trim () ;
} catch ( error ) { message = ( error as Error ). message ; }
message = `Preset " ${ presetName . trim () } " saved.` ;
} catch ( e ) { error = ( e as Error ). message ; }
finally { saving = false ; }
finally { saving = false ; }
}
}
</ script >
</ script >
< section class = "builder" aria-label = "Beta test plan" >
< section class = "builder" aria-label = "Beta test plan" >
< h3 > Test plan </ h3 >
< header class = "section-heading" >
< p > All requirements must pass on each qualifying server. Milestones stay recorded; health and error budgets remain monitored until promotion. </ p >
< div >< span class = "eyebrow" > Validation </ span >< h3 > Build your test plan </ h3 ></ div >
< label > Start from a preset
< span class = "pill" > Manual promotion </ span >
< select bind:value = { selectedPreset } onchange= {() => { const p = presets . find ( p => p . name === selectedPreset ); if ( p ) plan = structuredClone ( p . plan ); }} >
</ header >
< option value = "" > Cus tom plan </ option >
< p > Choose a starting point, then tailor what each server needs to prove. </ p >
{ #each presets as preset } < option value = { preset . name } > { preset . name } </option > { /each }
</ select >
< div class = "presets" role = "group" aria-label = "Start from a preset" >
</ label >
{ #each builtInPlans as preset , index ( preset . name )}
{ #each plan . requirements as r , index ( r . id ) }
< button type = "button" class = "preset" class:selected = { matchingPreset ? . name === preset . name }
< fieldset >
aria-pressed= { matchingPreset ? . name === preset . name } aria-label = { `Use ${ preset . name } preset` } onclick= {() => applyPreset ( preset . name )} >
< legend > { requirementLabels [ r . kind ]} </ legend >
< span class = "preset-top" >< span class = "preset-number" > 0 { index + 1 } </ span >< span class = "choice" aria-hidden = "true" > { matchingPreset ? . name === preset . name ? '✓' : '' } </ span ></ span >
< strong > { preset . name } </ strong >
< span class = "preset-description" > { presetDescriptions [ preset . name ]} </ span >
< ul > { #each preset . plan . requirements as r } < li > { requirementSummary ( r )} </ li > { /each } </ ul >
< span class = "preset-footer" > { preset . plan . timeoutHours } -hour window < span > { matchingPreset ? . name === preset . name ? 'Selected' : 'Use preset →' } </ span ></ span >
</ button >
{ /each }
</ div >
{ #if savedPresets . length }
< label class = "saved-picker" > Or use a saved preset
< select value = { savedPresets . some ( p => p . name === matchingPreset ? . name ) ? matchingPreset ? . name : '' } onchange= { event => applyPreset ( event . currentTarget . value )} >
< option value = "" disabled > Choose a saved plan…</ option >
{ #each savedPresets as preset } < option value = { preset . name } > { preset . name } </option > { /each }
</ select >
</ label >
{ /if }
< div class = "feedback" role = "status" aria-live = "polite" > { message } </ div >
{ #if error } < p class = "error" role = "alert" > { error } </ p > { /if }
< div class = "active-plan" >
< div class = "section-heading" >
< div >< span class = "eyebrow" > Your active plan</ span >< h4 > { matchingPreset ? . name ?? 'Custom plan' } </ h4 ></ div >
< span class = "pill" > { plan . requirements . length } requirement{ plan . requirements . length === 1 ? '' : 's' } </ span >
</ div >
< dl class = "plan-facts" >
< div >< dt > Must pass on</ dt >< dd > { coverageSummary ( plan )} </ dd ></ div >
< div >< dt > Testing window</ dt >< dd > { plan . timeoutHours } hours < small > from opening</ small ></ dd ></ div >
< div >< dt > If a check fails</ dt >< dd > { failureSummary } </ dd ></ div >
</ dl >
< p class = "plan-caption" > Every requirement below must pass on each qualifying server. You can edit any check.</ p >
{ #if plan . requirements . some ( needsSessionTelemetry )}
< p class = "telemetry-note" >< span aria-hidden = "true" > ⓘ</ span > This plan needs session telemetry from the game reporter.</ p >
{ /if }
< div class = "requirements" >
{ #each plan . requirements as r , index ( r . id )}
< article class = "requirement" >
< div class = "requirement-heading" >
< span class = "step-number" > { String ( index + 1 ). padStart ( 2 , '0' )} </ span >
< div class = "requirement-copy" >< span class = "eyebrow" > { requirementCategory ( r )} </ span >< h5 > { requirementSummary ( r )} </ h5 ></ div >
< button type = "button" class = "remove" aria-label = { `Remove requirement ${ index + 1 } : ${ r . label } ` } onclick= {() => { plan . requirements = plan . requirements . filter ( item => item . id !== r . id ); message = 'Requirement removed.' ; }} > × </ button >
</ div >
< details class = "editor" open = { editingId === r . id } >
< summary > Edit requirement < span > { requirementLabels [ r . kind ]} </ span ></ summary >
< div class = "editor-fields" >
< div class = "fields" >
< div class = "fields" >
< label > Description< input bind:value = { r . label } maxlength="200" required /></ label >
< label > Description< input bind:value = { r . label } maxlength="200" required /></ label >
{ #if r . kind !== 'manual' }
{ #if r . kind !== 'manual' }
< label > {[ 'failure-budget' , 'restart-budget' , 'clean-logs' ]. includes ( r . kind ) ? 'Maximum allowed' : 'Target' }
< label > {[ 'failure-budget' , 'restart-budget' , 'clean-logs' ]. includes ( r . kind ) ? 'Maximum allowed' : 'Target' }
< input type = "number" min = {[ 'failure-budget' , 'restart-budget' , 'clean-logs' ]. includes ( r . kind ) ? 0 : 0.01 } step= {[ 'healthy-minutes' , 'populated-minutes' , 'player-hours' , 'sustained-population' ]. includes ( r . kind ) ? 'any' : 1 } bind:value = { r . target } required />
< input type = "number" min = {[ 'failure-budget' , 'restart-budget' , 'clean-logs' ]. includes ( r . kind ) ? 0 : [ 'healthy-minutes' , 'populated-minutes' , 'player-hours' , 'sustained-population' ]. includes ( r . kind ) ? 0.01 : 1 } step= {[ 'healthy-minutes' , 'populated-minutes' , 'player-hours' , 'sustained-population' ]. includes ( r . kind ) ? 'any' : 1 } bind:value = { r . target } required />
</ label >
</ label >
{ /if }
{ /if }
{ #if [ 'round' , 'matches' , 'populated-minutes' , 'player-hours' , 'sustained-population' , 'feature' ]. includes ( r . kind )}
{ #if [ 'round' , 'matches' , 'populated-minutes' , 'player-hours' , 'sustained-population' , 'feature' ]. includes ( r . kind )}
@@ -69,51 +143,123 @@
{ :else if r . kind === 'manual' }
{ :else if r . kind === 'manual' }
< p > A tester records their name and observation on the beta detail page.</ p >
< p > A tester records their name and observation on the beta detail page.</ p >
{ /if }
{ /if }
< button type = "button" onclick = {() => plan . requirements = plan . requirements . filter (( _ , i ) => i !== index )} > Remove requirement </ button >
</ div >
</ fieldset >
</ details >
{ /each }
</ article >
< div class = "fields" >
{ : else }
< label > Add requirement < select bind:value = { addKind } > { #each requirementKinds as kind } <option value = { kind } > { requirementLabels [ kind ]} </option > { /each } </ select ></ label >
< div class = "empty-plan" >< strong > No requirements yet </ strong >< p > Choose a preset above or add your first check below. A beta needs at least one requirement. </ p ></ div >
< button type = "button" disabled = { plan . requirements . length >= 30 } onclick= {() => plan . requirements = [... plan . requirements , requirement ( addKind , `$ { Date . now (). toString ( 36 )} - $ { Math . random (). toString ( 36 ). slice ( 2 )} `)] } > Add requirement</ button >
{ /each }
</ div >
< fieldset >
< legend > Coverage and rollout</ legend >
< div class = "fields" >
< label > Qualifying servers< select bind:value = { plan . scope } >
< option value = "all" > Every selected server</ option >< option value = "quorum" > At least N selected servers</ option >
< option value = "map" > At least one per selected map</ option >< option value = "game" > At least one per selected game</ option >
< option value = "map-game" > At least one per selected game/map combination</ option >
</ select ></ label >
{ #if plan . scope === 'quorum' } < label > Required servers< input type = "number" min = "1" step = "1" bind:value = { plan . quorum } required /></ label > { /if }
< label > Servers per wave (0 = all at once)< input type = "number" min = "0" step = "1" bind:value = { plan . waveSize } required /></ label >
</ div >
</ div >
< p > Each wave must fully pass before files reach the next wave. Coverage rules apply after all selected servers have started testing. </ p >
< div class = "add-row" >
</ fieldset >
< label > Add another check < select bind:value = { addKind } > { #each requirementKinds as kind } <option value = { kind } > { requirementLabels [ kind ]} </option > { /each } </ select ></ label >
< fieldset >
< button type = "button" class = "secondary" disabled = { plan . requirements . length >= 30 } onclick= { addRequirement } > + Add requirement </ button >
< legend > Deadline and failures</ legend >
< div class = "fields" >
< label > Testing window (hours from opening)< input type = "number" min = "0.01" max = "8760" step = "any" bind:value = { plan . timeoutHours } required /></ label >
< label > On test failure< select bind:value = { plan . failureAction } > <option value = "fail" > Mark failed and hold</ option >< option value = "pause" > Pause for investigation</ option >< option value = "rollback" > Roll back automatically</ option ></ select ></ label >
< label > Rollback restarts< select bind:value = { plan . rollbackRestart } > <option value = "when-empty" > Rolling restart when empty</ option >< option value = "manual" > Restore files; restart manually</ option ></ select ></ label >
</ div >
</ div >
< p > Missing evidence at the deadline is inconclusive. Promotion always requires a manual review.</ p >
</ fieldset >
< div class = "fields" >
< label > Save as preset< input bind:value = { presetName } maxlength="80" placeholder = "My gameplay check" /></ label >
< button type = "button" disabled = { saving || ! presetName . trim ()} onclick= { savePreset } > Save preset</ button >
</ div >
</ div >
{ #if message } < p role = "status" > { message } </ p > { /if }
< div class = "policy-settings" >
< details class = "policy" >
< summary >< span > Coverage & rollout< small > { coverageSummary ( plan )} · { rolloutSummary } </ small ></ span >< span class = "edit-hint" > Configure</ span ></ summary >
< div class = "policy-body" >
< div class = "fields" >
< label > Qualifying servers< select bind:value = { plan . scope } >
< option value = "all" > Every selected server</ option >< option value = "quorum" > At least N selected servers</ option >
< option value = "map" > At least one per selected map</ option >< option value = "game" > At least one per selected game</ option >
< option value = "map-game" > At least one per selected game/map combination</ option >
</ select ></ label >
{ #if plan . scope === 'quorum' } < label > Required servers< input type = "number" min = "1" step = "1" bind:value = { plan . quorum } required /></ label > { /if }
< label > Servers per wave (0 = all at once)< input type = "number" min = "0" step = "1" bind:value = { plan . waveSize } required /></ label >
</ div >
< p > Each wave must fully pass before files reach the next wave. Coverage rules apply after all selected servers have started testing. Restarts always wait for empty servers.</ p >
</ div >
</ details >
< details class = "policy" >
< summary >< span > Deadline & failure handling< small > { plan . timeoutHours } -hour window · { failureSummary } </ small ></ span >< span class = "edit-hint" > Configure</ span ></ summary >
< div class = "policy-body" >
< div class = "fields" >
< label > Testing window (hours from opening)< input type = "number" min = "0.01" max = "8760" step = "any" bind:value = { plan . timeoutHours } required /></ label >
< label > On test failure< select bind:value = { plan . failureAction } > <option value = "fail" > Mark failed and hold</ option >< option value = "pause" > Pause for investigation</ option >< option value = "rollback" > Roll back automatically</ option ></ select ></ label >
< label > Rollback restarts< select bind:value = { plan . rollbackRestart } > <option value = "when-empty" > Rolling restart when empty</ option >< option value = "manual" > Restore files; restart manually</ option ></ select ></ label >
</ div >
< p > Missing evidence at the deadline is inconclusive. Health and error checks continue until you manually promote.</ p >
</ div >
</ details >
< details class = "policy save-policy" >
< summary >< span > Save this plan as a preset< small > Reuse your requirements and settings on future betas.</ small ></ span >< span class = "edit-hint" > Save</ span ></ summary >
< div class = "policy-body fields" >
< label > Preset name< input bind:value = { presetName } maxlength="80" placeholder = "My gameplay check" /></ label >
< button type = "button" class = "secondary" disabled = { saving || ! presetName . trim ()} onclick= { savePreset } > { saving ? 'Saving…' : 'Save preset' } </ button >
</ div >
</ details >
</ div >
</ section >
</ section >
< style >
< style >
. builder { display : grid ; gap : 16 px ; }
. builder { display : grid ; gap : 16 px ; padding-top : 24 px ; border-top : 1 px solid var ( -- border - strong ); }
h3 , p { margin : 0 ; }
h3 , h4 , h5 , p , dl , dd { margin : 0 ; }
h3 { font-size : 21 px ; font-weight : 650 ; letter-spacing : -.025 em ; }
h4 { font-size : 19 px ; font-weight : 600 ; }
h5 { font-size : 15 px ; font-weight : 550 ; line-height : 1.5 ; }
p { color : var ( -- text - secondary ); font-size : 13 px ; line-height : 1.6 ; }
p { color : var ( -- text - secondary ); font-size : 13 px ; line-height : 1.6 ; }
fieldset { border : 1 px solid var ( -- border - subtle ); border-radius : 8 px ; padding : 16 px ; display : grid ; gap : 12 px ; min-width : 0 ; }
. section-heading { display : flex ; align-items : center ; justify-content : space-between ; gap : 12 px ; flex-wrap : wrap ; }
legend { font-weight : 600 ; padding : 0 6 px ; }
. eyebrow { display : block ; font-size : 10 px ; text-transform : uppercase ; letter-spacing : .1 em ; font-weight : 600 ; color : var ( -- text - secondary ); margin-bottom : 6 px ; }
. fields { display : flex ; flex-wrap : wrap ; gap : 12 px ; align-items : end ; }
. pill { padding : 5 px 9 px ; border-radius : 99 px ; font-size : 11 px ; color : var ( -- text - secondary ); border : 1 px solid var ( -- border - strong ); white-space : nowrap ; }
label { display : grid ; gap : 6 px ; flex : 1 1 180 px ; font-size : 13 px ; }
. presets { display : grid ; grid-template-columns : repeat ( 3 , minmax ( 0 , 1 fr )); gap : 12 px ; margin-top : 4 px ; }
input , select , button { color : var ( -- text - primary ) ; background : var ( -- surface - sunken ); border : 1 px solid var ( -- border - subtle ); border-radius : 6 px ; padding : 10 px ; min-width : 0 ; font : inherit ; }
. preset { display : flex ; flex-direction : column ; text-align : left ; padding : 18 px ; border-radius : 12 px ; background : var ( -- surface - sunken ); border : 1 px solid var ( -- border - strong ); gap : 10 px ; transition : border-color .15 s , background-color .15 s ; }
button { cursor : pointer ; justify-self : start ; }
. preset : hover { border-color : var ( -- accent - primary - hover ) ; }
. preset . selected { border-color : var ( -- accent - primary ); background : color-mix ( in oklab , var ( -- accent - primary ) 10 % , var ( -- surface - sunken )); box-shadow : inset 0 0 0 1 px color-mix ( in oklab , var ( -- accent - primary ) 30 % , transparent ); }
. preset-top { display : flex ; align-items : center ; justify-content : space-between ; }
. preset-number , . step-number { font-family : var ( -- font - mono ); color : var ( -- text - secondary ); font-size : 12 px ; }
. choice { width : 19 px ; height : 19 px ; border : 1 px solid var ( -- border - strong ); border-radius : 50 % ; display : grid ; place-items : center ; font-size : 12 px ; }
. selected . choice { background : var ( -- accent - primary ); color : var ( -- text - on - accent ); border-color : var ( -- accent - primary ); }
. preset > strong { font-size : 15 px ; }
. preset-description { font-size : 12 px ; line-height : 1.6 ; color : var ( -- text - secondary ); }
. preset ul { padding-left : 16 px ; margin : 2 px 0 10 px ; font-size : 12 px ; line-height : 1.65 ; flex : 1 ; }
. preset li + li { margin-top : 5 px ; }
. preset li :: marker { color : var ( -- accent - primary - hover ); }
. preset-footer { display : flex ; justify-content : space-between ; flex-wrap : wrap ; gap : 8 px ; border-top : 1 px solid var ( -- border - subtle ); padding-top : 12 px ; font-size : 11 px ; color : var ( -- text - secondary ); }
. preset-footer > span { color : var ( -- accent - primary - hover ); }
. feedback { color : var ( -- accent - primary - hover ); font-size : 12 px ; min-height : 18 px ; }
. error { color : var ( -- state - danger ); }
. active-plan { display : grid ; gap : 18 px ; padding : 22 px ; background : color-mix ( in oklab , var ( -- surface - raised ) 25 % , var ( -- surface )); border : 1 px solid var ( -- border - strong ); border-radius : 12 px ; }
. plan-facts { display : grid ; grid-template-columns : repeat ( 3 , minmax ( 0 , 1 fr )); gap : 16 px ; padding : 16 px 0 ; border-top : 1 px solid var ( -- border - subtle ); border-bottom : 1 px solid var ( -- border - subtle ); }
dt { color : var ( -- text - secondary ); font-size : 11 px ; margin-bottom : 6 px ; }
dd { font-size : 13 px ; font-weight : 550 ; line-height : 1.5 ; }
dd small { font-size : 11 px ; font-weight : 400 ; color : var ( -- text - secondary ); }
. plan-caption { font-size : 12 px ; }
. telemetry-note { border-left : 2 px solid var ( -- accent - primary ); background : color-mix ( in oklab , var ( -- accent - primary ) 6 % , transparent ); padding : 10 px 12 px ; font-size : 12 px ; }
. telemetry-note span { color : var ( -- accent - primary - hover ); margin-right : 5 px ; }
. requirements { display : grid ; gap : 10 px ; }
. requirement { border : 1 px solid var ( -- border - subtle ); border-radius : 9 px ; background : var ( -- surface - sunken ); min-width : 0 ; overflow : hidden ; }
. requirement-heading { display : flex ; align-items : center ; gap : 14 px ; padding : 16 px ; }
. requirement-copy { flex : 1 ; min-width : 0 ; overflow-wrap : anywhere ; }
. requirement-copy . eyebrow { font-size : 9 px ; margin-bottom : 4 px ; }
. remove { padding : 3 px 8 px ; font-size : 21 px ; background : transparent ; border : 0 ; color : var ( -- text - secondary ); }
. remove : hover { color : var ( -- state - danger ); }
. editor { border-top : 1 px solid var ( -- border - subtle ); }
. editor > summary { padding : 10 px 16 px ; font-size : 11 px ; color : var ( -- accent - primary - hover ); }
. editor > summary > span { color : var ( -- text - secondary ); margin-left : 10 px ; }
. editor-fields { display : grid ; gap : 12 px ; padding : 6 px 16 px 16 px ; }
. fields , . add-row { display : flex ; flex-wrap : wrap ; gap : 12 px ; align-items : end ; }
. add-row label { flex : 1 1 220 px ; }
label { display : grid ; gap : 7 px ; flex : 1 1 180 px ; font-size : 12 px ; color : var ( -- text - secondary ); }
input , select , button { color : var ( -- text - primary ); background : var ( -- surface - sunken ); border : 1 px solid var ( -- border - strong ); border-radius : 6 px ; padding : 10 px 12 px ; min-width : 0 ; font : inherit ; }
input , select { width : 100 % ; font-size : 13 px ; min-height : 40 px ; box-sizing : border-box ; }
button { cursor : pointer ; }
button : disabled { opacity : .5 ; cursor : default ; }
button : disabled { opacity : .5 ; cursor : default ; }
button : focus-visible , summary : focus-visible , input : focus-visible , select : focus-visible { outline : 2 px solid var ( -- accent - primary - hover ); outline-offset : 3 px ; }
. secondary { background : var ( -- surface - raised ); font-size : 12 px ; min-height : 40 px ; }
. secondary : hover : not ( : disabled ) { border-color : var ( -- accent - primary - hover ); }
. policy-settings { display : grid ; gap : 8 px ; }
. policy { border : 1 px solid var ( -- border - subtle ); border-radius : 9 px ; }
. policy > summary { display : flex ; align-items : center ; justify-content : space-between ; gap : 16 px ; padding : 16 px 18 px ; font-size : 13 px ; font-weight : 550 ; }
. policy small { display : block ; font-size : 11 px ; color : var ( -- text - secondary ); font-weight : 400 ; line-height : 1.6 ; margin-top : 5 px ; }
. edit-hint { color : var ( -- accent - primary - hover ); font-size : 11 px ; }
. policy-body { padding : 0 18 px 18 px ; display : grid ; gap : 12 px ; }
. policy-body . fields { display : flex ; }
summary { cursor : pointer ; }
. empty-plan { padding : 22 px ; text-align : center ; border : 1 px dashed var ( -- border - strong ); border-radius : 8 px ; }
. empty-plan strong { display : block ; margin-bottom : 8 px ; font-size : 14 px ; }
@ media ( max-width : 850px ) { . presets { grid-template-columns : 1 fr ; } . preset { padding : 16 px ; } . plan-facts { grid-template-columns : 1 fr ; gap : 12 px ; } }
@ media ( max-width : 480px ) { . active-plan { padding : 14 px ; } . requirement-heading { padding : 12 px ; gap : 10 px ; } . editor > summary > span { display : block ; margin : 5 px 0 0 ; } . add-row { align-items : stretch ; } . add-row button { width : 100 % ; } }
@ media ( prefers-reduced-motion : reduce ) { . preset { transition : none ; } }
</ style >
</ style >