Fix beta preset selection and improve test plan builder UI
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
import type { BetaPlan, BetaRequirement } from './beta-plan.js';
|
||||
|
||||
const count = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`;
|
||||
const duration = (minutes: number) => minutes >= 60 && minutes % 60 === 0 ? count(minutes / 60, 'hour') : count(minutes, 'minute');
|
||||
export const presetDescriptions: Record<string, string> = {
|
||||
'Quick smoke test': 'A quick check that the server starts and stays healthy.',
|
||||
'Gameplay validation': 'Prove the build through real matches and populated playtime.',
|
||||
'Long soak': 'Exercise stability and participation over a longer test.'
|
||||
};
|
||||
export function requirementSummary(r: BetaRequirement): string {
|
||||
const players = r.minPlayers > 1 ? ` with at least ${r.minPlayers} players` : ' with players present';
|
||||
switch (r.kind) {
|
||||
case 'round': return `Reach round ${r.target}${players}`;
|
||||
case 'matches': return `Complete ${count(r.target, 'match').replace('matchs', 'matches')}${r.minRound ? ` reaching round ${r.minRound}` : ''}${r.minPlayers > 1 ? players : ''}${r.minMinutes ? ` lasting at least ${duration(r.minMinutes)} each` : ''}`;
|
||||
case 'populated-minutes': return `Accumulate ${duration(r.target)}${players}`;
|
||||
case 'player-hours': return `Accumulate ${r.target} player-hours${r.minPlayers > 1 ? players : ''}`;
|
||||
case 'population': return `Reach ${count(r.target, 'player')} online`;
|
||||
case 'sustained-population': return `Keep at least ${count(r.minPlayers, 'player')} online for ${duration(r.target)}`;
|
||||
case 'healthy-minutes': return `Stay continuously healthy for ${duration(r.target)}`;
|
||||
case 'failure-budget': return r.target === 0 ? 'Allow no health outages' : `Allow at most ${count(r.target, 'health outage')}`;
|
||||
case 'restart-budget': return r.target === 0 ? 'Allow no unexpected restarts' : `Allow at most ${count(r.target, 'unexpected restart')}`;
|
||||
case 'clean-logs': return r.target === 0 ? 'Allow no detected script errors' : `Allow at most ${count(r.target, 'detected script error')}`;
|
||||
case 'recovery': return `Repeat the checks after ${count(r.target, 'recovery restart')}`;
|
||||
case 'feature': return `Observe ${r.event || 'a named feature event'} ${count(r.target, 'time')}${players}${r.minRound ? ` at round ${r.minRound} or later` : ''}`;
|
||||
case 'manual': return r.label;
|
||||
}
|
||||
}
|
||||
export function coverageSummary(plan: BetaPlan): string {
|
||||
return plan.scope === 'all' ? 'Every selected server' : plan.scope === 'quorum' ? `At least ${count(plan.quorum, 'selected server')}`
|
||||
: plan.scope === 'map' ? 'One server per selected map' : plan.scope === 'game' ? 'One server per selected game' : 'One server per game / map';
|
||||
}
|
||||
export function needsSessionTelemetry(r: BetaRequirement): boolean {
|
||||
return ['round', 'feature', 'restart-budget'].includes(r.kind) || (r.kind === 'matches' && (r.minRound > 0 || r.minPlayers > 1));
|
||||
}
|
||||
export function requirementCategory(r: BetaRequirement): string {
|
||||
return r.kind === 'manual' ? 'Tester sign-off' : ['healthy-minutes', 'failure-budget', 'restart-budget', 'clean-logs'].includes(r.kind) ? 'Continuous check' : 'Milestone';
|
||||
}
|
||||
@@ -1,48 +1,122 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
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 presets = $state(builtInPlans);
|
||||
let selectedPreset = $state('');
|
||||
let presetName = $state('');
|
||||
let addKind = $state<RequirementKind>('round');
|
||||
let message = $state('');
|
||||
let error = $state('');
|
||||
let saving = $state(false);
|
||||
onMount(async () => {
|
||||
try { const response = await fetch('/api/betas/presets'); if (response.ok) presets = (await response.json()).presets; }
|
||||
catch { message = 'Saved presets could not be loaded.'; }
|
||||
});
|
||||
let editingId = $state('');
|
||||
let appliedPresetName = $state('');
|
||||
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() {
|
||||
message = ''; saving = true;
|
||||
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);
|
||||
presets = (await (await fetch('/api/betas/presets')).json()).presets;
|
||||
message = 'Preset saved.';
|
||||
} catch (error) { message = (error as Error).message; }
|
||||
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">
|
||||
<h3>Test plan</h3>
|
||||
<p>All requirements must pass on each qualifying server. Milestones stay recorded; health and error budgets remain monitored until promotion.</p>
|
||||
<label>Start from a preset
|
||||
<select bind:value={selectedPreset} onchange={() => { const p = presets.find(p => p.name === selectedPreset); if (p) plan = structuredClone(p.plan); }}>
|
||||
<option value="">Custom plan</option>
|
||||
{#each presets as preset}<option value={preset.name}>{preset.name}</option>{/each}
|
||||
</select>
|
||||
</label>
|
||||
{#each plan.requirements as r, index (r.id)}
|
||||
<fieldset>
|
||||
<legend>{requirementLabels[r.kind]}</legend>
|
||||
<header class="section-heading">
|
||||
<div><span class="eyebrow">Validation</span><h3>Build your test plan</h3></div>
|
||||
<span class="pill">Manual promotion</span>
|
||||
</header>
|
||||
<p>Choose a starting point, then tailor what each server needs to prove.</p>
|
||||
|
||||
<div class="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}
|
||||
<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">
|
||||
<label>Description<input bind:value={r.label} maxlength="200" required /></label>
|
||||
{#if r.kind !== 'manual'}
|
||||
<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>
|
||||
{/if}
|
||||
{#if ['round', 'matches', 'populated-minutes', 'player-hours', 'sustained-population', 'feature'].includes(r.kind)}
|
||||
@@ -69,51 +143,123 @@
|
||||
{:else if r.kind === 'manual'}
|
||||
<p>A tester records their name and observation on the beta detail page.</p>
|
||||
{/if}
|
||||
<button type="button" onclick={() => plan.requirements = plan.requirements.filter((_, i) => i !== index)}>Remove requirement</button>
|
||||
</fieldset>
|
||||
{/each}
|
||||
<div class="fields">
|
||||
<label>Add requirement<select bind:value={addKind}>{#each requirementKinds as kind}<option value={kind}>{requirementLabels[kind]}</option>{/each}</select></label>
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
<p>Each wave must fully pass before files reach the next wave. Coverage rules apply after all selected servers have started testing.</p>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<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 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>
|
||||
<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>
|
||||
{#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>
|
||||
|
||||
<style>
|
||||
.builder { display: grid; gap: 16px; }
|
||||
h3, p { margin: 0; }
|
||||
.builder { display: grid; gap: 16px; padding-top: 24px; border-top: 1px solid var(--border-strong); }
|
||||
h3, h4, h5, p, dl, dd { margin: 0; }
|
||||
h3 { font-size: 21px; font-weight: 650; letter-spacing: -.025em; }
|
||||
h4 { font-size: 19px; font-weight: 600; }
|
||||
h5 { font-size: 15px; font-weight: 550; line-height: 1.5; }
|
||||
p { color: var(--text-secondary); font-size: 13px; line-height: 1.6; }
|
||||
fieldset { border: 1px solid var(--border-subtle); border-radius: 8px; padding: 16px; display: grid; gap: 12px; min-width: 0; }
|
||||
legend { font-weight: 600; padding: 0 6px; }
|
||||
.fields { display: flex; flex-wrap: wrap; gap: 12px; align-items: end; }
|
||||
label { display: grid; gap: 6px; flex: 1 1 180px; font-size: 13px; }
|
||||
input, select, button { color: var(--text-primary); background: var(--surface-sunken); border: 1px solid var(--border-subtle); border-radius: 6px; padding: 10px; min-width: 0; font: inherit; }
|
||||
button { cursor: pointer; justify-self: start; }
|
||||
.section-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
|
||||
.eyebrow { display: block; font-size: 10px; text-transform: uppercase; letter-spacing: .1em; font-weight: 600; color: var(--text-secondary); margin-bottom: 6px; }
|
||||
.pill { padding: 5px 9px; border-radius: 99px; font-size: 11px; 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: 12px; background: var(--surface-sunken); border: 1px solid var(--border-strong); gap: 10px; transition: border-color .15s, background-color .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-mono); color: var(--text-secondary); font-size: 12px; }
|
||||
.choice { width: 19px; height: 19px; border: 1px solid var(--border-strong); border-radius: 50%; display: grid; place-items: center; font-size: 12px; }
|
||||
.selected .choice { background: var(--accent-primary); color: var(--text-on-accent); border-color: var(--accent-primary); }
|
||||
.preset > strong { font-size: 15px; }
|
||||
.preset-description { font-size: 12px; line-height: 1.6; color: var(--text-secondary); }
|
||||
.preset ul { padding-left: 16px; margin: 2px 0 10px; font-size: 12px; 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: 11px; color: var(--text-secondary); }
|
||||
.preset-footer > span { color: var(--accent-primary-hover); }
|
||||
.feedback { color: var(--accent-primary-hover); font-size: 12px; 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: 12px; }
|
||||
.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: 11px; margin-bottom: 6px; }
|
||||
dd { font-size: 13px; font-weight: 550; line-height: 1.5; }
|
||||
dd small { font-size: 11px; font-weight: 400; color: var(--text-secondary); }
|
||||
.plan-caption { font-size: 12px; }
|
||||
.telemetry-note { border-left: 2px solid var(--accent-primary); background: color-mix(in oklab, var(--accent-primary) 6%, transparent); padding: 10px 12px; font-size: 12px; }
|
||||
.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: 9px; 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: 9px; margin-bottom: 4px; }
|
||||
.remove { padding: 3px 8px; font-size: 21px; 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: 11px; 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; font-size: 12px; 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: 13px; min-height: 40px; box-sizing: border-box; }
|
||||
button { cursor: pointer; }
|
||||
button:disabled { opacity: .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: 12px; 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: 9px; }
|
||||
.policy > summary { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 16px 18px; font-size: 13px; font-weight: 550; }
|
||||
.policy small { display: block; font-size: 11px; color: var(--text-secondary); font-weight: 400; line-height: 1.6; margin-top: 5px; }
|
||||
.edit-hint { color: var(--accent-primary-hover); font-size: 11px; }
|
||||
.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: 8px; }
|
||||
.empty-plan strong { display: block; margin-bottom: 8px; font-size: 14px; }
|
||||
@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>
|
||||
|
||||
Reference in New Issue
Block a user