951 lines
35 KiB
Svelte
951 lines
35 KiB
Svelte
<script lang="ts">
|
||
import { onMount, tick } from 'svelte';
|
||
import {
|
||
builtInPlans,
|
||
requirement,
|
||
requirementKinds,
|
||
requirementLabels,
|
||
parseBetaPlan,
|
||
betaPlanIssues,
|
||
type BetaPlan,
|
||
type RequirementKind
|
||
} from '$lib/beta-plan.js';
|
||
import {
|
||
coverageSummary,
|
||
needsGameTelemetry,
|
||
presetDescriptions,
|
||
requirementCategory,
|
||
requirementSummary
|
||
} from '$lib/beta-plan-summary.js';
|
||
let {
|
||
plan = $bindable<BetaPlan>(),
|
||
serverCount,
|
||
allServersRequired = false
|
||
}: { plan: BetaPlan; serverCount?: number; allServersRequired?: boolean } = $props();
|
||
let validationRequested = $state(false),
|
||
coverageOpen = $state(false),
|
||
deadlineOpen = $state(false);
|
||
const issues = $derived(validationRequested ? betaPlanIssues(plan, serverCount) : []);
|
||
const fieldError = (field: string) => issues.find((issue) => issue.field === field)?.message;
|
||
export async function validate(): Promise<boolean> {
|
||
validationRequested = true;
|
||
const issue = betaPlanIssues(plan, serverCount)[0];
|
||
if (!issue) return true;
|
||
if (issue.requirementId) editingId = issue.requirementId;
|
||
if (['quorum', 'waveSize'].includes(issue.field)) coverageOpen = true;
|
||
if (issue.field === 'timeoutHours') deadlineOpen = true;
|
||
await tick();
|
||
document.getElementById(`plan-${issue.field}`)?.focus();
|
||
return false;
|
||
}
|
||
const targetLabel = (kind: RequirementKind) =>
|
||
({
|
||
round: 'Target round',
|
||
matches: 'Matches required',
|
||
'populated-minutes': 'Populated minutes',
|
||
'player-hours': 'Player-hours required',
|
||
population: 'Players required',
|
||
'sustained-population': 'Consecutive minutes',
|
||
'healthy-minutes': 'Consecutive healthy minutes',
|
||
'failure-budget': 'Maximum health outages',
|
||
recovery: 'Recovery restarts',
|
||
manual: 'Checklist items'
|
||
})[kind];
|
||
let presets = $state(builtInPlans);
|
||
let presetName = $state('');
|
||
let addKind = $state<RequirementKind>('round');
|
||
let message = $state('');
|
||
let error = $state('');
|
||
let saving = $state(false);
|
||
let editingId = $state('');
|
||
let appliedPresetName = $state('');
|
||
let choosingPreset = $state(false);
|
||
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 waveSummary = $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);
|
||
if (allServersRequired) plan.scope = 'all';
|
||
appliedPresetName = name;
|
||
choosingPreset = false;
|
||
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() {
|
||
if (!(await validate())) return;
|
||
message = '';
|
||
error = '';
|
||
saving = true;
|
||
try {
|
||
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 result = await response.json();
|
||
if (!response.ok) throw new Error(result.error);
|
||
await loadPresets();
|
||
appliedPresetName = presetName.trim();
|
||
message = `Preset "${presetName.trim()}" saved.`;
|
||
} catch (e) {
|
||
error = (e as Error).message;
|
||
} finally {
|
||
saving = false;
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<section class="builder" aria-label="Beta test plan">
|
||
<header class="section-heading">
|
||
<p>Define the checks each qualifying server must pass.</p>
|
||
<span class="pill">Manual promotion</span>
|
||
<button
|
||
type="button"
|
||
class="secondary"
|
||
aria-expanded={choosingPreset}
|
||
aria-controls="beta-presets"
|
||
onclick={() => (choosingPreset = !choosingPreset)}
|
||
>{choosingPreset ? 'Close presets' : 'Change preset'}</button
|
||
>
|
||
</header>
|
||
|
||
{#if choosingPreset}
|
||
<div class="presets" id="beta-presets" role="group" aria-label="Start from a preset">
|
||
{#each builtInPlans as preset, index (preset.name)}
|
||
<button
|
||
type="button"
|
||
class="preset"
|
||
class:selected={matchingPreset?.name === preset.name}
|
||
aria-pressed={matchingPreset?.name === preset.name}
|
||
aria-label={`Use ${preset.name} preset`}
|
||
onclick={() => applyPreset(preset.name)}
|
||
>
|
||
<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}
|
||
{/if}
|
||
{#if message}<div class="feedback" role="status" aria-live="polite">{message}</div>{/if}
|
||
{#if error}<p class="error" role="alert">{error}</p>{/if}
|
||
{#if issues.length}<p class="error" role="alert">
|
||
Correct {issues.length} highlighted {issues.length === 1 ? 'field' : 'fields'} before continuing.
|
||
</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(needsGameTelemetry)}
|
||
<p class="telemetry-note">
|
||
<span aria-hidden="true">ⓘ</span> Gameplay progress comes from game start, round change,
|
||
and game end reports.
|
||
</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">
|
||
<label
|
||
>Description<input
|
||
id={`plan-${r.id}-label`}
|
||
aria-invalid={!!fieldError(`${r.id}-label`)}
|
||
aria-describedby={`error-${r.id}-label`}
|
||
bind:value={r.label}
|
||
maxlength="200"
|
||
required
|
||
/><span class="error" id={`error-${r.id}-label`}
|
||
>{fieldError(`${r.id}-label`) ?? ''}</span
|
||
></label
|
||
>
|
||
{#if r.kind !== 'manual'}
|
||
<label
|
||
>{targetLabel(r.kind)}
|
||
<input
|
||
id={`plan-${r.id}-target`}
|
||
aria-invalid={!!fieldError(`${r.id}-target`)}
|
||
aria-describedby={`error-${r.id}-target`}
|
||
type="number"
|
||
min={r.kind === 'failure-budget'
|
||
? 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
|
||
/>
|
||
<span class="error" id={`error-${r.id}-target`}
|
||
>{fieldError(`${r.id}-target`) ?? ''}</span
|
||
>
|
||
</label>
|
||
{/if}
|
||
{#if ['round', 'matches', 'populated-minutes', 'player-hours', 'sustained-population'].includes(r.kind)}
|
||
<label
|
||
>Minimum players<input
|
||
id={`plan-${r.id}-minPlayers`}
|
||
aria-invalid={!!fieldError(`${r.id}-minPlayers`)}
|
||
aria-describedby={`error-${r.id}-minPlayers`}
|
||
type="number"
|
||
min="1"
|
||
step="1"
|
||
bind:value={r.minPlayers}
|
||
required
|
||
/><span class="error" id={`error-${r.id}-minPlayers`}
|
||
>{fieldError(`${r.id}-minPlayers`) ?? ''}</span
|
||
></label
|
||
>
|
||
{/if}
|
||
{#if r.kind === 'matches'}
|
||
<label
|
||
>Minimum round in the same game<input
|
||
id={`plan-${r.id}-minRound`}
|
||
aria-invalid={!!fieldError(`${r.id}-minRound`)}
|
||
aria-describedby={`error-${r.id}-minRound`}
|
||
type="number"
|
||
min="0"
|
||
step="1"
|
||
bind:value={r.minRound}
|
||
required
|
||
/><span class="error" id={`error-${r.id}-minRound`}
|
||
>{fieldError(`${r.id}-minRound`) ?? ''}</span
|
||
></label
|
||
>
|
||
{/if}
|
||
{#if r.kind === 'matches'}
|
||
<label
|
||
>Minimum match duration (minutes)<input
|
||
id={`plan-${r.id}-minMinutes`}
|
||
aria-invalid={!!fieldError(`${r.id}-minMinutes`)}
|
||
aria-describedby={`error-${r.id}-minMinutes`}
|
||
type="number"
|
||
min="0"
|
||
step="any"
|
||
bind:value={r.minMinutes}
|
||
required
|
||
/><span class="error" id={`error-${r.id}-minMinutes`}
|
||
>{fieldError(`${r.id}-minMinutes`) ?? ''}</span
|
||
></label
|
||
>
|
||
{/if}
|
||
</div>
|
||
{#if needsGameTelemetry(r)}
|
||
<p>Uses game events reported by this server automatically.</p>
|
||
{:else if r.kind === 'recovery'}
|
||
<p>
|
||
After the other requirements pass, restart when empty and repeat
|
||
them. Every cycle requires fresh evidence and checklist
|
||
sign-off.
|
||
</p>
|
||
{:else if r.kind === 'failure-budget'}
|
||
<p>
|
||
Counts transitions from healthy to unhealthy. Missing or stale
|
||
monitoring resets continuous healthy time.
|
||
</p>
|
||
{:else if r.kind === 'manual'}
|
||
<p>
|
||
A tester records their name and observation on the beta detail
|
||
page.
|
||
</p>
|
||
{/if}
|
||
</div>
|
||
</details>
|
||
</article>
|
||
{:else}
|
||
<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>
|
||
{/each}
|
||
</div>
|
||
<div class="add-row">
|
||
<label
|
||
>Add another check<select bind:value={addKind}
|
||
>{#each requirementKinds as kind}<option value={kind}
|
||
>{requirementLabels[kind]}</option
|
||
>{/each}</select
|
||
></label
|
||
>
|
||
<button
|
||
type="button"
|
||
class="secondary"
|
||
disabled={plan.requirements.length >= 30}
|
||
onclick={addRequirement}>+ Add requirement</button
|
||
>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="policy-settings">
|
||
<details class="policy" bind:open={coverageOpen}>
|
||
<summary
|
||
><span
|
||
>Coverage & waves<small>{coverageSummary(plan)} · {waveSummary}</small
|
||
></span
|
||
><span class="edit-hint">Configure</span></summary
|
||
>
|
||
<div class="policy-body">
|
||
<div class="fields">
|
||
{#if allServersRequired}
|
||
<p>
|
||
Every requested instance must pass. Change the instance counts in the
|
||
Servers step.
|
||
</p>
|
||
{:else}
|
||
<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
|
||
id="plan-quorum"
|
||
aria-invalid={!!fieldError('quorum')}
|
||
aria-describedby="error-quorum"
|
||
type="number"
|
||
min="1"
|
||
max={serverCount ?? 1000}
|
||
step="1"
|
||
bind:value={plan.quorum}
|
||
required
|
||
/><span class="error" id="error-quorum"
|
||
>{fieldError('quorum') ??
|
||
`${serverCount ?? 0} servers selected`}</span
|
||
></label
|
||
>{/if}
|
||
{/if}
|
||
<label
|
||
>Servers per wave (0 = all at once)<input
|
||
id="plan-waveSize"
|
||
aria-invalid={!!fieldError('waveSize')}
|
||
aria-describedby="error-waveSize"
|
||
type="number"
|
||
min="0"
|
||
step="1"
|
||
bind:value={plan.waveSize}
|
||
required
|
||
/><span class="error" id="error-waveSize"
|
||
>{fieldError('waveSize') ?? ''}</span
|
||
></label
|
||
>
|
||
</div>
|
||
<p>
|
||
Each wave must fully pass before files reach the next wave. Coverage rules apply
|
||
after all selected servers have started testing, and every file must pass on at
|
||
least one matching instance. Restarts always wait for empty servers.
|
||
</p>
|
||
</div>
|
||
</details>
|
||
<details class="policy" bind:open={deadlineOpen}>
|
||
<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
|
||
id="plan-timeoutHours"
|
||
aria-invalid={!!fieldError('timeoutHours')}
|
||
aria-describedby="error-timeoutHours"
|
||
type="number"
|
||
min="0.01"
|
||
max="8760"
|
||
step="any"
|
||
bind:value={plan.timeoutHours}
|
||
required
|
||
/><span class="error" id="error-timeoutHours"
|
||
>{fieldError('timeoutHours') ?? ''}</span
|
||
></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 activation<select bind:value={plan.rollbackRestart}
|
||
><option value="when-empty">Restore and restart when empty</option
|
||
><option value="manual">Hold changes for manual resume</option></select
|
||
></label
|
||
>
|
||
</div>
|
||
<p>
|
||
Waiting for empty servers uses this window. Pausing and retrying do not extend
|
||
it. Missing evidence at the deadline is inconclusive. Health 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>
|
||
|
||
<style>
|
||
.builder {
|
||
display: grid;
|
||
gap: 16px;
|
||
}
|
||
h4,
|
||
h5,
|
||
p,
|
||
dl,
|
||
dd {
|
||
margin: 0;
|
||
}
|
||
h4 {
|
||
font-size: var(--text-lg);
|
||
font-weight: 600;
|
||
}
|
||
h5 {
|
||
font-size: var(--text-base);
|
||
font-weight: 550;
|
||
line-height: 1.5;
|
||
}
|
||
p {
|
||
color: var(--text-secondary);
|
||
font-size: var(--text-sm);
|
||
line-height: 1.6;
|
||
}
|
||
.section-heading {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.eyebrow {
|
||
display: block;
|
||
font-size: var(--text-xs);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.1em;
|
||
font-weight: 600;
|
||
color: var(--text-secondary);
|
||
margin-bottom: 6px;
|
||
}
|
||
.pill {
|
||
padding: 5px 9px;
|
||
border-radius: 99px;
|
||
font-size: var(--text-xs);
|
||
color: var(--text-secondary);
|
||
border: 1px solid var(--border-strong);
|
||
white-space: nowrap;
|
||
}
|
||
.presets {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||
gap: 12px;
|
||
margin-top: 4px;
|
||
}
|
||
.preset {
|
||
display: flex;
|
||
flex-direction: column;
|
||
text-align: left;
|
||
padding: 18px;
|
||
border-radius: var(--radius-md);
|
||
background: var(--surface-sunken);
|
||
border: 1px solid var(--border-strong);
|
||
gap: 10px;
|
||
transition:
|
||
border-color 0.15s,
|
||
background-color 0.15s;
|
||
}
|
||
.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 1px 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-numeric);
|
||
color: var(--text-secondary);
|
||
font-size: var(--text-xs);
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
.choice {
|
||
width: 19px;
|
||
height: 19px;
|
||
border: 1px solid var(--border-strong);
|
||
border-radius: 50%;
|
||
display: grid;
|
||
place-items: center;
|
||
font-size: var(--text-xs);
|
||
}
|
||
.selected .choice {
|
||
background: var(--accent-primary);
|
||
color: var(--text-on-accent);
|
||
border-color: var(--accent-primary);
|
||
}
|
||
.preset > strong {
|
||
font-size: var(--text-base);
|
||
}
|
||
.preset-description {
|
||
font-size: var(--text-xs);
|
||
line-height: 1.6;
|
||
color: var(--text-secondary);
|
||
}
|
||
.preset ul {
|
||
padding-left: 16px;
|
||
margin: 2px 0 10px;
|
||
font-size: var(--text-xs);
|
||
line-height: 1.65;
|
||
flex: 1;
|
||
}
|
||
.preset li + li {
|
||
margin-top: 5px;
|
||
}
|
||
.preset li::marker {
|
||
color: var(--accent-primary-hover);
|
||
}
|
||
.preset-footer {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
border-top: 1px solid var(--border-subtle);
|
||
padding-top: 12px;
|
||
font-size: var(--text-xs);
|
||
color: var(--text-secondary);
|
||
}
|
||
.preset-footer > span {
|
||
color: var(--accent-primary-hover);
|
||
}
|
||
.feedback {
|
||
color: var(--accent-primary-hover);
|
||
font-size: var(--text-xs);
|
||
min-height: 18px;
|
||
}
|
||
.error {
|
||
color: var(--state-danger);
|
||
}
|
||
.active-plan {
|
||
display: grid;
|
||
gap: 18px;
|
||
padding: 22px;
|
||
background: color-mix(in oklab, var(--surface-raised) 25%, var(--surface));
|
||
border: 1px solid var(--border-strong);
|
||
border-radius: var(--radius-md);
|
||
}
|
||
.plan-facts {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||
gap: 16px;
|
||
padding: 16px 0;
|
||
border-top: 1px solid var(--border-subtle);
|
||
border-bottom: 1px solid var(--border-subtle);
|
||
}
|
||
dt {
|
||
color: var(--text-secondary);
|
||
font-size: var(--text-xs);
|
||
margin-bottom: 6px;
|
||
}
|
||
dd {
|
||
font-size: var(--text-sm);
|
||
font-weight: 550;
|
||
line-height: 1.5;
|
||
}
|
||
dd small {
|
||
font-size: var(--text-xs);
|
||
font-weight: 400;
|
||
color: var(--text-secondary);
|
||
}
|
||
.plan-caption {
|
||
font-size: var(--text-xs);
|
||
}
|
||
.telemetry-note {
|
||
border-left: 2px solid var(--accent-primary);
|
||
background: color-mix(in oklab, var(--accent-primary) 6%, transparent);
|
||
padding: 10px 12px;
|
||
font-size: var(--text-xs);
|
||
}
|
||
.telemetry-note span {
|
||
color: var(--accent-primary-hover);
|
||
margin-right: 5px;
|
||
}
|
||
.requirements {
|
||
display: grid;
|
||
gap: 10px;
|
||
}
|
||
.requirement {
|
||
border: 1px solid var(--border-subtle);
|
||
border-radius: var(--radius-md);
|
||
background: var(--surface-sunken);
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
}
|
||
.requirement-heading {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 14px;
|
||
padding: 16px;
|
||
}
|
||
.requirement-copy {
|
||
flex: 1;
|
||
min-width: 0;
|
||
overflow-wrap: anywhere;
|
||
}
|
||
.requirement-copy .eyebrow {
|
||
font-size: var(--text-xs);
|
||
margin-bottom: 4px;
|
||
}
|
||
.remove {
|
||
padding: 3px 8px;
|
||
font-size: var(--text-lg);
|
||
background: transparent;
|
||
border: 0;
|
||
color: var(--text-secondary);
|
||
}
|
||
.remove:hover {
|
||
color: var(--state-danger);
|
||
}
|
||
.editor {
|
||
border-top: 1px solid var(--border-subtle);
|
||
}
|
||
.editor > summary {
|
||
padding: 10px 16px;
|
||
font-size: var(--text-xs);
|
||
color: var(--accent-primary-hover);
|
||
}
|
||
.editor > summary > span {
|
||
color: var(--text-secondary);
|
||
margin-left: 10px;
|
||
}
|
||
.editor-fields {
|
||
display: grid;
|
||
gap: 12px;
|
||
padding: 6px 16px 16px;
|
||
}
|
||
.fields,
|
||
.add-row {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 12px;
|
||
align-items: end;
|
||
}
|
||
.add-row label {
|
||
flex: 1 1 220px;
|
||
}
|
||
label {
|
||
display: grid;
|
||
gap: 7px;
|
||
flex: 1 1 180px;
|
||
min-width: 0;
|
||
font-size: var(--text-xs);
|
||
color: var(--text-secondary);
|
||
}
|
||
input,
|
||
select,
|
||
button {
|
||
color: var(--text-primary);
|
||
background: var(--surface-sunken);
|
||
border: 1px solid var(--border-strong);
|
||
border-radius: 6px;
|
||
padding: 10px 12px;
|
||
min-width: 0;
|
||
font: inherit;
|
||
}
|
||
input,
|
||
select {
|
||
width: 100%;
|
||
font-size: var(--text-sm);
|
||
min-height: 40px;
|
||
box-sizing: border-box;
|
||
border-color: var(--border-control);
|
||
}
|
||
button {
|
||
cursor: pointer;
|
||
}
|
||
button:disabled {
|
||
opacity: 0.5;
|
||
cursor: default;
|
||
}
|
||
button:focus-visible,
|
||
summary:focus-visible,
|
||
input:focus-visible,
|
||
select:focus-visible {
|
||
outline: 2px solid var(--accent-primary-hover);
|
||
outline-offset: 3px;
|
||
}
|
||
.secondary {
|
||
background: var(--surface-raised);
|
||
font-size: var(--text-xs);
|
||
min-height: 40px;
|
||
}
|
||
.secondary:hover:not(:disabled) {
|
||
border-color: var(--accent-primary-hover);
|
||
}
|
||
.policy-settings {
|
||
display: grid;
|
||
gap: 8px;
|
||
}
|
||
.policy {
|
||
border: 1px solid var(--border-subtle);
|
||
border-radius: var(--radius-md);
|
||
}
|
||
.policy > summary {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 16px;
|
||
padding: 16px 18px;
|
||
font-size: var(--text-sm);
|
||
font-weight: 550;
|
||
}
|
||
.policy small {
|
||
display: block;
|
||
font-size: var(--text-xs);
|
||
color: var(--text-secondary);
|
||
font-weight: 400;
|
||
line-height: 1.6;
|
||
margin-top: 5px;
|
||
}
|
||
.edit-hint {
|
||
color: var(--accent-primary-hover);
|
||
font-size: var(--text-xs);
|
||
}
|
||
.policy-body {
|
||
padding: 0 18px 18px;
|
||
display: grid;
|
||
gap: 12px;
|
||
}
|
||
.policy-body.fields {
|
||
display: flex;
|
||
}
|
||
summary {
|
||
cursor: pointer;
|
||
}
|
||
.empty-plan {
|
||
padding: 22px;
|
||
text-align: center;
|
||
border: 1px dashed var(--border-strong);
|
||
border-radius: var(--radius-md);
|
||
}
|
||
.empty-plan strong {
|
||
display: block;
|
||
margin-bottom: 8px;
|
||
font-size: var(--text-base);
|
||
}
|
||
@media (max-width: 850px) {
|
||
.presets {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
.preset {
|
||
padding: 16px;
|
||
}
|
||
.plan-facts {
|
||
grid-template-columns: 1fr;
|
||
gap: 12px;
|
||
}
|
||
}
|
||
@media (max-width: 480px) {
|
||
.active-plan {
|
||
padding: 14px;
|
||
}
|
||
.requirement-heading {
|
||
padding: 12px;
|
||
gap: 10px;
|
||
}
|
||
.editor > summary > span {
|
||
display: block;
|
||
margin: 5px 0 0;
|
||
}
|
||
.add-row {
|
||
align-items: stretch;
|
||
}
|
||
.add-row button {
|
||
width: 100%;
|
||
}
|
||
}
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.preset {
|
||
transition: none;
|
||
}
|
||
}
|
||
</style>
|