Use shared form controls and enforce UI patterns (Refs #110)
CI / checks (push) Successful in 4m51s

This commit is contained in:
2026-10-10 00:19:48 -04:00
parent 8d1bb69591
commit cc9c10226e
54 changed files with 1431 additions and 1449 deletions
+12 -1
View File
@@ -8,7 +8,18 @@ Primitives live in its `primitives` directory.
- Use `SectionHeader` for page titles and primary actions. The page owns spacing - Use `SectionHeader` for page titles and primary actions. The page owns spacing
between sections. between sections.
- Use `Field` with `Input`, `Select` or `Textarea` for labels, help and errors. - Use `Field` with `Input`, `Select` or `Textarea` for labels, help and errors.
Give controls distinct `ariaLabel` values when several share one field. Give controls distinct `ariaLabel` values when several share one field. Use
`hiddenLabel` on `Field` for compact filters with a visually hidden label.
`Input` and `Select` forward native validation, accessibility and event props;
use `bind:element` when a workflow needs to focus the native control. `Select`
accepts either `options` or child `option` / `optgroup` elements and preserves
the bound value's type. Keep layout on the field or its parent; controls own
their appearance.
- `npm run check:styles` rejects raw selects and text-like inputs outside their
primitives, including search, password, numeric and date/time fields. Native
checkboxes, radios, file pickers, hidden inputs, range/color pickers and input
buttons are allowed. The check parses Svelte markup, so comments and examples
in scripts are ignored.
- Pair `Tabs` with a labeled panel. Use `ListFooter` for long lists. - Pair `Tabs` with a labeled panel. Use `ListFooter` for long lists.
- Use `StatusBadge` for workflow states and `ServerIdentity` for server names. - Use `StatusBadge` for workflow states and `ServerIdentity` for server names.
Keep raw IDs available for copying and troubleshooting. Keep raw IDs available for copying and troubleshooting.
+78 -3
View File
@@ -1,19 +1,90 @@
import { readdir, readFile } from 'node:fs/promises'; import { readdir, readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import path from 'node:path'; import path from 'node:path';
import { parse } from 'svelte/compiler';
const root = fileURLToPath(new URL('../src/', import.meta.url)); const nativeInputTypes = new Set([
'checkbox',
'radio',
'file',
'hidden',
'range',
'color',
'button',
'submit',
'reset',
'image'
]);
/**
* @param {string} source
* @param {string} filename
*/
export function checkFormControls(source, filename) {
if (
!filename.endsWith('.svelte') ||
/(?:^|\/)primitives\/(?:Input|Select)\.svelte$/.test(filename)
)
return [];
/** @type {string[]} */
const failures = []; const failures = [];
/** @param {unknown} node */
function visit(node) {
if (!node || typeof node !== 'object') return;
if ('type' in node && node.type === 'RegularElement') {
const element = /** @type {import('svelte/compiler').AST.RegularElement} */ (node);
const type = element.attributes.find(
(attribute) => attribute.type === 'Attribute' && attribute.name === 'type'
);
const value =
type?.type === 'Attribute' && type.value !== true
? Array.isArray(type.value)
? type.value[0]
: type.value
: undefined;
const inputType =
value?.type === 'Text'
? value.data
: value?.expression.type === 'Literal'
? value.expression.value
: undefined;
if (
element.name === 'select' ||
(element.name === 'input' &&
(typeof inputType !== 'string' || !nativeInputTypes.has(inputType)))
) {
const line = source.slice(0, element.start).split('\n').length;
failures.push(
`${filename}:${line}: use Field with ${element.name === 'select' ? 'Select' : 'Input'} instead of raw <${element.name}>`
);
}
}
for (const value of Object.values(node)) {
if (Array.isArray(value)) value.forEach(visit);
else if (value && typeof value === 'object') visit(value);
}
}
visit(parse(source, { modern: true }).fragment);
return failures;
}
async function main() {
const root = fileURLToPath(new URL('../src/', import.meta.url));
/** @type {string[]} */
const failures = [];
/** @param {string} directory */
async function visit(directory) { async function visit(directory) {
for (const entry of await readdir(directory, { withFileTypes: true })) { for (const entry of await readdir(directory, { withFileTypes: true })) {
const file = path.join(directory, entry.name); const file = path.join(directory, entry.name);
if (entry.isDirectory()) await visit(file); if (entry.isDirectory()) await visit(file);
else if (/\.(svelte|css)$/.test(entry.name) && entry.name !== 'tokens.css') { else if (/\.(svelte|css)$/.test(entry.name) && entry.name !== 'tokens.css') {
const source = await readFile(file, 'utf8'); const source = await readFile(file, 'utf8');
const filename = path.relative(process.cwd(), file);
failures.push(...checkFormControls(source, filename));
for (const match of source.matchAll(/font-size\s*:\s*[\d.]+px\b/g)) { for (const match of source.matchAll(/font-size\s*:\s*[\d.]+px\b/g)) {
const line = source.slice(0, match.index).split('\n').length; const line = source.slice(0, match.index).split('\n').length;
failures.push( failures.push(
`${path.relative(process.cwd(), file)}:${line}: use a shared type token (12 px minimum): ${match[0]}` `${filename}:${line}: use a shared type token (12 px minimum): ${match[0]}`
); );
} }
} }
@@ -29,4 +100,8 @@ for (const match of tokens.matchAll(
if (failures.length) { if (failures.length) {
console.error(failures.join('\n')); console.error(failures.join('\n'));
process.exitCode = 1; process.exitCode = 1;
} else console.log('UI styles: shared font sizes and 12 px minimum verified.'); } else console.log('UI styles: shared form controls, font sizes and 12 px minimum verified.');
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url))
await main();
+39 -61
View File
@@ -1,4 +1,7 @@
<script lang="ts"> <script lang="ts">
import Input from '$ui/components/primitives/Input.svelte';
import Select from '$ui/components/primitives/Select.svelte';
import Field from '$ui/components/primitives/Field.svelte';
import { untrack } from 'svelte'; import { untrack } from 'svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
@@ -187,59 +190,59 @@
<h2 class="sr-only" id={headingId}>Activity history</h2> <h2 class="sr-only" id={headingId}>Activity history</h2>
{#snippet filterFields(suffix: string)} {#snippet filterFields(suffix: string)}
<div class="filters"> <div class="filters">
<label for={`activity-type${suffix}`} <Field forId={`activity-type${suffix}`} label="Type">
>Type<select <Select
id={`activity-type${suffix}`} id={`activity-type${suffix}`}
value={filters.type ?? ''} value={filters.type ?? ''}
onchange={(event) => update({ type: event.currentTarget.value })} onchange={(event) => update({ type: event.currentTarget.value })}
><option value="">All types</option>{#each typeOptions as [value, label]}<option ><option value="">All types</option>{#each typeOptions as [value, label]}<option
{value}>{label}</option {value}>{label}</option
>{/each}</select >{/each}</Select
></label ></Field
> >
<label for={`activity-state${suffix}`} <Field forId={`activity-state${suffix}`} label="State">
>State<select <Select
id={`activity-state${suffix}`} id={`activity-state${suffix}`}
value={filters.state ?? ''} value={filters.state ?? ''}
onchange={(event) => update({ state: event.currentTarget.value })} onchange={(event) => update({ state: event.currentTarget.value })}
><option value="">All states</option ><option value="">All states</option
>{#each Object.entries(activityStates) as [value, label]}<option {value} >{#each Object.entries(activityStates) as [value, label]}<option {value}
>{label}</option >{label}</option
>{/each}</select >{/each}</Select
></label ></Field
> >
{#if source === undefined}<label for={`activity-source${suffix}`} {#if source === undefined}<Field forId={`activity-source${suffix}`} label="Source">
>Source<select <Select
id={`activity-source${suffix}`} id={`activity-source${suffix}`}
value={filters.source ?? ''} value={filters.source ?? ''}
onchange={(event) => update({ source: event.currentTarget.value })} onchange={(event) => update({ source: event.currentTarget.value })}
><option value="">All sources</option ><option value="">All sources</option
>{#each sourceOptions as [value, label]}<option {value}>{label}</option >{#each sourceOptions as [value, label]}<option {value}>{label}</option
>{/each}</select >{/each}</Select
></label ></Field
>{/if} >{/if}
{#if server === undefined}<label for={`activity-server${suffix}`} {#if server === undefined}<Field forId={`activity-server${suffix}`} label="Server">
>Server<select <Select
id={`activity-server${suffix}`} id={`activity-server${suffix}`}
value={filters.server ?? ''} value={filters.server ?? ''}
onchange={(event) => update({ server: event.currentTarget.value })} onchange={(event) => update({ server: event.currentTarget.value })}
><option value="">All servers</option>{#each serverOptions as name}<option ><option value="">All servers</option>{#each serverOptions as name}<option
value={name}>{serverTitle(name)}</option value={name}>{serverTitle(name)}</option
>{/each}</select >{/each}</Select
></label ></Field
>{/if} >{/if}
<label for={`activity-host${suffix}`} <Field forId={`activity-host${suffix}`} label="Host">
>Host<select <Select
id={`activity-host${suffix}`} id={`activity-host${suffix}`}
value={filters.host ?? ''} value={filters.host ?? ''}
onchange={(event) => update({ host: event.currentTarget.value })} onchange={(event) => update({ host: event.currentTarget.value })}
><option value="">All hosts</option>{#each hostOptions as [value, label]}<option ><option value="">All hosts</option>{#each hostOptions as [value, label]}<option
{value}>{label}</option {value}>{label}</option
>{/each}</select >{/each}</Select
></label ></Field
> >
<label for={`activity-days${suffix}`} <Field forId={`activity-days${suffix}`} label="Date range">
>Date range<select <Select
id={`activity-days${suffix}`} id={`activity-days${suffix}`}
value={String(filters.days ?? 0)} value={String(filters.days ?? 0)}
onchange={(event) => update({ days: event.currentTarget.value })} onchange={(event) => update({ days: event.currentTarget.value })}
@@ -249,8 +252,8 @@
><option value="90">Last 90 days</option ><option value="90">Last 90 days</option
>{#if filters.days && ![1, 7, 30, 90].includes(filters.days)}<option >{#if filters.days && ![1, 7, 30, 90].includes(filters.days)}<option
value={String(filters.days)}>Last {filters.days} days</option value={String(filters.days)}>Last {filters.days} days</option
>{/if}</select >{/if}</Select
></label ></Field
> >
<form <form
class="search" class="search"
@@ -260,14 +263,14 @@
submitSearch(); submitSearch();
}} }}
> >
<label for={`activity-search${suffix}`} <Field forId={`activity-search${suffix}`} label="Search activity">
>Search activity<input <Input
id={`activity-search${suffix}`} id={`activity-search${suffix}`}
type="search" type="search"
bind:value={search} bind:value={search}
maxlength="200" maxlength={200}
placeholder="Server, host, or activity…" placeholder="Server, host, or activity…"
/></label /></Field
><Button type="submit" size="sm">Search</Button> ><Button type="submit" size="sm">Search</Button>
</form> </form>
</div> </div>
@@ -377,15 +380,15 @@
? `${data.offset + 1}–${data.offset + data.rows.length} of ${data.total}` ? `${data.offset + 1}–${data.offset + data.rows.length} of ${data.total}`
: 'No activities on this page'}</span : 'No activities on this page'}</span
> >
<label <Field label="Per page">
>Per page<select <Select
aria-label="Activities per page" aria-label="Activities per page"
value={data.limit} value={data.limit}
onchange={(event) => update({ limit: event.currentTarget.value })} onchange={(event) => update({ limit: event.currentTarget.value })}
>{#each [...new Set( [25, 50, 100, data.limit] )].sort((a, b) => a - b) as size}<option >{#each [...new Set( [25, 50, 100, data.limit] )].sort((a, b) => a - b) as size}<option
value={size}>{size}</option value={size}>{size}</option
>{/each}</select >{/each}</Select
></label ></Field
> >
{#if data.total > data.limit || data.offset > 0}<nav aria-label="Activity pages"> {#if data.total > data.limit || data.offset > 0}<nav aria-label="Activity pages">
<Button <Button
@@ -418,25 +421,7 @@
align-items: end; align-items: end;
gap: var(--space-3); gap: var(--space-3);
} }
label {
display: grid;
gap: 6px;
color: var(--text-secondary);
font-size: var(--text-xs);
min-width: 0;
}
select,
input {
width: 100%;
min-height: 40px;
padding: 8px 10px;
border: 1px solid var(--border-control);
border-radius: var(--radius-md);
background: var(--surface-sunken);
color: var(--text-primary);
font: inherit;
font-size: var(--text-sm);
}
.search { .search {
grid-column: span 2; grid-column: span 2;
display: flex; display: flex;
@@ -444,7 +429,8 @@
gap: var(--space-2); gap: var(--space-2);
min-width: 0; min-width: 0;
} }
.search label {
.search :global(.field) {
flex: 1; flex: 1;
} }
.context, .context,
@@ -549,15 +535,7 @@
.pagination { .pagination {
justify-content: space-between; justify-content: space-between;
} }
.pagination label {
display: flex;
align-items: center;
gap: var(--space-2);
}
.pagination select {
width: auto;
min-height: 36px;
}
.pagination nav { .pagination nav {
display: flex; display: flex;
gap: var(--space-2); gap: var(--space-2);
+13 -17
View File
@@ -1,4 +1,6 @@
<script lang="ts"> <script lang="ts">
import Input from '$ui/components/primitives/Input.svelte';
import Field from '$ui/components/primitives/Field.svelte';
import { timeFormat } from '$ui/stores/time-zone.js'; import { timeFormat } from '$ui/stores/time-zone.js';
import Dialog from './primitives/Dialog.svelte'; import Dialog from './primitives/Dialog.svelte';
import Button from './primitives/Button.svelte'; import Button from './primitives/Button.svelte';
@@ -89,34 +91,34 @@
</p> </p>
<form onsubmit={add}> <form onsubmit={add}>
<fieldset disabled={busy}> <fieldset disabled={busy}>
<label <Field label="Friendly name">
>Friendly name<input <Input
required required
maxlength="200" maxlength={200}
bind:value={name} bind:value={name}
placeholder="Game host 1" placeholder="Game host 1"
/></label /></Field
> >
<details> <details>
<summary>Advanced settings</summary> <summary>Advanced settings</summary>
<div class="ports"> <div class="ports">
<label <Field label="First game port">
>First game port<input <Input
type="number" type="number"
required required
min="1" min="1"
max="65535" max="65535"
bind:value={portMin} bind:value={portMin}
/></label /></Field
> >
<label <Field label="Last game port">
>Last game port<input <Input
type="number" type="number"
required required
min={portMin} min={portMin}
max="65535" max="65535"
bind:value={portMax} bind:value={portMax}
/></label /></Field
> >
</div> </div>
</details> </details>
@@ -150,12 +152,7 @@
.muted { .muted {
font-size: var(--text-xs); font-size: var(--text-xs);
} }
label {
display: grid;
gap: 8px;
font-size: var(--text-sm);
}
input,
textarea { textarea {
box-sizing: border-box; box-sizing: border-box;
width: 100%; width: 100%;
@@ -186,7 +183,6 @@
flex-wrap: wrap; flex-wrap: wrap;
gap: 12px; gap: 12px;
} }
input:focus-visible,
textarea:focus-visible, textarea:focus-visible,
summary:focus-visible { summary:focus-visible {
outline: 2px solid var(--accent-primary); outline: 2px solid var(--accent-primary);
+57 -69
View File
@@ -1,4 +1,7 @@
<script lang="ts"> <script lang="ts">
import Input from '$ui/components/primitives/Input.svelte';
import Select from '$ui/components/primitives/Select.svelte';
import Field from '$ui/components/primitives/Field.svelte';
import { onMount, tick } from 'svelte'; import { onMount, tick } from 'svelte';
import { import {
builtInPlans, builtInPlans,
@@ -192,9 +195,8 @@
{/each} {/each}
</div> </div>
{#if savedPresets.length} {#if savedPresets.length}
<label class="saved-picker" <Field class="saved-picker" label="Or use a saved preset">
>Or use a saved preset <Select
<select
value={savedPresets.some((p) => p.name === matchingPreset?.name) value={savedPresets.some((p) => p.name === matchingPreset?.name)
? matchingPreset?.name ? matchingPreset?.name
: ''} : ''}
@@ -203,8 +205,8 @@
<option value="" disabled>Choose a saved plan…</option> <option value="" disabled>Choose a saved plan…</option>
{#each savedPresets as preset}<option value={preset.name}>{preset.name}</option {#each savedPresets as preset}<option value={preset.name}>{preset.name}</option
>{/each} >{/each}
</select> </Select>
</label> </Field>
{/if} {/if}
{/if} {/if}
{#if message}<div class="feedback" role="status" aria-live="polite">{message}</div>{/if} {#if message}<div class="feedback" role="status" aria-live="polite">{message}</div>{/if}
@@ -274,22 +276,21 @@
<summary>Edit requirement <span>{requirementLabels[r.kind]}</span></summary> <summary>Edit requirement <span>{requirementLabels[r.kind]}</span></summary>
<div class="editor-fields"> <div class="editor-fields">
<div class="fields"> <div class="fields">
<label <Field label="Description">
>Description<input <Input
id={`plan-${r.id}-label`} id={`plan-${r.id}-label`}
aria-invalid={!!fieldError(`${r.id}-label`)} aria-invalid={!!fieldError(`${r.id}-label`)}
aria-describedby={`error-${r.id}-label`} aria-describedby={`error-${r.id}-label`}
bind:value={r.label} bind:value={r.label}
maxlength="200" maxlength={200}
required required
/><span class="error" id={`error-${r.id}-label`} /><span class="error" id={`error-${r.id}-label`}
>{fieldError(`${r.id}-label`) ?? ''}</span >{fieldError(`${r.id}-label`) ?? ''}</span
></label ></Field
> >
{#if r.kind !== 'manual'} {#if r.kind !== 'manual'}
<label <Field label={targetLabel(r.kind)}>
>{targetLabel(r.kind)} <Input
<input
id={`plan-${r.id}-target`} id={`plan-${r.id}-target`}
aria-invalid={!!fieldError(`${r.id}-target`)} aria-invalid={!!fieldError(`${r.id}-target`)}
aria-describedby={`error-${r.id}-target`} aria-describedby={`error-${r.id}-target`}
@@ -318,11 +319,11 @@
<span class="error" id={`error-${r.id}-target`} <span class="error" id={`error-${r.id}-target`}
>{fieldError(`${r.id}-target`) ?? ''}</span >{fieldError(`${r.id}-target`) ?? ''}</span
> >
</label> </Field>
{/if} {/if}
{#if ['round', 'matches', 'populated-minutes', 'player-hours', 'sustained-population'].includes(r.kind)} {#if ['round', 'matches', 'populated-minutes', 'player-hours', 'sustained-population'].includes(r.kind)}
<label <Field label="Minimum players">
>Minimum players<input <Input
id={`plan-${r.id}-minPlayers`} id={`plan-${r.id}-minPlayers`}
aria-invalid={!!fieldError(`${r.id}-minPlayers`)} aria-invalid={!!fieldError(`${r.id}-minPlayers`)}
aria-describedby={`error-${r.id}-minPlayers`} aria-describedby={`error-${r.id}-minPlayers`}
@@ -333,12 +334,12 @@
required required
/><span class="error" id={`error-${r.id}-minPlayers`} /><span class="error" id={`error-${r.id}-minPlayers`}
>{fieldError(`${r.id}-minPlayers`) ?? ''}</span >{fieldError(`${r.id}-minPlayers`) ?? ''}</span
></label ></Field
> >
{/if} {/if}
{#if r.kind === 'matches'} {#if r.kind === 'matches'}
<label <Field label="Minimum round in the same game">
>Minimum round in the same game<input <Input
id={`plan-${r.id}-minRound`} id={`plan-${r.id}-minRound`}
aria-invalid={!!fieldError(`${r.id}-minRound`)} aria-invalid={!!fieldError(`${r.id}-minRound`)}
aria-describedby={`error-${r.id}-minRound`} aria-describedby={`error-${r.id}-minRound`}
@@ -349,12 +350,12 @@
required required
/><span class="error" id={`error-${r.id}-minRound`} /><span class="error" id={`error-${r.id}-minRound`}
>{fieldError(`${r.id}-minRound`) ?? ''}</span >{fieldError(`${r.id}-minRound`) ?? ''}</span
></label ></Field
> >
{/if} {/if}
{#if r.kind === 'matches'} {#if r.kind === 'matches'}
<label <Field label="Minimum match duration (minutes)">
>Minimum match duration (minutes)<input <Input
id={`plan-${r.id}-minMinutes`} id={`plan-${r.id}-minMinutes`}
aria-invalid={!!fieldError(`${r.id}-minMinutes`)} aria-invalid={!!fieldError(`${r.id}-minMinutes`)}
aria-describedby={`error-${r.id}-minMinutes`} aria-describedby={`error-${r.id}-minMinutes`}
@@ -365,7 +366,7 @@
required required
/><span class="error" id={`error-${r.id}-minMinutes`} /><span class="error" id={`error-${r.id}-minMinutes`}
>{fieldError(`${r.id}-minMinutes`) ?? ''}</span >{fieldError(`${r.id}-minMinutes`) ?? ''}</span
></label ></Field
> >
{/if} {/if}
</div> </div>
@@ -402,12 +403,12 @@
{/each} {/each}
</div> </div>
<div class="add-row"> <div class="add-row">
<label <Field label="Add another check">
>Add another check<select bind:value={addKind} <Select bind:value={addKind}
>{#each requirementKinds as kind}<option value={kind} >{#each requirementKinds as kind}<option value={kind}
>{requirementLabels[kind]}</option >{requirementLabels[kind]}</option
>{/each}</select >{/each}</Select
></label ></Field
> >
<button <button
type="button" type="button"
@@ -434,8 +435,8 @@
Servers step. Servers step.
</p> </p>
{:else} {:else}
<label <Field label="Qualifying servers">
>Qualifying servers<select bind:value={plan.scope}> <Select bind:value={plan.scope}>
<option value="all">Every selected server</option><option <option value="all">Every selected server</option><option
value="quorum">At least N selected servers</option value="quorum">At least N selected servers</option
> >
@@ -445,10 +446,10 @@
<option value="map-game" <option value="map-game"
>At least one per selected game/map combination</option >At least one per selected game/map combination</option
> >
</select></label </Select></Field
> >
{#if plan.scope === 'quorum'}<label {#if plan.scope === 'quorum'}<Field label="Required servers">
>Required servers<input <Input
id="plan-quorum" id="plan-quorum"
aria-invalid={!!fieldError('quorum')} aria-invalid={!!fieldError('quorum')}
aria-describedby="error-quorum" aria-describedby="error-quorum"
@@ -461,11 +462,11 @@
/><span class="error" id="error-quorum" /><span class="error" id="error-quorum"
>{fieldError('quorum') ?? >{fieldError('quorum') ??
`${serverCount ?? 0} servers selected`}</span `${serverCount ?? 0} servers selected`}</span
></label ></Field
>{/if} >{/if}
{/if} {/if}
<label <Field label="Servers per wave (0 = all at once)">
>Servers per wave (0 = all at once)<input <Input
id="plan-waveSize" id="plan-waveSize"
aria-invalid={!!fieldError('waveSize')} aria-invalid={!!fieldError('waveSize')}
aria-describedby="error-waveSize" aria-describedby="error-waveSize"
@@ -476,7 +477,7 @@
required required
/><span class="error" id="error-waveSize" /><span class="error" id="error-waveSize"
>{fieldError('waveSize') ?? ''}</span >{fieldError('waveSize') ?? ''}</span
></label ></Field
> >
</div> </div>
<p> <p>
@@ -496,8 +497,8 @@
> >
<div class="policy-body"> <div class="policy-body">
<div class="fields"> <div class="fields">
<label <Field label="Testing window (hours from opening)">
>Testing window (hours from opening)<input <Input
id="plan-timeoutHours" id="plan-timeoutHours"
aria-invalid={!!fieldError('timeoutHours')} aria-invalid={!!fieldError('timeoutHours')}
aria-describedby="error-timeoutHours" aria-describedby="error-timeoutHours"
@@ -509,20 +510,20 @@
required required
/><span class="error" id="error-timeoutHours" /><span class="error" id="error-timeoutHours"
>{fieldError('timeoutHours') ?? ''}</span >{fieldError('timeoutHours') ?? ''}</span
></label ></Field
> >
<label <Field label="On test failure">
>On test failure<select bind:value={plan.failureAction} <Select bind:value={plan.failureAction}
><option value="fail">Mark failed and hold</option><option value="pause" ><option value="fail">Mark failed and hold</option><option value="pause"
>Pause for investigation</option >Pause for investigation</option
><option value="rollback">Roll back automatically</option></select ><option value="rollback">Roll back automatically</option></Select
></label ></Field
> >
<label <Field label="Rollback activation">
>Rollback activation<select bind:value={plan.rollbackRestart} <Select bind:value={plan.rollbackRestart}
><option value="when-empty">Restore and restart when empty</option ><option value="when-empty">Restore and restart when empty</option
><option value="manual">Hold changes for manual resume</option></select ><option value="manual">Hold changes for manual resume</option></Select
></label ></Field
> >
</div> </div>
<p> <p>
@@ -541,12 +542,12 @@
><span class="edit-hint">Save</span></summary ><span class="edit-hint">Save</span></summary
> >
<div class="policy-body fields"> <div class="policy-body fields">
<label <Field label="Preset name">
>Preset name<input <Input
bind:value={presetName} bind:value={presetName}
maxlength="80" maxlength={80}
placeholder="My gameplay check" placeholder="My gameplay check"
/></label /></Field
> >
<button <button
type="button" type="button"
@@ -808,19 +809,15 @@
gap: 12px; gap: 12px;
align-items: end; align-items: end;
} }
.add-row label {
.builder .add-row > :global(.field) {
flex: 1 1 220px; flex: 1 1 220px;
} }
label {
display: grid; .builder :global(.field) {
gap: 7px;
flex: 1 1 180px; flex: 1 1 180px;
min-width: 0; min-width: 0;
font-size: var(--text-xs);
color: var(--text-secondary);
} }
input,
select,
button { button {
color: var(--text-primary); color: var(--text-primary);
background: var(--surface-sunken); background: var(--surface-sunken);
@@ -830,14 +827,7 @@
min-width: 0; min-width: 0;
font: inherit; font: inherit;
} }
input,
select {
width: 100%;
font-size: var(--text-sm);
min-height: 40px;
box-sizing: border-box;
border-color: var(--border-control);
}
button { button {
cursor: pointer; cursor: pointer;
} }
@@ -846,9 +836,7 @@
cursor: default; cursor: default;
} }
button:focus-visible, button:focus-visible,
summary:focus-visible, summary:focus-visible {
input:focus-visible,
select:focus-visible {
outline: 2px solid var(--accent-primary-hover); outline: 2px solid var(--accent-primary-hover);
outline-offset: 3px; outline-offset: 3px;
} }
+21 -13
View File
@@ -1,4 +1,7 @@
<script lang="ts"> <script lang="ts">
import Input from '$ui/components/primitives/Input.svelte';
import Select from '$ui/components/primitives/Select.svelte';
import Field from '$ui/components/primitives/Field.svelte';
import { serverTitle } from '$ui/server-labels.js'; import { serverTitle } from '$ui/server-labels.js';
import RelativeTime from '$ui/components/RelativeTime.svelte'; import RelativeTime from '$ui/components/RelativeTime.svelte';
import { timeFormat } from '$ui/stores/time-zone.js'; import { timeFormat } from '$ui/stores/time-zone.js';
@@ -413,27 +416,27 @@
current progress uses only the current attempt. current progress uses only the current attempt.
</p> </p>
<div class="filters"> <div class="filters">
<label <Field label="Server">
>Server<select bind:value={evidenceServer} <Select bind:value={evidenceServer}
><option value="">All servers</option ><option value="">All servers</option
>{#each [...new Set(test.evidence.map((event) => event.server))] as server}<option >{#each [...new Set(test.evidence.map((event) => event.server))] as server}<option
value={server}>{serverTitle(server)}</option value={server}>{serverTitle(server)}</option
>{/each}</select >{/each}</Select
></label ></Field
><label ><Field label="Event type">
>Event type<select bind:value={evidenceKind} <Select bind:value={evidenceKind}
><option value="">All events</option><option value="gameplay" ><option value="">All events</option><option value="gameplay"
>Gameplay reports</option >Gameplay reports</option
><option value="health">Health observations</option><option ><option value="health">Health observations</option><option
value="manual-sign-off">Tester sign-offs</option value="manual-sign-off">Tester sign-offs</option
></select ></Select
></label ></Field
><label ><Field label="Search evidence">
>Search evidence<input <Input
type="search" type="search"
bind:value={evidenceQuery} bind:value={evidenceQuery}
placeholder="Round, map, tester, observation…" placeholder="Round, map, tester, observation…"
/></label /></Field
> >
</div> </div>
{#each filteredEvidence.slice(0, evidenceLimit) as event (event.id)}<article {#each filteredEvidence.slice(0, evidenceLimit) as event (event.id)}<article
@@ -683,14 +686,15 @@
.signoff label { .signoff label {
flex: 1 1 240px; flex: 1 1 240px;
} }
.signoff :global(.field) {
flex: 1 1 240px;
}
label { label {
display: grid; display: grid;
gap: 6px; gap: 6px;
font-size: var(--text-sm); font-size: var(--text-sm);
color: var(--text-secondary); color: var(--text-secondary);
} }
input,
select,
textarea { textarea {
min-height: 40px; min-height: 40px;
padding: 8px 10px; padding: 8px 10px;
@@ -714,6 +718,10 @@
flex: 1 1 180px; flex: 1 1 180px;
min-width: 0; min-width: 0;
} }
.filters :global(.field) {
flex: 1 1 180px;
min-width: 0;
}
.evidence { .evidence {
display: grid; display: grid;
gap: 8px; gap: 8px;
+28 -39
View File
@@ -1,4 +1,7 @@
<script lang="ts"> <script lang="ts">
import Select from '$ui/components/primitives/Select.svelte';
import Input from '$ui/components/primitives/Input.svelte';
import Field from '$ui/components/primitives/Field.svelte';
import { isDialogBackdrop } from '$ui/dialog.js'; import { isDialogBackdrop } from '$ui/dialog.js';
import Button from './primitives/Button.svelte'; import Button from './primitives/Button.svelte';
import type { BlueprintSummary } from '$lib/deployment-plan.js'; import type { BlueprintSummary } from '$lib/deployment-plan.js';
@@ -184,21 +187,21 @@
}} }}
> >
<div class="identity"> <div class="identity">
<label <Field label="Collection name">
>Collection name<input <Input
bind:value={name} bind:value={name}
maxlength="80" maxlength={80}
placeholder="Friday Zombies" placeholder="Friday Zombies"
required required
disabled={busy} disabled={busy}
/></label /></Field
><label ><Field label="Description (optional)">
>Description (optional)<input <Input
bind:value={description} bind:value={description}
maxlength="500" maxlength={500}
placeholder="Our weekend lineup" placeholder="Our weekend lineup"
disabled={busy} disabled={busy}
/></label /></Field
> >
</div> </div>
<div class="lineup"> <div class="lineup">
@@ -211,8 +214,8 @@
{#each rows as row, index (row.key)} {#each rows as row, index (row.key)}
<div class="map-row"> <div class="map-row">
<span class="number">{String(index + 1).padStart(2, '0')}</span> <span class="number">{String(index + 1).padStart(2, '0')}</span>
<label <Field label="Game">
>Game<select <Select
aria-label={`Game ${index + 1}`} aria-label={`Game ${index + 1}`}
bind:value={row.game} bind:value={row.game}
disabled={busy} disabled={busy}
@@ -222,11 +225,11 @@
?.slug ?? '')} ?.slug ?? '')}
><option value="t5">Black Ops I</option><option value="t6" ><option value="t5">Black Ops I</option><option value="t6"
>Black Ops II</option >Black Ops II</option
></select ></Select
></label ></Field
> >
<label <Field label="Map">
>Map<select <Select
aria-label={`Map ${index + 1}`} aria-label={`Map ${index + 1}`}
aria-invalid={duplicate(row) || undefined} aria-invalid={duplicate(row) || undefined}
aria-describedby={duplicate(row) aria-describedby={duplicate(row)
@@ -242,13 +245,13 @@
other.game === row.game && other.game === row.game &&
other.mapSlug === map.slug other.mapSlug === map.slug
)}>{map.friendly}</option )}>{map.friendly}</option
>{/each}</select >{/each}</Select
>{#if duplicate(row)}<span class="error" id={`map-error-${row.key}`} >{#if duplicate(row)}<span class="error" id={`map-error-${row.key}`}
>Already selected. Choose another map.</span >Already selected. Choose another map.</span
>{/if}</label >{/if}</Field
> >
<label <Field label="Target lobbies">
>Target lobbies<input <Input
aria-label={`Lobbies ${index + 1}`} aria-label={`Lobbies ${index + 1}`}
aria-invalid={!( aria-invalid={!(
Number.isInteger(row.count) && Number.isInteger(row.count) &&
@@ -263,7 +266,7 @@
bind:value={row.count} bind:value={row.count}
required required
disabled={busy} disabled={busy}
/><span id={`count-hint-${row.key}`}>1–15</span></label /><span id={`count-hint-${row.key}`}>1–15</span></Field
> >
<button <button
type="button" type="button"
@@ -371,24 +374,7 @@
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: 16px; gap: 16px;
} }
label {
display: grid;
gap: 7px;
font-size: var(--text-xs);
min-width: 0;
}
input,
select {
width: 100%;
min-width: 0;
box-sizing: border-box;
padding: 11px;
border: 1px solid var(--border-control);
border-radius: var(--radius-md);
background: var(--surface-sunken);
color: inherit;
font: inherit;
}
.lineup { .lineup {
border-block: 1px solid var(--border-subtle); border-block: 1px solid var(--border-subtle);
padding: 20px 0; padding: 20px 0;
@@ -457,8 +443,11 @@
display: none; display: none;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
.map-row label:first-of-type,
.map-row label:nth-of-type(2) { .map-row > :global(.field:first-of-type) {
grid-column: 1 / -1;
}
.map-row > :global(.field:nth-of-type(2)) {
grid-column: 1 / -1; grid-column: 1 / -1;
} }
.actions { .actions {
+11 -20
View File
@@ -1,4 +1,6 @@
<script lang="ts"> <script lang="ts">
import Select from '$ui/components/primitives/Select.svelte';
import Field from '$ui/components/primitives/Field.svelte';
import RelativeTime from '$ui/components/RelativeTime.svelte'; import RelativeTime from '$ui/components/RelativeTime.svelte';
import type { CapacitySamplePage } from '$lib/hosts.js'; import type { CapacitySamplePage } from '$lib/hosts.js';
import Button from './primitives/Button.svelte'; import Button from './primitives/Button.svelte';
@@ -59,18 +61,18 @@
measurements from other hosts used by fleet allowances. measurements from other hosts used by fleet allowances.
</p> </p>
<div class="controls"> <div class="controls">
<label <Field label="Source">
>Source<select bind:value={scope} onchange={() => (page = 1)} <Select bind:value={scope} onchange={() => (page = 1)}
><option value="host">This host</option><option value="fleet" ><option value="host">This host</option><option value="fleet"
>Entire fleet</option >Entire fleet</option
></select ></Select
></label ></Field
> >
<label <Field label="Phase">
>Phase<select bind:value={phase} onchange={() => (page = 1)} <Select bind:value={phase} onchange={() => (page = 1)}
><option value="all">All phases</option><option value="startup">Starting</option ><option value="all">All phases</option><option value="startup">Starting</option
><option value="running">Running</option></select ><option value="running">Running</option></Select
></label ></Field
> >
<Button <Button
size="sm" size="sm"
@@ -182,18 +184,7 @@
nav { nav {
align-items: center; align-items: center;
} }
label {
display: grid;
gap: 6px;
font-size: var(--text-sm);
}
select {
padding: 8px;
color: var(--text-primary);
background: var(--surface-sunken);
border: 1px solid var(--border-control);
border-radius: var(--radius-sm);
}
.table-wrap { .table-wrap {
max-width: 100%; max-width: 100%;
overflow: auto; overflow: auto;
+9 -27
View File
@@ -1,4 +1,6 @@
<script lang="ts"> <script lang="ts">
import Field from '$ui/components/primitives/Field.svelte';
import Input from '$ui/components/primitives/Input.svelte';
import { tick } from 'svelte'; import { tick } from 'svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import type { UserRole } from '$lib/auth.js'; import type { UserRole } from '$lib/auth.js';
@@ -154,13 +156,13 @@
description="Jump to a page, server, host, player, or match." description="Jump to a page, server, host, player, or match."
> >
<div class="palette"> <div class="palette">
<label class="sr-only" for={`${id}-search`}>Search Electric Cherry</label> <Field label="Search Electric Cherry" forId={`${id}-search`} hiddenLabel>
<input <Input
bind:this={input} bind:element={input}
bind:value={query} bind:value={query}
id={`${id}-search`} id={`${id}-search`}
type="search" type="search"
maxlength="100" maxlength={100}
placeholder="Search Electric Cherry…" placeholder="Search Electric Cherry…"
role="combobox" role="combobox"
aria-autocomplete="list" aria-autocomplete="list"
@@ -170,7 +172,8 @@
aria-describedby={`${id}-hint`} aria-describedby={`${id}-hint`}
autocomplete="off" autocomplete="off"
onkeydown={keydown} onkeydown={keydown}
/> /></Field
>
<p class="hint" id={`${id}-hint`}> <p class="hint" id={`${id}-hint`}>
Try a lobby, player name, or #match number. <span>↑ ↓ to choose · Enter to open</span> Try a lobby, player name, or #match number. <span>↑ ↓ to choose · Enter to open</span>
</p> </p>
@@ -228,17 +231,7 @@
gap: var(--space-3); gap: var(--space-3);
min-width: 0; min-width: 0;
} }
input {
width: 100%;
min-height: 44px;
padding: var(--space-3);
font: inherit;
font-size: var(--text-base);
color: var(--text-primary);
background: var(--surface-sunken);
border: 1px solid var(--border-control);
border-radius: var(--radius-sm);
}
.hint, .hint,
.status, .status,
.error { .error {
@@ -296,15 +289,4 @@
.error { .error {
color: var(--state-warning); color: var(--state-warning);
} }
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
</style> </style>
+16 -12
View File
@@ -1,4 +1,7 @@
<script lang="ts"> <script lang="ts">
import Input from '$ui/components/primitives/Input.svelte';
import Select from '$ui/components/primitives/Select.svelte';
import Field from '$ui/components/primitives/Field.svelte';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { confirmAction } from '$ui/confirm.js'; import { confirmAction } from '$ui/confirm.js';
import { beforeNavigate, goto } from '$app/navigation'; import { beforeNavigate, goto } from '$app/navigation';
@@ -230,8 +233,8 @@
{#if dirty}<span class="draft-tag">Unsaved draft</span>{/if} {#if dirty}<span class="draft-tag">Unsaved draft</span>{/if}
</div> </div>
<div class="version-toolbar"> <div class="version-toolbar">
<label <Field label="Starting version">
>Starting version<select <Select
aria-label="Starting version" aria-label="Starting version"
value={selected} value={selected}
onchange={changeVersion} onchange={changeVersion}
@@ -240,8 +243,8 @@
>v{version.version}{version.id === currentVersionId >v{version.version}{version.id === currentVersionId
? ' · promoted' ? ' · promoted'
: ''}{version.id === versions[0]?.id ? ' · latest' : ''}</option : ''}{version.id === versions[0]?.id ? ' · latest' : ''}</option
>{/each}</select >{/each}</Select
></label ></Field
> >
<label class="file-button" <label class="file-button"
>Import .cfg<input >Import .cfg<input
@@ -274,12 +277,12 @@
overlay={kind === 'config'} overlay={kind === 'config'}
disabled={saving || runningBetaId !== null} disabled={saving || runningBetaId !== null}
/> />
<label class="notes-label" <Field class="notes-label" label="Version notes">
>Version notes<input <Input
bind:value={notes} bind:value={notes}
placeholder="What changed, and why?" placeholder="What changed, and why?"
disabled={saving || runningBetaId !== null} disabled={saving || runningBetaId !== null}
/></label /></Field
> >
<div class="actions ui-sticky-actions"> <div class="actions ui-sticky-actions">
<span class="muted" <span class="muted"
@@ -355,13 +358,15 @@
.version-toolbar > label:first-child { .version-toolbar > label:first-child {
flex: 1; flex: 1;
} }
.version-toolbar > :global(.field:first-child) {
flex: 1;
}
label { label {
display: grid; display: grid;
gap: 8px; gap: 8px;
color: var(--text-secondary); color: var(--text-secondary);
font-size: var(--text-xs); font-size: var(--text-xs);
} }
select,
input:not([type='file']) { input:not([type='file']) {
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
@@ -373,7 +378,6 @@
font: var(--text-sm) var(--font-body); font: var(--text-sm) var(--font-body);
} }
input:focus-visible, input:focus-visible,
select:focus-visible,
.file-button:focus-within { .file-button:focus-within {
outline: 2px solid var(--accent-primary); outline: 2px solid var(--accent-primary);
outline-offset: 2px; outline-offset: 2px;
@@ -394,9 +398,10 @@
width: 100%; width: 100%;
cursor: pointer; cursor: pointer;
} }
.notes-label { .editor-panel :global(.notes-label) {
margin-top: 20px; margin-top: 20px;
} }
.actions { .actions {
margin-top: 16px; margin-top: 16px;
} }
@@ -441,8 +446,7 @@
padding: 16px; padding: 16px;
} }
} }
input:not([type='checkbox']):not([type='file']), input:not([type='checkbox']):not([type='file']) {
select {
border-color: var(--border-control); border-color: var(--border-control);
} }
</style> </style>
+16 -12
View File
@@ -1,4 +1,7 @@
<script lang="ts"> <script lang="ts">
import Input from '$ui/components/primitives/Input.svelte';
import Select from '$ui/components/primitives/Select.svelte';
import Field from '$ui/components/primitives/Field.svelte';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { serverTitle } from '$ui/server-labels.js'; import { serverTitle } from '$ui/server-labels.js';
import type { ConfigInspection, ConfigServer, ConfigSource } from '$lib/config-workbench.js'; import type { ConfigInspection, ConfigServer, ConfigSource } from '$lib/config-workbench.js';
@@ -191,8 +194,8 @@
? 'Your draft composed with the other promoted configs. Saving creates an untested version.' ? 'Your draft composed with the other promoted configs. Saving creates an untested version.'
: 'The intended file from promoted configs and the selected map. Live files and active betas may differ.'} : 'The intended file from promoted configs and the selected map. Live files and active betas may differ.'}
</p> </p>
{#if !fixedServer}<label class="server-label" {#if !fixedServer}<Field class="server-label" label="Server">
>Server<select <Select
aria-label="Server" aria-label="Server"
bind:value={selected} bind:value={selected}
disabled={!!fixedServer || !eligible.length} disabled={!!fixedServer || !eligible.length}
@@ -201,7 +204,7 @@
>{serverTitle(server.name)}</option >{serverTitle(server.name)}</option
>{/each} >{/each}
{#if !eligible.length}<option value="">No matching servers</option>{/if} {#if !eligible.length}<option value="">No matching servers</option>{/if}
</select></label </Select></Field
>{/if} >{/if}
{#if error}<div class="error" role="alert"> {#if error}<div class="error" role="alert">
{error}{inspection ? ' Showing the last completed preview.' : ''} {error}{inspection ? ' Showing the last completed preview.' : ''}
@@ -274,11 +277,13 @@
></textarea> ></textarea>
{:else} {:else}
<div class="filters"> <div class="filters">
<input <Field label="Search effective settings" hiddenLabel class="search"
><Input
aria-label="Search effective settings" aria-label="Search effective settings"
bind:value={query} bind:value={query}
placeholder="Search settings or sources…" placeholder="Search settings or sources…"
/><label class="check" /></Field
><label class="check"
><input type="checkbox" bind:checked={onlyOverrides} /> Overrides ({overlaps})</label ><input type="checkbox" bind:checked={onlyOverrides} /> Overrides ({overlaps})</label
> >
</div> </div>
@@ -379,13 +384,12 @@
font-size: var(--text-sm); font-size: var(--text-sm);
margin: 12px 0 20px; margin: 12px 0 20px;
} }
.server-label { .inspector :global(.server-label) {
display: grid; display: grid;
gap: 8px; gap: 8px;
font-size: var(--text-xs); font-size: var(--text-xs);
color: var(--text-secondary); color: var(--text-secondary);
} }
select,
input:not([type='checkbox']) { input:not([type='checkbox']) {
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
@@ -396,7 +400,6 @@
padding: 10px 12px; padding: 10px 12px;
font: var(--text-sm) var(--font-body); font: var(--text-sm) var(--font-body);
} }
select:focus-visible,
input:focus-visible, input:focus-visible,
button:focus-visible, button:focus-visible,
summary:focus-visible, summary:focus-visible,
@@ -480,12 +483,13 @@
color: var(--text-secondary); color: var(--text-secondary);
margin-left: 6px; margin-left: 6px;
} }
.filters > :global(.search) {
flex: 1 1 180px;
}
.filters { .filters {
margin: 16px 0 8px; margin: 16px 0 8px;
} }
.filters > input {
flex: 1 1 180px;
}
.check { .check {
font-size: var(--text-xs); font-size: var(--text-xs);
color: var(--text-secondary); color: var(--text-secondary);
@@ -532,6 +536,7 @@
color: var(--text-secondary); color: var(--text-secondary);
margin-top: 6px; margin-top: 6px;
} }
.source-label::before { .source-label::before {
content: '▸'; content: '▸';
} }
@@ -610,7 +615,6 @@
} }
} }
input:not([type='checkbox']):not([type='file']), input:not([type='checkbox']):not([type='file']),
select,
textarea { textarea {
border-color: var(--border-control); border-color: var(--border-control);
} }
+41 -34
View File
@@ -1,4 +1,7 @@
<script lang="ts"> <script lang="ts">
import Field from '$ui/components/primitives/Field.svelte';
import Select from '$ui/components/primitives/Select.svelte';
import Input from '$ui/components/primitives/Input.svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { contextHref } from '$lib/navigation-context.js'; import { contextHref } from '$lib/navigation-context.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
@@ -247,28 +250,35 @@
<ConfigInspector /> <ConfigInspector />
{:else} {:else}
{#if loading || artifacts.length || error}<div class="library-toolbar"> {#if loading || artifacts.length || error}<div class="library-toolbar">
<input <Field label="Search configs" hiddenLabel class="search"
><Input
type="search" type="search"
class="search"
aria-label="Search configs" aria-label="Search configs"
bind:value={query} bind:value={query}
placeholder="Search configs, maps, or servers…" placeholder="Search configs, maps, or servers…"
/> /></Field
<select aria-label="Filter by game" bind:value={game} >
<Field label="Filter by game" hiddenLabel
><Select aria-label="Filter by game" bind:value={game}
><option value="">All games</option>{#each gameOptions as option}<option ><option value="">All games</option>{#each gameOptions as option}<option
value={option}>{gameTitle(option)}</option value={option}>{gameTitle(option)}</option
>{/each}</select >{/each}</Select
></Field
> >
<select aria-label="Filter by scope" bind:value={scope} <Field label="Filter by scope" hiddenLabel
><Select aria-label="Filter by scope" bind:value={scope}
><option value="">All scopes</option><option value="game">Game</option ><option value="">All scopes</option><option value="game">Game</option
><option value="map">Map</option></select ><option value="map">Map</option></Select
></Field
> >
<select aria-label="Filter by status" bind:value={status} <Field label="Filter by status" hiddenLabel
><Select aria-label="Filter by status" bind:value={status}
><option value="">All statuses</option><option value="pending" ><option value="">All statuses</option><option value="pending"
>Awaiting promotion</option >Awaiting promotion</option
><option value="promoted">Has promoted version</option><option value="beta" ><option value="promoted">Has promoted version</option><option
>Active beta</option value="beta">Active beta</option
><option value="failed">Test failed</option></select ><option value="failed">Test failed</option></Select
></Field
> >
{#if filtered.length && (query || game || scope || status)}<Button {#if filtered.length && (query || game || scope || status)}<Button
size="sm" size="sm"
@@ -390,15 +400,15 @@
> >
</div> </div>
<div class="form-grid"> <div class="form-grid">
<label <Field label="Name">
>Name<input <Input
bind:value={name} bind:value={name}
placeholder={kind === 'config' ? 'No AFK timeout' : 'Black Ops I baseline'} placeholder={kind === 'config' ? 'No AFK timeout' : 'Black Ops I baseline'}
required required
disabled={saving} disabled={saving}
/></label /></Field
><label ><Field label="Game">
>Game<select <Select
aria-label="Game" aria-label="Game"
bind:value={scopeGame} bind:value={scopeGame}
onchange={() => (scopeMap = '')} onchange={() => (scopeMap = '')}
@@ -406,16 +416,16 @@
disabled={saving} disabled={saving}
><option value="" disabled>Choose a game</option ><option value="" disabled>Choose a game</option
>{#each gameOptions as option}<option value={option}>{gameTitle(option)}</option >{#each gameOptions as option}<option value={option}>{gameTitle(option)}</option
>{/each}</select >{/each}</Select
></label ></Field
> >
{#if kind === 'config'}<label {#if kind === 'config'}<Field label="Apply to">
>Apply to<select aria-label="Apply to" bind:value={scopeType} disabled={saving} <Select aria-label="Apply to" bind:value={scopeType} disabled={saving}
><option value="game">Whole game</option><option value="map">One map</option ><option value="game">Whole game</option><option value="map">One map</option
></select ></Select
></label ></Field
>{#if scopeType === 'map'}<label >{#if scopeType === 'map'}<Field label="Map">
>Map<select <Select
aria-label="Map" aria-label="Map"
bind:value={scopeMap} bind:value={scopeMap}
required required
@@ -423,8 +433,8 @@
><option value="" disabled>Choose a map</option ><option value="" disabled>Choose a map</option
>{#each scopeOptions.mapsByGame[scopeGame] ?? [] as option}<option >{#each scopeOptions.mapsByGame[scopeGame] ?? [] as option}<option
value={option}>{option}</option value={option}>{option}</option
>{/each}</select >{/each}</Select
></label ></Field
>{/if}{/if} >{/if}{/if}
</div> </div>
{#if baselineExists}<p class="warning"> {#if baselineExists}<p class="warning">
@@ -442,12 +452,12 @@
/></label /></label
> >
<ConfigTextEditor bind:text overlay={kind === 'config'} disabled={saving} /> <ConfigTextEditor bind:text overlay={kind === 'config'} disabled={saving} />
<label class="notes" <Field class="notes" label="Notes">
>Notes<input <Input
bind:value={notes} bind:value={notes}
placeholder="What is this config for?" placeholder="What is this config for?"
disabled={saving} disabled={saving}
/></label /></Field
> >
{#if createError}<div class="error" role="alert">{createError}</div>{/if} {#if createError}<div class="error" role="alert">{createError}</div>{/if}
<div class="dialog-actions"> <div class="dialog-actions">
@@ -504,13 +514,11 @@
} }
button:focus-visible, button:focus-visible,
input:focus-visible, input:focus-visible,
select:focus-visible,
a:focus-visible { a:focus-visible {
outline: 2px solid var(--accent-primary); outline: 2px solid var(--accent-primary);
outline-offset: 3px; outline-offset: 3px;
} }
input, input {
select {
box-sizing: border-box; box-sizing: border-box;
min-width: 0; min-width: 0;
background: var(--surface-sunken); background: var(--surface-sunken);
@@ -653,8 +661,7 @@
.loading { .loading {
padding: 24px 0; padding: 24px 0;
} }
input:not([type='checkbox']):not([type='file']), input:not([type='checkbox']):not([type='file']) {
select {
border-color: var(--border-control); border-color: var(--border-control);
} }
</style> </style>
+28 -34
View File
@@ -1,4 +1,6 @@
<script lang="ts"> <script lang="ts">
import Field from '$ui/components/primitives/Field.svelte';
import Input from '$ui/components/primitives/Input.svelte';
import { import {
addConfigSetting, addConfigSetting,
changeConfigValue, changeConfigValue,
@@ -69,44 +71,50 @@
></textarea> ></textarea>
{:else} {:else}
<div class="settings"> <div class="settings">
<input <Field label="Find a setting" hiddenLabel class="search"
class="search" ><Input
aria-label="Find a setting" aria-label="Find a setting"
bind:value={search} bind:value={search}
placeholder="Find a setting…" placeholder="Find a setting…"
/> /></Field
>
<p class="hint"> <p class="hint">
Edit values and inline comments. Other commands and formatting stay in the raw file. Edit values and inline comments. Other commands and formatting stay in the raw file.
</p> </p>
{#each visible as entry (entry.index)} {#each visible as entry (entry.index)}
<label class="setting" <Field class="setting"
><span><code>{entry.key}</code><small>Line {entry.index + 1}</small></span> >{#snippet label()}<span
<input ><code>{entry.key}</code><small>Line {entry.index + 1}</small></span
>{/snippet}
<Input
mono
value={entry.value} value={entry.value}
{disabled} {disabled}
aria-label={`${entry.key}, line ${entry.index + 1}`} aria-label={`${entry.key}, line ${entry.index + 1}`}
oninput={(e) => oninput={(e) =>
(text = changeConfigValue(text, entry.index, e.currentTarget.value))} (text = changeConfigValue(text, entry.index, e.currentTarget.value))}
/> />
</label> </Field>
{:else}<p class="hint"> {:else}<p class="hint">
{entries.length ? 'No matching settings.' : 'Add your first setting below.'} {entries.length ? 'No matching settings.' : 'Add your first setting below.'}
</p>{/each} </p>{/each}
<div class="add-setting"> <div class="add-setting">
<label <Field label="Setting name">
>Setting name<input <Input
mono
bind:value={newKey} bind:value={newKey}
onkeydown={addOnEnter} onkeydown={addOnEnter}
placeholder="g_inactivity" placeholder="g_inactivity"
{disabled} {disabled}
/></label /></Field
><label ><Field label="Value">
>Value<input <Input
mono
bind:value={newValue} bind:value={newValue}
onkeydown={addOnEnter} onkeydown={addOnEnter}
placeholder="0" placeholder="0"
{disabled} {disabled}
/></label /></Field
><Button ><Button
size="sm" size="sm"
onclick={addSetting} onclick={addSetting}
@@ -170,7 +178,6 @@
color: var(--text-secondary); color: var(--text-secondary);
} }
button:focus-visible, button:focus-visible,
input:focus-visible,
textarea:focus-visible { textarea:focus-visible {
outline: 2px solid var(--accent-primary); outline: 2px solid var(--accent-primary);
outline-offset: -2px; outline-offset: -2px;
@@ -189,8 +196,7 @@
font: var(--text-sm)/1.8 var(--font-mono); font: var(--text-sm)/1.8 var(--font-mono);
tab-size: 4; tab-size: 4;
} }
textarea:disabled, textarea:disabled {
input:disabled {
opacity: 0.65; opacity: 0.65;
} }
.settings { .settings {
@@ -198,17 +204,8 @@
max-height: 520px; max-height: 520px;
overflow: auto; overflow: auto;
} }
input {
box-sizing: border-box; .settings :global(.setting) {
background: var(--surface);
color: var(--text-primary);
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
padding: 9px 12px;
font: var(--text-sm) var(--font-mono);
width: 100%;
}
.setting {
display: grid; display: grid;
grid-template-columns: minmax(140px, 1fr) minmax(0, 1.4fr); grid-template-columns: minmax(140px, 1fr) minmax(0, 1.4fr);
align-items: center; align-items: center;
@@ -216,7 +213,7 @@
padding: 12px 0; padding: 12px 0;
border-top: 1px solid var(--border-subtle); border-top: 1px solid var(--border-subtle);
} }
.setting span { .settings :global(.setting span) {
min-width: 0; min-width: 0;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
@@ -228,11 +225,9 @@
border-top: 1px solid var(--border-strong); border-top: 1px solid var(--border-strong);
padding-top: 16px; padding-top: 16px;
} }
.add-setting label {
.add-setting :global(.field) {
flex: 1 1 140px; flex: 1 1 140px;
display: grid;
gap: 6px;
font-size: var(--text-sm);
} }
code { code {
font: var(--text-sm) var(--font-mono); font: var(--text-sm) var(--font-mono);
@@ -259,7 +254,7 @@
margin: 6px 0; margin: 6px 0;
} }
@media (max-width: 600px) { @media (max-width: 600px) {
.setting { .settings :global(.setting) {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 8px; gap: 8px;
} }
@@ -267,7 +262,6 @@
display: none; display: none;
} }
} }
input:not([type='checkbox']):not([type='file']),
textarea { textarea {
border-color: var(--border-control); border-color: var(--border-control);
} }
+10 -16
View File
@@ -1,4 +1,6 @@
<script lang="ts"> <script lang="ts">
import Select from '$ui/components/primitives/Select.svelte';
import Field from '$ui/components/primitives/Field.svelte';
import { onMount, onDestroy, tick } from 'svelte'; import { onMount, onDestroy, tick } from 'svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import Dialog from './primitives/Dialog.svelte'; import Dialog from './primitives/Dialog.svelte';
@@ -246,9 +248,9 @@
<p class="muted" role="status">Loading maps…</p> <p class="muted" role="status">Loading maps…</p>
{:else} {:else}
<div class="fields"> <div class="fields">
<label <Field label="Game">
>Game<select <Select
bind:this={gameSelect} bind:element={gameSelect}
bind:value={game} bind:value={game}
disabled={launching || unconfirmedLaunch} disabled={launching || unconfirmedLaunch}
onchange={(event) => { onchange={(event) => {
@@ -260,16 +262,16 @@
<option value="t5">Black Ops I</option><option value="t6" <option value="t5">Black Ops I</option><option value="t6"
>Black Ops II</option >Black Ops II</option
> >
</select></label </Select></Field
> >
<label <Field label="Map">
>Map<select <Select
bind:value={mapSlug} bind:value={mapSlug}
disabled={launching || unconfirmedLaunch} disabled={launching || unconfirmedLaunch}
onchange={selectionChanged} onchange={selectionChanged}
> >
{#each maps as map}<option value={map.slug}>{map.friendly}</option>{/each} {#each maps as map}<option value={map.slug}>{map.friendly}</option>{/each}
</select></label </Select></Field
> >
</div> </div>
@@ -414,15 +416,7 @@
grid-template-columns: 1fr 1.3fr; grid-template-columns: 1fr 1.3fr;
gap: 14px; gap: 14px;
} }
label {
display: grid;
gap: 8px;
color: var(--text-secondary);
font-size: var(--text-sm);
}
select {
width: 100%;
}
.summary { .summary {
display: grid; display: grid;
gap: 8px; gap: 8px;
@@ -1,4 +1,6 @@
<script lang="ts"> <script lang="ts">
import Select from '$ui/components/primitives/Select.svelte';
import Field from '$ui/components/primitives/Field.svelte';
import { untrack } from 'svelte'; import { untrack } from 'svelte';
import { currentRound, freshServer, type FleetServer } from '$lib/fleet-overview.js'; import { currentRound, freshServer, type FleetServer } from '$lib/fleet-overview.js';
import { actionLabels } from '$lib/provisioning.js'; import { actionLabels } from '$lib/provisioning.js';
@@ -130,10 +132,10 @@
wide wide
> >
<div class="fields"> <div class="fields">
<label class="action" <Field class="action" label="Action">
>Action<select bind:value={action} disabled={busy} onchange={chooseAction}> <Select bind:value={action} disabled={busy} onchange={chooseAction}>
{#each fleetActions as value}<option {value}>{actionLabels[value]}</option>{/each} {#each fleetActions as value}<option {value}>{actionLabels[value]}</option>{/each}
</select></label </Select></Field
> >
<p>{descriptions[action]}</p> <p>{descriptions[action]}</p>
{#if error}<p class="ui-error" role="alert">{error}</p>{/if} {#if error}<p class="ui-error" role="alert">{error}</p>{/if}
@@ -236,23 +238,14 @@
<style> <style>
.fields, .fields,
.action { .fields :global(.action) {
display: grid; display: grid;
gap: var(--space-4); gap: var(--space-4);
} }
.action { .fields :global(.action) {
gap: var(--space-2); gap: var(--space-2);
} }
select {
width: 100%;
min-height: 40px;
padding: 10px;
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--text-primary);
font: inherit;
}
p, p,
ul { ul {
margin: 0; margin: 0;
+9 -17
View File
@@ -1,4 +1,6 @@
<script lang="ts"> <script lang="ts">
import Select from '$ui/components/primitives/Select.svelte';
import Field from '$ui/components/primitives/Field.svelte';
import { untrack } from 'svelte'; import { untrack } from 'svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { currentRound, freshServer, type FleetServer } from '$lib/fleet-overview.js'; import { currentRound, freshServer, type FleetServer } from '$lib/fleet-overview.js';
@@ -145,20 +147,16 @@
<Dialog bind:open {title} {busy}> <Dialog bind:open {title} {busy}>
<div class="fields"> <div class="fields">
{#if !initialServer} {#if !initialServer}
<label <Field label="Server">
>Server <select <Select bind:value={server} disabled={busy || loading} onchange={chooseServer}>
bind:value={server}
disabled={busy || loading}
onchange={chooseServer}
>
<option value="">{loading ? 'Loading servers…' : 'Choose a server'}</option> <option value="">{loading ? 'Loading servers…' : 'Choose a server'}</option>
{#each servers as item (item.name)}<option value={item.name} {#each servers as item (item.name)}<option value={item.name}
>{serverTitle(item.name)}</option >{serverTitle(item.name)}</option
>{/each} >{/each}
</select></label </Select></Field
> >
<label <Field label="Action">
>Action <select <Select
bind:value={action} bind:value={action}
disabled={busy || !server || loading} disabled={busy || !server || loading}
onchange={() => { onchange={() => {
@@ -172,7 +170,7 @@
>{/if} >{/if}
{#each available as value}<option {value}>{actionLabels[value]}</option>{/each} {#each available as value}<option {value}>{actionLabels[value]}</option>{/each}
{#if server}<option value="destroy">Destroy server</option>{/if} {#if server}<option value="destroy">Destroy server</option>{/if}
</select></label </Select></Field
> >
{/if} {/if}
{#if loading}<p role="status">Checking the latest server status…</p> {#if loading}<p role="status">Checking the latest server status…</p>
@@ -219,13 +217,7 @@
display: grid; display: grid;
gap: 8px; gap: 8px;
} }
select {
padding: 10px;
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--text-primary);
}
p { p {
margin: 0; margin: 0;
line-height: 1.6; line-height: 1.6;
+5 -15
View File
@@ -1,4 +1,6 @@
<script lang="ts"> <script lang="ts">
import Input from '$ui/components/primitives/Input.svelte';
import Field from '$ui/components/primitives/Field.svelte';
import { tick, untrack, onMount, onDestroy } from 'svelte'; import { tick, untrack, onMount, onDestroy } from 'svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
@@ -193,12 +195,8 @@
retryDisabled={loading} retryDisabled={loading}
/> />
<div class="log-tools"> <div class="log-tools">
<label class="log-search" <Field class="log-search" label="Search loaded lines">
>Search loaded lines<input <Input type="search" bind:value={query} placeholder="Find text in this log" /></Field
type="search"
bind:value={query}
placeholder="Find text in this log"
/></label
> >
<details class="log-actions"> <details class="log-actions">
<summary>More</summary> <summary>More</summary>
@@ -302,7 +300,7 @@
.log-tools { .log-tools {
align-items: end; align-items: end;
} }
.log-search { .log-tools :global(.log-search) {
display: grid; display: grid;
gap: 6px; gap: 6px;
flex: 1; flex: 1;
@@ -310,14 +308,6 @@
font-size: var(--text-xs); font-size: var(--text-xs);
color: var(--text-secondary); color: var(--text-secondary);
} }
.log-search input {
background: var(--surface-sunken);
color: var(--text-primary);
border: 1px solid var(--border-control);
border-radius: var(--radius-sm);
padding: 9px 12px;
font: inherit;
}
.copy-status { .copy-status {
margin: 0; margin: 0;
font-size: var(--text-xs); font-size: var(--text-xs);
+40 -46
View File
@@ -1,4 +1,7 @@
<script lang="ts"> <script lang="ts">
import Input from '$ui/components/primitives/Input.svelte';
import Select from '$ui/components/primitives/Select.svelte';
import Field from '$ui/components/primitives/Field.svelte';
import RelativeTime from '$ui/components/RelativeTime.svelte'; import RelativeTime from '$ui/components/RelativeTime.svelte';
import { untrack } from 'svelte'; import { untrack } from 'svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
@@ -129,17 +132,17 @@
<section class="history" aria-label="Match history"> <section class="history" aria-label="Match history">
{#snippet filterFields(suffix: string)} {#snippet filterFields(suffix: string)}
<div class="filters"> <div class="filters">
{#if server === undefined}<label for={`match-game${suffix}`} {#if server === undefined}<Field forId={`match-game${suffix}`} label="Game">
>Game<select <Select
id={`match-game${suffix}`} id={`match-game${suffix}`}
value={filters.game} value={filters.game}
onchange={(event) => update({ game: event.currentTarget.value, map: '' })} onchange={(event) => update({ game: event.currentTarget.value, map: '' })}
><option value="">All games</option><option value="t5">Black Ops I</option ><option value="">All games</option><option value="t5">Black Ops I</option
><option value="t6">Black Ops II</option></select ><option value="t6">Black Ops II</option></Select
></label ></Field
>{/if} >{/if}
<label for={`match-map${suffix}`} <Field forId={`match-map${suffix}`} label="Map">
>Map<select <Select
id={`match-map${suffix}`} id={`match-map${suffix}`}
value={filters.map} value={filters.map}
onchange={(event) => update({ map: event.currentTarget.value })} onchange={(event) => update({ map: event.currentTarget.value })}
@@ -153,7 +156,7 @@
? ` · ${map.game === 't5' ? 'BO1' : 'BO2'}` ? ` · ${map.game === 't5' ? 'BO1' : 'BO2'}`
: ''}</option : ''}</option
>{/each} >{/each}
</select></label </Select></Field
> >
{#if server === undefined}<form {#if server === undefined}<form
role="search" role="search"
@@ -163,32 +166,32 @@
if (suffix) filtersOpen = false; if (suffix) filtersOpen = false;
}} }}
> >
<label for={`match-search${suffix}`} <Field forId={`match-search${suffix}`} label="Server">
>Server<input <Input
id={`match-search${suffix}`} id={`match-search${suffix}`}
type="search" type="search"
bind:value={search} bind:value={search}
maxlength="100" maxlength={100}
placeholder="Find a server…" placeholder="Find a server…"
aria-describedby={`match-search-help${suffix}`} aria-describedby={`match-search-help${suffix}`}
/></label /></Field
> >
<span id={`match-search-help${suffix}`} class="sr-only" <span id={`match-search-help${suffix}`} class="sr-only"
>Press Enter to search by server name.</span >Press Enter to search by server name.</span
> >
</form>{/if} </form>{/if}
<label for={`match-days${suffix}`} <Field forId={`match-days${suffix}`} label="Date range">
>Date range<select <Select
id={`match-days${suffix}`} id={`match-days${suffix}`}
value={String(filters.days)} value={String(filters.days)}
onchange={(event) => update({ days: event.currentTarget.value })} onchange={(event) => update({ days: event.currentTarget.value })}
><option value="0">All time</option><option value="7">Last 7 days</option ><option value="0">All time</option><option value="7">Last 7 days</option
><option value="30">Last 30 days</option><option value="90">Last 90 days</option ><option value="30">Last 30 days</option><option value="90">Last 90 days</option
></select ></Select
></label ></Field
> >
<label for={`match-status${suffix}`} <Field forId={`match-status${suffix}`} label="Status">
>Status<select <Select
id={`match-status${suffix}`} id={`match-status${suffix}`}
value={filters.status} value={filters.status}
onchange={(event) => update({ status: event.currentTarget.value })} onchange={(event) => update({ status: event.currentTarget.value })}
@@ -196,28 +199,28 @@
>Completed</option >Completed</option
><option value="active">In progress</option><option value="interrupted" ><option value="active">In progress</option><option value="interrupted"
>Interrupted</option >Interrupted</option
></select ></Select
></label ></Field
> >
{#if suffix} {#if suffix}
<label for={`match-sort${suffix}`} <Field forId={`match-sort${suffix}`} label="Sort by">
>Sort by<select <Select
id={`match-sort${suffix}`} id={`match-sort${suffix}`}
value={filters.sort} value={filters.sort}
onchange={(event) => update({ sort: event.currentTarget.value })} onchange={(event) => update({ sort: event.currentTarget.value })}
>{#each sorts as item}<option value={item.key}>{item.label}</option >{#each sorts as item}<option value={item.key}>{item.label}</option
>{/each}</select >{/each}</Select
></label ></Field
> >
<label for={`match-order${suffix}`} <Field forId={`match-order${suffix}`} label="Order">
>Order<select <Select
id={`match-order${suffix}`} id={`match-order${suffix}`}
value={filters.direction} value={filters.direction}
onchange={(event) => update({ direction: event.currentTarget.value })} onchange={(event) => update({ direction: event.currentTarget.value })}
><option value="desc">Highest or newest first</option><option value="asc" ><option value="desc">Highest or newest first</option><option value="asc"
>Lowest or oldest first</option >Lowest or oldest first</option
></select ></Select
></label ></Field
> >
{/if} {/if}
</div> </div>
@@ -460,30 +463,19 @@
flex-wrap: wrap; flex-wrap: wrap;
gap: var(--space-3); gap: var(--space-3);
} }
label,
form { form {
flex: 1; flex: 1;
min-width: 145px; min-width: 145px;
} }
label { .filters :global(.field) {
display: flex; flex: 1;
min-width: 145px;
}
.filters :global(.field) {
flex-direction: column; flex-direction: column;
gap: 6px;
color: var(--text-secondary);
font-size: var(--text-xs);
}
input,
select {
width: 100%;
min-height: 40px;
padding: 8px 10px;
border: 1px solid var(--border-control);
border-radius: var(--radius-md);
background: var(--surface-sunken);
color: var(--text-primary);
font: inherit;
font-size: var(--text-sm);
} }
summary { summary {
cursor: pointer; cursor: pointer;
color: var(--text-secondary); color: var(--text-secondary);
@@ -640,10 +632,12 @@
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
} }
.filters > label,
.filters > form { .filters > form {
min-width: 0; min-width: 0;
} }
.filters > :global(.field) {
min-width: 0;
}
.context { .context {
gap: var(--space-1) var(--space-2); gap: var(--space-1) var(--space-2);
} }
@@ -1,4 +1,6 @@
<script lang="ts"> <script lang="ts">
import Input from '$ui/components/primitives/Input.svelte';
import Field from '$ui/components/primitives/Field.svelte';
import { gameTitle } from '$ui/server-labels.js'; import { gameTitle } from '$ui/server-labels.js';
import RelativeTime from '$ui/components/RelativeTime.svelte'; import RelativeTime from '$ui/components/RelativeTime.svelte';
import type { Host, NativeDeploymentSelection } from '$lib/hosts.js'; import type { Host, NativeDeploymentSelection } from '$lib/hosts.js';
@@ -148,36 +150,36 @@
</script> </script>
{#snippet runtimeFields()} {#snippet runtimeFields()}
<label <Field label={compact ? 'Runtime image' : 'Plutainer runtime image'}>
>{compact ? 'Runtime image' : 'Plutainer runtime image'}<input <Input
required required
bind:value={selection.image} bind:value={selection.image}
{disabled} {disabled}
oninput={() => onchange?.()} oninput={() => onchange?.()}
/></label /></Field
> >
{#if compact}<p>Custom images must be prepared on a host before it can run this server.</p>{/if} {#if compact}<p>Custom images must be prepared on a host before it can run this server.</p>{/if}
{/snippet} {/snippet}
{#snippet networkFields()} {#snippet networkFields()}
<label <Field label="Local bind address">
>Local bind address<input <Input
bind:value={selection.bindAddress} bind:value={selection.bindAddress}
placeholder="Host default" placeholder="Host default"
{disabled} {disabled}
oninput={() => onchange?.()} oninput={() => onchange?.()}
/></label /></Field
> >
<label <Field label="Public / NAT address">
>Public / NAT address<input <Input
bind:value={selection.publicAddress} bind:value={selection.publicAddress}
placeholder="Host default" placeholder="Host default"
{disabled} {disabled}
oninput={() => onchange?.()} oninput={() => onchange?.()}
/></label /></Field
> >
<label <Field label="Public / NAT port">
>Public / NAT port<input <Input
type="number" type="number"
min="1" min="1"
max="65535" max="65535"
@@ -185,7 +187,7 @@
placeholder="Same as local port" placeholder="Same as local port"
{disabled} {disabled}
oninput={() => onchange?.()} oninput={() => onchange?.()}
/></label /></Field
> >
<p>Public mapping does not change the local Docker port reservation.</p> <p>Public mapping does not change the local Docker port reservation.</p>
{/snippet} {/snippet}
@@ -317,21 +319,7 @@
font-size: var(--text-sm); font-size: var(--text-sm);
margin: 0; margin: 0;
} }
label {
display: grid;
gap: 7px;
font-size: var(--text-sm);
}
input {
width: 100%;
box-sizing: border-box;
padding: 11px;
color: var(--text-primary);
background: var(--surface);
border: 1px solid var(--border-control);
border-radius: var(--radius-sm);
font: inherit;
}
p { p {
color: var(--text-secondary); color: var(--text-secondary);
font-size: var(--text-xs); font-size: var(--text-xs);
@@ -360,7 +348,8 @@
cursor: pointer; cursor: pointer;
font-size: var(--text-sm); font-size: var(--text-sm);
} }
details label {
details :global(.field) {
margin-top: 14px; margin-top: 14px;
} }
details p { details p {
+13 -23
View File
@@ -1,4 +1,7 @@
<script lang="ts"> <script lang="ts">
import Select from '$ui/components/primitives/Select.svelte';
import Input from '$ui/components/primitives/Input.svelte';
import Field from '$ui/components/primitives/Field.svelte';
import RelativeTime from '$ui/components/RelativeTime.svelte'; import RelativeTime from '$ui/components/RelativeTime.svelte';
import type { ActivityEvent, OperationStep } from '$lib/operation-flow.js'; import type { ActivityEvent, OperationStep } from '$lib/operation-flow.js';
import Button from './primitives/Button.svelte'; import Button from './primitives/Button.svelte';
@@ -54,19 +57,19 @@
> >
{#if events.length} {#if events.length}
<div class="tools"> <div class="tools">
<label <Field label="Search activity">
>Search activity<input <Input
type="search" type="search"
bind:value={query} bind:value={query}
oninput={() => (limit = 25)} oninput={() => (limit = 25)}
placeholder="Server, stage, or message" placeholder="Server, stage, or message"
/></label /></Field
><label ><Field label="Order">
>Order<select bind:value={newestFirst} <Select bind:value={newestFirst}
><option value={true}>Newest first</option><option value={false} ><option value={true}>Newest first</option><option value={false}
>Oldest first</option >Oldest first</option
></select ></Select
></label ></Field
> >
</div> </div>
<ol class="timeline"> <ol class="timeline">
@@ -133,25 +136,12 @@
gap: 12px; gap: 12px;
margin-bottom: 20px; margin-bottom: 20px;
} }
label {
display: grid; .tools > :global(.field:first-child) {
gap: 6px;
font-size: var(--text-xs);
color: var(--text-secondary);
}
label:first-child {
flex: 1; flex: 1;
min-width: 180px; min-width: 180px;
} }
input,
select {
background: var(--surface-sunken);
color: var(--text-primary);
border: 1px solid var(--border-control);
padding: 9px 12px;
border-radius: var(--radius-sm);
font: inherit;
}
.time, .time,
.muted { .muted {
color: var(--text-secondary); color: var(--text-secondary);
+19 -40
View File
@@ -1,4 +1,7 @@
<script lang="ts"> <script lang="ts">
import Select from '$ui/components/primitives/Select.svelte';
import Input from '$ui/components/primitives/Input.svelte';
import Field from '$ui/components/primitives/Field.svelte';
import { timeFormat } from '$ui/stores/time-zone.js'; import { timeFormat } from '$ui/stores/time-zone.js';
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
@@ -184,9 +187,8 @@
<p>Import a release below to track updates for this plugin.</p> <p>Import a release below to track updates for this plugin.</p>
{/if} {/if}
<form onsubmit={preview} class="lookup"> <form onsubmit={preview} class="lookup">
<label <Field label="Repository or release URL">
>Repository or release URL <Input
<input
type="url" type="url"
bind:value={url} bind:value={url}
oninput={() => { oninput={() => {
@@ -197,7 +199,7 @@
placeholder="https://github.com/alicealys/t6-gsc-utils/releases/tag/v3.1.0" placeholder="https://github.com/alicealys/t6-gsc-utils/releases/tag/v3.1.0"
disabled={busy || disabled} disabled={busy || disabled}
/> />
</label> </Field>
<Button <Button
type="submit" type="submit"
loading={busy && !release} loading={busy && !release}
@@ -228,29 +230,29 @@
</p> </p>
{:else} {:else}
<div class="fields"> <div class="fields">
<label <Field label="DLL asset">
>DLL asset<select bind:value={assetId} disabled={busy || disabled}> <Select bind:value={assetId} disabled={busy || disabled}>
<option value="" disabled>Choose a DLL…</option> <option value="" disabled>Choose a DLL…</option>
{#each release.assets as asset}<option value={String(asset.id)} {#each release.assets as asset}<option value={String(asset.id)}
>{asset.name} · {(asset.size / 1024).toFixed(1)} KB</option >{asset.name} · {(asset.size / 1024).toFixed(1)} KB</option
>{/each} >{/each}
</select></label </Select></Field
> >
{#if !pluginId} {#if !pluginId}
<label <Field label="Plugin name">
>Plugin name<input <Input
bind:value={name} bind:value={name}
disabled={busy} disabled={busy}
placeholder="t6-gsc-utils" placeholder="t6-gsc-utils"
/></label /></Field
> >
<label <Field label="Deploy scope">
>Deploy scope<select bind:value={game} disabled={busy} <Select bind:value={game} disabled={busy}
><option value="">Unscoped — select servers in a beta</option ><option value="">Unscoped — select servers in a beta</option
><option value="t5">Black Ops I</option><option value="t6" ><option value="t5">Black Ops I</option><option value="t6"
>Black Ops II</option >Black Ops II</option
></select ></Select
></label ></Field
> >
{/if} {/if}
</div> </div>
@@ -334,30 +336,11 @@
gap: var(--space-3); gap: var(--space-3);
flex-wrap: wrap; flex-wrap: wrap;
} }
.lookup label {
.lookup :global(.field) {
flex: 1 1 300px; flex: 1 1 300px;
} }
label {
display: grid;
gap: var(--space-2);
font-size: var(--text-sm);
}
input,
select {
width: 100%;
min-width: 0;
padding: 10px 12px;
background: var(--surface-sunken);
border: 1px solid var(--border-strong);
border-radius: var(--radius-md);
color: var(--text-primary);
font: inherit;
}
input:focus-visible,
select:focus-visible {
outline: 2px solid var(--accent-primary);
outline-offset: 2px;
}
.fields { .fields {
display: grid; display: grid;
gap: var(--space-3); gap: var(--space-3);
@@ -404,8 +387,4 @@
font: var(--text-xs) var(--font-mono); font: var(--text-xs) var(--font-mono);
margin-top: var(--space-2); margin-top: var(--space-2);
} }
input:not([type='checkbox']):not([type='file']),
select {
border-color: var(--border-control);
}
</style> </style>
@@ -1,4 +1,7 @@
<script lang="ts"> <script lang="ts">
import Select from '$ui/components/primitives/Select.svelte';
import Input from '$ui/components/primitives/Input.svelte';
import Field from '$ui/components/primitives/Field.svelte';
import { timestampISO } from '$lib/time-zone.js'; import { timestampISO } from '$lib/time-zone.js';
import { timeFormat } from '$ui/stores/time-zone.js'; import { timeFormat } from '$ui/stores/time-zone.js';
import { onDestroy } from 'svelte'; import { onDestroy } from 'svelte';
@@ -125,23 +128,23 @@
void load(); void load();
}} }}
> >
<label class="search" <Field class="search" label="Search activity">
>Search activity<input <Input
type="search" type="search"
bind:value={query} bind:value={query}
placeholder="Find a stage or message" placeholder="Find a stage or message"
/></label /></Field
> >
<label <Field label="Order">
>Order<select <Select
bind:value={order} bind:value={order}
onchange={() => { onchange={() => {
void load(); void load();
}} }}
><option value="newest">Newest first</option><option value="oldest" ><option value="newest">Newest first</option><option value="oldest"
>Oldest first</option >Oldest first</option
></select ></Select
></label ></Field
> >
<Button type="submit" disabled={loading}>Search</Button> <Button type="submit" disabled={loading}>Search</Button>
</form> </form>
@@ -270,31 +273,11 @@
align-items: end; align-items: end;
gap: var(--space-3); gap: var(--space-3);
} }
.tools label {
display: grid; .tools :global(.search) {
gap: var(--space-2);
font-size: var(--text-xs);
color: var(--text-secondary);
}
.search {
flex: 1 1 160px; flex: 1 1 160px;
} }
input,
select {
background: var(--surface-sunken);
color: var(--text-primary);
border: 1px solid var(--border-control);
border-radius: var(--radius-sm);
padding: 10px;
font: inherit;
min-height: 40px;
width: 100%;
}
input:focus-visible,
select:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: 2px;
}
.new-events { .new-events {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -333,7 +316,7 @@
.tools { .tools {
align-items: stretch; align-items: stretch;
} }
.tools .search { .tools :global(.search) {
flex-basis: 100%; flex-basis: 100%;
} }
} }
+11 -18
View File
@@ -1,4 +1,7 @@
<script lang="ts"> <script lang="ts">
import Input from '$ui/components/primitives/Input.svelte';
import Select from '$ui/components/primitives/Select.svelte';
import Field from '$ui/components/primitives/Field.svelte';
import type { FleetSettings } from '$lib/fleet-settings.js'; import type { FleetSettings } from '$lib/fleet-settings.js';
let { settings }: { settings: FleetSettings } = $props(); let { settings }: { settings: FleetSettings } = $props();
import Button from './primitives/Button.svelte'; import Button from './primitives/Button.svelte';
@@ -141,12 +144,14 @@
retry requests for that campaign; unfinished recovery still prevents cleanup. retry requests for that campaign; unfinished recovery still prevents cleanup.
</p> </p>
<div class="record"> <div class="record">
<label <Field label="Category">
>Category<select bind:value={category} <Select bind:value={category}
>{#each categories as value}<option {value}>{value}</option>{/each}</select >{#each categories as value}<option {value}>{value}</option>{/each}</Select
></label ></Field
>
<Field label="Record ID">
<Input bind:value={recordId} maxlength={200} /></Field
> >
<label>Record ID<input bind:value={recordId} maxlength="200" /></label>
<Button disabled={busy || !recordId} onclick={() => control({ protected: true })} <Button disabled={busy || !recordId} onclick={() => control({ protected: true })}
>Protect record</Button >Protect record</Button
> >
@@ -179,19 +184,7 @@
code { code {
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
label {
display: grid;
gap: 8px;
}
select,
input {
font: inherit;
color: var(--text-primary);
background: var(--surface-sunken);
border: 1px solid var(--border-control);
border-radius: var(--radius-sm);
padding: 8px;
}
.table-wrap { .table-wrap {
overflow-x: auto; overflow-x: auto;
margin: 16px 0; margin: 16px 0;
+20 -20
View File
@@ -1,4 +1,7 @@
<script lang="ts"> <script lang="ts">
import Input from '$ui/components/primitives/Input.svelte';
import Select from '$ui/components/primitives/Select.svelte';
import Field from '$ui/components/primitives/Field.svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { tick } from 'svelte'; import { tick } from 'svelte';
import { plural } from '$ui/plural.js'; import { plural } from '$ui/plural.js';
@@ -306,8 +309,8 @@
<a href="/scripts/{item.result.scriptId}">View script →</a> <a href="/scripts/{item.result.scriptId}">View script →</a>
</p> </p>
{:else} {:else}
<label <Field label="Destination">
>Destination<select <Select
bind:value={item.target} bind:value={item.target}
disabled={busy || item.inspecting} disabled={busy || item.inspecting}
onchange={() => (item.error = '')} onchange={() => (item.error = '')}
@@ -320,10 +323,10 @@
? ' · beta active' ? ' · beta active'
: ''}</option : ''}</option
>{/each} >{/each}
</select></label </Select></Field
> >
{#if item.target === 'new'}<label {#if item.target === 'new'}<Field label="Script name">
>Script name<input bind:value={item.name} disabled={busy} /></label <Input bind:value={item.name} disabled={busy} /></Field
> >
{:else if item.target !== ''}{@const s = scripts.find( {:else if item.target !== ''}{@const s = scripts.find(
(s) => s.id === item.target (s) => s.id === item.target
@@ -362,8 +365,8 @@
></textarea></label ></textarea></label
> >
{#if hasNew} {#if hasNew}
<label <Field label="Scope for new scripts">
>Scope for new scripts<select <Select
bind:value={scopeType} bind:value={scopeType}
disabled={busy} disabled={busy}
onchange={() => { onchange={() => {
@@ -372,28 +375,28 @@
}} }}
><option value="none">Unscoped · choose beta targets</option><option ><option value="none">Unscoped · choose beta targets</option><option
value="game">Game</option value="game">Game</option
><option value="map">Map</option></select ><option value="map">Map</option></Select
></label ></Field
> >
{#if scopeType !== 'none'} {#if scopeType !== 'none'}
<label <Field label="Game">
>Game<select <Select
bind:value={scopeGame} bind:value={scopeGame}
disabled={busy || !scopeLoaded} disabled={busy || !scopeLoaded}
onchange={() => (scopeMap = '')} onchange={() => (scopeMap = '')}
><option value="">Choose a game…</option ><option value="">Choose a game…</option
>{#each scopeOptions.games as g}<option value={g} >{#each scopeOptions.games as g}<option value={g}
>{gameTitle(g)}</option >{gameTitle(g)}</option
>{/each}</select >{/each}</Select
></label ></Field
> >
{#if scopeType === 'map'}<label {#if scopeType === 'map'}<Field label="Map">
>Map<select bind:value={scopeMap} disabled={busy || !scopeGame} <Select bind:value={scopeMap} disabled={busy || !scopeGame}
><option value="">Choose a map…</option ><option value="">Choose a map…</option
>{#each scopeOptions.mapsByGame[scopeGame] ?? [] as m}<option >{#each scopeOptions.mapsByGame[scopeGame] ?? [] as m}<option
value={m}>{mapLabel(m)}</option value={m}>{mapLabel(m)}</option
>{/each}</select >{/each}</Select
></label ></Field
>{/if} >{/if}
{#if scopeValue}<p class="note"> {#if scopeValue}<p class="note">
{matches.length} matching servers: {matches {matches.length} matching servers: {matches
@@ -548,7 +551,6 @@
color: var(--text-secondary); color: var(--text-secondary);
} }
input, input,
select,
textarea { textarea {
box-sizing: border-box; box-sizing: border-box;
width: 100%; width: 100%;
@@ -565,7 +567,6 @@
resize: vertical; resize: vertical;
} }
input:focus-visible, input:focus-visible,
select:focus-visible,
textarea:focus-visible { textarea:focus-visible {
outline: 2px solid var(--accent-primary); outline: 2px solid var(--accent-primary);
outline-offset: 2px; outline-offset: 2px;
@@ -622,7 +623,6 @@
} }
} }
input:not([type='checkbox']):not([type='file']), input:not([type='checkbox']):not([type='file']),
select,
textarea { textarea {
border-color: var(--border-control); border-color: var(--border-control);
} }
+31 -32
View File
@@ -1,4 +1,7 @@
<script lang="ts"> <script lang="ts">
import Select from '$ui/components/primitives/Select.svelte';
import Field from '$ui/components/primitives/Field.svelte';
import Input from '$ui/components/primitives/Input.svelte';
import { untrack } from 'svelte'; import { untrack } from 'svelte';
import { beforeNavigate, goto } from '$app/navigation'; import { beforeNavigate, goto } from '$app/navigation';
import { confirmAction } from '$ui/confirm.js'; import { confirmAction } from '$ui/confirm.js';
@@ -155,18 +158,18 @@
<svelte:window onbeforeunload={beforeUnload} /> <svelte:window onbeforeunload={beforeUnload} />
<div class="settings-layout"> <div class="settings-layout">
<aside class="section-rail"> <aside class="section-rail">
<label class="search-label" for="settings-search">Find a setting</label> <Field label="Find a setting" forId="settings-search">
<input <Input
id="settings-search" id="settings-search"
type="search" type="search"
placeholder="Search settings" placeholder="Search settings"
bind:value={query} bind:value={query}
disabled={saving} disabled={saving}
oninput={() => (reviewInvalid = false)} oninput={() => (reviewInvalid = false)}
/> /></Field
<label class="mobile-section" >
>Section <Field class="mobile-section" label="Section">
<select <Select
aria-label="Settings section" aria-label="Settings section"
value={selectedGroup} value={selectedGroup}
disabled={saving} disabled={saving}
@@ -177,8 +180,8 @@
? ` · ${changed.filter((key) => group.keys.includes(key)).length} changed` ? ` · ${changed.filter((key) => group.keys.includes(key)).length} changed`
: ''}</option : ''}</option
>{/each} >{/each}
</select> </Select>
</label> </Field>
<nav class="group-links" aria-label="Settings sections"> <nav class="group-links" aria-label="Settings sections">
{#each settingsGroups as group} {#each settingsGroups as group}
{@const count = changed.filter((key) => group.keys.includes(key)).length} {@const count = changed.filter((key) => group.keys.includes(key)).length}
@@ -263,17 +266,23 @@
> >
<td class="value-cell"> <td class="value-cell">
<div class="numeric-value"> <div class="numeric-value">
{#if unlimited}<input {#if unlimited}<Field
><Input
id={`setting-${key}`} id={`setting-${key}`}
type="number" type="number"
disabled disabled
placeholder="—" placeholder="—"
aria-describedby={`setting-${key}-help setting-${key}-default`} aria-describedby={`setting-${key}-help setting-${key}-default`}
/> /></Field
{:else}<input >
{:else}<Field
><Input
id={`setting-${key}`} id={`setting-${key}`}
type="number" type="number"
min={Math.max(1, settingMinimum(key))} min={Math.max(
1,
settingMinimum(key)
)}
max={key.endsWith('Days') max={key.endsWith('Days')
? 36500 ? 36500
: undefined} : undefined}
@@ -286,7 +295,8 @@
error = ''; error = '';
notice = ''; notice = '';
}} }}
/>{/if} /></Field
>{/if}
<span class="unit">{settingUnit(key)}</span> <span class="unit">{settingUnit(key)}</span>
</div> </div>
{#if zeroMode}<label {#if zeroMode}<label
@@ -393,13 +403,7 @@
gap: var(--space-2); gap: var(--space-2);
min-width: 0; min-width: 0;
} }
.search-label {
font-size: var(--text-sm);
color: var(--text-secondary);
}
#settings-search {
width: 100%;
}
.group-links { .group-links {
display: grid; display: grid;
gap: var(--space-1); gap: var(--space-1);
@@ -521,17 +525,15 @@
flex-wrap: wrap; flex-wrap: wrap;
gap: var(--space-1); gap: var(--space-1);
} }
.numeric-value input {
width: 88px;
min-height: 32px;
padding: 5px var(--space-2);
font-family: var(--font-numeric);
font-variant-numeric: tabular-nums;
}
input:disabled { input:disabled {
color: var(--text-tertiary); color: var(--text-tertiary);
opacity: 0.75; opacity: 0.75;
} }
.numeric-value > :global(.field) {
width: 100px;
flex: 0 0 auto;
}
.unit { .unit {
color: var(--text-secondary); color: var(--text-secondary);
font-size: var(--text-xs); font-size: var(--text-xs);
@@ -646,7 +648,7 @@
.notice { .notice {
color: var(--state-online); color: var(--state-online);
} }
.mobile-section, .section-rail :global(.mobile-section),
.mobile-label { .mobile-label {
display: none; display: none;
} }
@@ -680,7 +682,7 @@
.group-links { .group-links {
display: none; display: none;
} }
.mobile-section { .section-rail :global(.mobile-section) {
display: grid; display: grid;
grid-template-columns: auto minmax(0, 1fr); grid-template-columns: auto minmax(0, 1fr);
align-items: center; align-items: center;
@@ -688,9 +690,6 @@
font-size: var(--text-sm); font-size: var(--text-sm);
color: var(--text-secondary); color: var(--text-secondary);
} }
.mobile-section select {
width: 100%;
}
.table-wrap { .table-wrap {
overflow: visible; overflow: visible;
} }
@@ -1,4 +1,7 @@
<script lang="ts"> <script lang="ts">
import Input from '$ui/components/primitives/Input.svelte';
import Select from '$ui/components/primitives/Select.svelte';
import Field from '$ui/components/primitives/Field.svelte';
import { onMount, untrack } from 'svelte'; import { onMount, untrack } from 'svelte';
import type { import type {
RebalancePreview, RebalancePreview,
@@ -254,15 +257,15 @@
{/each} {/each}
</div> </div>
<div class="settings"> <div class="settings">
<label <Field label="Game">
>Game<select bind:value={game} onchange={editSettings} <Select bind:value={game} onchange={editSettings}
><option value="all">All games</option><option value="t5" ><option value="all">All games</option><option value="t5"
>Black Ops I</option >Black Ops I</option
><option value="t6">Black Ops II</option></select ><option value="t6">Black Ops II</option></Select
></label ></Field
> >
<label <Field class="max-moves" label="Maximum moves">
>Maximum moves<input <Input
type="number" type="number"
min="1" min="1"
max="20" max="20"
@@ -270,7 +273,7 @@
required required
bind:value={maxMoves} bind:value={maxMoves}
oninput={editSettings} oninput={editSettings}
/></label /></Field
> >
<Button <Button
type="submit" type="submit"
@@ -301,16 +304,20 @@
<section class="runs" aria-labelledby="rebalance-runs-title"> <section class="runs" aria-labelledby="rebalance-runs-title">
<div class="heading"> <div class="heading">
<h3 id="rebalance-runs-title">Rebalance runs</h3> <h3 id="rebalance-runs-title">Rebalance runs</h3>
{#if report.runs.length}<label class="run-picker" {#if report.runs.length}<Field
><span class="sr-only">Select rebalance run</span><select class="run-picker"
hiddenLabel
label="Select rebalance run"
>
<Select
value={run?.id ?? ''} value={run?.id ?? ''}
onchange={(event) => (selectedRun = event.currentTarget.value)} onchange={(event) => (selectedRun = event.currentTarget.value)}
>{#each report.runs as item (item.id)}<option value={item.id} >{#each report.runs as item (item.id)}<option value={item.id}
>{$timeFormat.dateTime(item.createdAt)} · {rebalanceRunLabels[ >{$timeFormat.dateTime(item.createdAt)} · {rebalanceRunLabels[
item.state item.state
]}</option ]}</option
>{/each}</select >{/each}</Select
></label ></Field
>{/if} >{/if}
</div> </div>
{#if preferredRun && !report.runs.some((item) => item.id === preferredRun)}<p {#if preferredRun && !report.runs.some((item) => item.id === preferredRun)}<p
@@ -422,37 +429,18 @@
opacity: 0.65; opacity: 0.65;
cursor: default; cursor: default;
} }
.settings > :global(.max-moves) {
width: 110px;
}
.settings { .settings {
justify-content: flex-start; justify-content: flex-start;
align-items: end; align-items: end;
} }
.settings label {
display: grid; input:disabled {
gap: 8px;
font-size: var(--text-xs);
}
.settings input {
width: 110px;
}
select,
input[type='number'] {
box-sizing: border-box;
padding: 9px 12px;
color: var(--text-primary);
background: var(--surface-sunken);
border: 1px solid var(--border-control);
border-radius: var(--radius-sm);
font: var(--text-sm) var(--font-body);
min-width: 0;
}
input:disabled,
select:disabled {
opacity: 0.6; opacity: 0.6;
} }
.run-picker { .heading :global(.run-picker) {
max-width: 100%;
}
.run-picker select {
max-width: 100%; max-width: 100%;
} }
.runs { .runs {
@@ -490,19 +478,11 @@
text-decoration: underline; text-decoration: underline;
} }
button:focus-visible, button:focus-visible,
input:focus-visible, input:focus-visible {
select:focus-visible {
outline: 2px solid var(--accent-primary); outline: 2px solid var(--accent-primary);
outline-offset: 3px; outline-offset: 3px;
} }
.sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
@media (max-width: 600px) { @media (max-width: 600px) {
.panel { .panel {
padding: 16px; padding: 16px;
+35 -7
View File
@@ -1,9 +1,11 @@
<script lang="ts"> <script lang="ts">
import { setContext, type Snippet } from 'svelte'; import { setContext, type Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
import { fieldContext, type FieldContext } from '../../field-context.js'; import { fieldContext, type FieldContext } from '../../field-context.js';
interface Props { interface Props extends Omit<HTMLAttributes<HTMLDivElement>, 'children'> {
label?: string; label?: string | Snippet;
hiddenLabel?: boolean;
hint?: string | null; hint?: string | null;
error?: string | null; error?: string | null;
required?: boolean; required?: boolean;
@@ -11,11 +13,21 @@
children?: Snippet; children?: Snippet;
} }
let { label, hint = null, error = null, required = false, forId, children }: Props = $props(); let {
label,
hiddenLabel = false,
hint = null,
error = null,
required = false,
forId,
children,
class: className,
...attributes
}: Props = $props();
const id = $props.id(); const id = $props.id();
setContext<FieldContext>(fieldContext, { setContext<FieldContext>(fieldContext, {
get labelId() { get labelId() {
return `${id}-label`; return label ? `${id}-label` : undefined;
}, },
get messageId() { get messageId() {
return error || hint ? `${id}-message` : undefined; return error || hint ? `${id}-message` : undefined;
@@ -42,10 +54,15 @@
} }
</script> </script>
<div class="field" use:connectLabel> <div {...attributes} class={['field', className]} use:connectLabel>
{#if label} {#if label}
<label class="label" id={`${id}-label`} for={forId ?? `${id}-control`}> <label
{label} class="label"
class:sr-only={hiddenLabel}
id={`${id}-label`}
for={forId ?? `${id}-control`}
>
{#if typeof label === 'string'}{label}{:else}{@render label()}{/if}
{#if required}<span class="req" aria-hidden="true">*</span>{/if} {#if required}<span class="req" aria-hidden="true">*</span>{/if}
</label> </label>
{/if} {/if}
@@ -60,6 +77,17 @@
</div> </div>
<style> <style>
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
.field { .field {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
+35 -42
View File
@@ -1,61 +1,54 @@
<script lang="ts"> <script lang="ts" generics="Value extends string | number | undefined = string">
import { getContext } from 'svelte'; import { getContext } from 'svelte';
import type { HTMLInputAttributes } from 'svelte/elements';
import { fieldContext, type FieldContext } from '../../field-context.js'; import { fieldContext, type FieldContext } from '../../field-context.js';
const field = getContext<FieldContext | undefined>(fieldContext); const field = getContext<FieldContext | undefined>(fieldContext);
interface Props {
value?: string; interface Props extends Omit<HTMLInputAttributes, 'value' | 'type'> {
type?: 'text' | 'search' | 'email' | 'number' | 'url' | 'password' | 'file'; value?: Value;
placeholder?: string; type?:
id?: string; | 'text'
name?: string; | 'search'
required?: boolean; | 'email'
disabled?: boolean; | 'number'
readonly?: boolean; | 'url'
autocomplete?: import('svelte/elements').FullAutoFill; | 'password'
| 'file'
| 'tel'
| 'time'
| 'date'
| 'datetime-local'
| 'month'
| 'week';
mono?: boolean; mono?: boolean;
onchange?: (e: Event) => void;
oninput?: (e: Event) => void;
accept?: string;
ariaLabel?: string; ariaLabel?: string;
element?: HTMLInputElement;
} }
let { let {
value = $bindable(''), value = $bindable(),
element = $bindable(),
type = 'text', type = 'text',
placeholder,
id,
name,
required = false,
disabled = false,
readonly = false,
autocomplete,
mono = false, mono = false,
onchange, ariaLabel,
oninput, class: className,
accept, ...attributes
ariaLabel
}: Props = $props(); }: Props = $props();
</script> </script>
<input <input
class="input" {...attributes}
class:mono class={['input', className, { mono }]}
{type} {type}
{id} aria-label={ariaLabel ?? attributes['aria-label']}
{name} aria-labelledby={attributes['aria-labelledby'] ??
{placeholder} (ariaLabel || attributes['aria-label'] ? undefined : field?.labelId)}
{required} aria-describedby={[attributes['aria-describedby'], field?.messageId]
{disabled} .filter(Boolean)
{readonly} .join(' ') || undefined}
{autocomplete} aria-invalid={attributes['aria-invalid'] ?? (field?.invalid || undefined)}
{accept} bind:this={element}
aria-label={ariaLabel}
aria-labelledby={ariaLabel ? undefined : field?.labelId}
aria-describedby={field?.messageId}
aria-invalid={field?.invalid || undefined}
bind:value bind:value
{onchange}
{oninput}
/> />
<style> <style>
+27 -29
View File
@@ -1,51 +1,48 @@
<script lang="ts"> <script lang="ts" generics="Value = string">
import { getContext } from 'svelte'; import { getContext, type Snippet } from 'svelte';
import type { HTMLSelectAttributes } from 'svelte/elements';
import { fieldContext, type FieldContext } from '../../field-context.js'; import { fieldContext, type FieldContext } from '../../field-context.js';
const field = getContext<FieldContext | undefined>(fieldContext); const field = getContext<FieldContext | undefined>(fieldContext);
interface Option { interface Option {
value: string; value: Value;
label: string; label: string;
disabled?: boolean; disabled?: boolean;
} }
interface Props { interface Props extends Omit<HTMLSelectAttributes, 'value' | 'children' | 'multiple'> {
value?: string; value?: Value;
options: Option[]; options?: Option[];
id?: string; children?: Snippet;
name?: string;
required?: boolean;
disabled?: boolean;
placeholder?: string; placeholder?: string;
onchange?: (e: Event) => void;
ariaLabel?: string; ariaLabel?: string;
element?: HTMLSelectElement;
} }
let { let {
value = $bindable(''), value = $bindable(),
options, element = $bindable(),
id, options = [],
name, children,
required = false,
disabled = false,
placeholder, placeholder,
onchange, ariaLabel,
ariaLabel class: className,
...attributes
}: Props = $props(); }: Props = $props();
</script> </script>
<div class="wrap"> <div class="wrap">
<select <select
class="select" {...attributes}
{id} class={['select', className]}
{name} aria-label={ariaLabel ?? attributes['aria-label']}
{required} aria-labelledby={attributes['aria-labelledby'] ??
{disabled} (ariaLabel || attributes['aria-label'] ? undefined : field?.labelId)}
aria-label={ariaLabel} aria-describedby={[attributes['aria-describedby'], field?.messageId]
aria-labelledby={ariaLabel ? undefined : field?.labelId} .filter(Boolean)
aria-describedby={field?.messageId} .join(' ') || undefined}
aria-invalid={field?.invalid || undefined} aria-invalid={attributes['aria-invalid'] ?? (field?.invalid || undefined)}
bind:this={element}
bind:value bind:value
{onchange}
> >
{#if placeholder} {#if placeholder}
<option value="" disabled>{placeholder}</option> <option value="" disabled>{placeholder}</option>
@@ -53,6 +50,7 @@
{#each options as o (o.value)} {#each options as o (o.value)}
<option value={o.value} disabled={o.disabled}>{o.label}</option> <option value={o.value} disabled={o.disabled}>{o.label}</option>
{/each} {/each}
{@render children?.()}
</select> </select>
<span class="chev" aria-hidden="true">▾</span> <span class="chev" aria-hidden="true">▾</span>
</div> </div>
+1 -1
View File
@@ -1,6 +1,6 @@
export const fieldContext = Symbol('field'); export const fieldContext = Symbol('field');
export interface FieldContext { export interface FieldContext {
readonly labelId: string; readonly labelId: string | undefined;
readonly messageId: string | undefined; readonly messageId: string | undefined;
readonly invalid: boolean; readonly invalid: boolean;
} }
+3 -16
View File
@@ -8,20 +8,8 @@
flex: 1 1 260px; flex: 1 1 260px;
min-width: 0; min-width: 0;
} }
.library-toolbar input, .library-toolbar > .field:not(.search) {
.library-toolbar select { flex: 0 1 auto;
box-sizing: border-box;
width: 100%;
min-height: 40px;
padding: 9px 12px;
border: 1px solid var(--border-control);
border-radius: var(--radius-sm);
background: var(--surface-sunken);
color: var(--text-primary);
font: var(--text-sm) var(--font-body);
}
.library-toolbar > select {
width: auto;
} }
.library-table { .library-table {
overflow-x: auto; overflow-x: auto;
@@ -93,8 +81,7 @@
outline-offset: 3px; outline-offset: 3px;
} }
@media (max-width: 640px) { @media (max-width: 640px) {
.library-toolbar > select, .library-toolbar > .field:not(.search) {
.library-toolbar > label {
flex: 1 1 140px; flex: 1 1 140px;
} }
.library-toolbar .search { .library-toolbar .search {
+4 -6
View File
@@ -47,11 +47,10 @@ dialog::backdrop {
align-items: center; align-items: center;
gap: var(--space-3); gap: var(--space-3);
} }
.ui-toolbar .search, .ui-toolbar > .search {
.ui-toolbar > input[type='search'] {
flex: 1 1 240px; flex: 1 1 240px;
} }
.ui-toolbar select { .ui-toolbar > .field:not(.search) {
flex: 0 1 auto; flex: 0 1 auto;
} }
.ui-summary { .ui-summary {
@@ -114,12 +113,11 @@ dialog::backdrop {
white-space: nowrap; white-space: nowrap;
} }
@media (max-width: 600px) { @media (max-width: 600px) {
.ui-toolbar .search, .ui-toolbar > .search {
.ui-toolbar > input[type='search'] {
flex-basis: 100%; flex-basis: 100%;
width: 100%; width: 100%;
} }
.ui-toolbar select { .ui-toolbar > .field:not(.search) {
flex: 1 1 130px; flex: 1 1 130px;
} }
.ui-sticky-actions { .ui-sticky-actions {
+22 -31
View File
@@ -1,4 +1,7 @@
<script module lang="ts"> <script module lang="ts">
import Select from '$ui/components/primitives/Select.svelte';
import Input from '$ui/components/primitives/Input.svelte';
import Field from '$ui/components/primitives/Field.svelte';
const serverTitleCollator = new Intl.Collator(undefined, { numeric: true }); const serverTitleCollator = new Intl.Collator(undefined, { numeric: true });
</script> </script>
@@ -443,12 +446,8 @@
{/if} {/if}
<div class="toolbar"> <div class="toolbar">
<div class="search-row"> <div class="search-row">
<label class="search" <Field class="search" hiddenLabel label="Search servers, maps, or players">
><span class="sr-only">Search servers, maps, or players</span><input <Input type="search" bind:value={query} placeholder="Search fleet…" /></Field
type="search"
bind:value={query}
placeholder="Search fleet…"
/></label
> >
<button <button
class="control" class="control"
@@ -459,27 +458,27 @@
> >
</div> </div>
{#if viewOptionsOpen}<div id="fleet-view-options" class="view-options"> {#if viewOptionsOpen}<div id="fleet-view-options" class="view-options">
<label <Field label="Game">
>Game<select bind:value={game} <Select bind:value={game}
><option value="">All games</option><option value="t5" ><option value="">All games</option><option value="t5"
>Black Ops I</option >Black Ops I</option
><option value="t6">Black Ops II</option></select ><option value="t6">Black Ops II</option></Select
></label ></Field
> >
<label <Field label="Group by">
>Group by<select bind:value={grouping} <Select bind:value={grouping}
><option value="none">None</option><option value="map">Map</option ><option value="none">None</option><option value="map">Map</option
><option value="game">Game</option></select ><option value="game">Game</option></Select
></label ></Field
> >
<label <Field label="Sort by">
>Sort by<select bind:value={sort} <Select bind:value={sort}
><option value="duration">Longest match</option><option value="players" ><option value="duration">Longest match</option><option value="players"
>Most players</option >Most players</option
><option value="round">Highest round</option><option value="name" ><option value="round">Highest round</option><option value="name"
>Lobby name</option >Lobby name</option
></select ></Select
></label ></Field
> >
<div class="view-toggle" role="group" aria-label="Display mode"> <div class="view-toggle" role="group" aria-label="Display mode">
<button <button
@@ -698,8 +697,7 @@
font-size: var(--text-xs); font-size: var(--text-xs);
} }
button, button,
input, input {
select {
font: inherit; font: inherit;
} }
button { button {
@@ -750,15 +748,11 @@
gap: var(--space-3); gap: var(--space-3);
align-items: center; align-items: center;
} }
.search { .search-row :global(.search) {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
} }
.search input { input {
width: 100%;
}
input,
select {
color: var(--text-primary); color: var(--text-primary);
background: var(--surface-sunken); background: var(--surface-sunken);
border: 1px solid var(--border-control); border: 1px solid var(--border-control);
@@ -790,12 +784,9 @@
background: var(--surface); background: var(--surface);
border-radius: var(--radius-md); border-radius: var(--radius-md);
} }
.view-options label {
display: grid; .view-options :global(.field) {
gap: var(--space-2);
flex: 1 1 130px; flex: 1 1 130px;
color: var(--text-secondary);
font-size: var(--text-xs);
} }
.chips, .chips,
.view-toggle { .view-toggle {
+33 -49
View File
@@ -1,4 +1,7 @@
<script lang="ts"> <script lang="ts">
import Select from '$ui/components/primitives/Select.svelte';
import Field from '$ui/components/primitives/Field.svelte';
import Input from '$ui/components/primitives/Input.svelte';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { timeFormat, timeZone, timeZonePersistenceError } from '$ui/stores/time-zone.js'; import { timeFormat, timeZone, timeZonePersistenceError } from '$ui/stores/time-zone.js';
import { availableTimeZones, DEFAULT_TIME_ZONE } from '$lib/time-zone.js'; import { availableTimeZones, DEFAULT_TIME_ZONE } from '$lib/time-zone.js';
@@ -105,53 +108,57 @@
</p>{/if} </p>{/if}
<form onsubmit={changePassword} aria-busy={busy}> <form onsubmit={changePassword} aria-busy={busy}>
<fieldset disabled={busy || signingOut}> <fieldset disabled={busy || signingOut}>
<input <Field label="Username" hiddenLabel class="password-username"
><Input
type="text" type="text"
class="password-username"
value={user.username} value={user.username}
autocomplete="username" autocomplete="username"
aria-label="Username" aria-label="Username"
readonly readonly
tabindex="-1" tabindex={-1}
/> /></Field
<label for="current-password" >
>{user.mustChangePassword ? 'Temporary password' : 'Current password'}<input <Field
forId="current-password"
label={user.mustChangePassword ? 'Temporary password' : 'Current password'}
>
<Input
id="current-password" id="current-password"
name="currentPassword" name="currentPassword"
type="password" type="password"
autocomplete="current-password" autocomplete="current-password"
required required
maxlength="128" maxlength={128}
bind:value={currentPassword} bind:value={currentPassword}
/></label /></Field
> >
<label for="new-password" <Field forId="new-password" label="New password">
>New password<input <Input
id="new-password" id="new-password"
name="newPassword" name="newPassword"
type="password" type="password"
autocomplete="new-password" autocomplete="new-password"
required required
minlength="8" minlength={8}
maxlength="128" maxlength={128}
bind:value={newPassword} bind:value={newPassword}
aria-describedby="new-password-help" aria-describedby="new-password-help"
/></label /></Field
> >
<p id="new-password-help" class="hint"> <p id="new-password-help" class="hint">
Use 8–128 characters and a password you do not use elsewhere. Use 8–128 characters and a password you do not use elsewhere.
</p> </p>
<label for="confirm-password" <Field forId="confirm-password" label="Confirm new password">
>Confirm new password<input <Input
id="confirm-password" id="confirm-password"
name="confirmPassword" name="confirmPassword"
type="password" type="password"
autocomplete="new-password" autocomplete="new-password"
required required
minlength="8" minlength={8}
maxlength="128" maxlength={128}
bind:value={confirmPassword} bind:value={confirmPassword}
/></label /></Field
> >
<div> <div>
<Button <Button
@@ -169,16 +176,15 @@
</section> </section>
<section class="panel" aria-labelledby="display-heading"> <section class="panel" aria-labelledby="display-heading">
<h2 id="display-heading">Display preferences</h2> <h2 id="display-heading">Display preferences</h2>
<label class="timezone" <Field class="timezone" label="Timezone">
>Timezone <Select bind:value={$timeZone} aria-describedby="timezone-help">
<select bind:value={$timeZone} aria-describedby="timezone-help">
{#each zones as zone}<option value={zone} {#each zones as zone}<option value={zone}
>{zone.replaceAll('_', ' ')}{zone === DEFAULT_TIME_ZONE >{zone.replaceAll('_', ' ')}{zone === DEFAULT_TIME_ZONE
? ' (default)' ? ' (default)'
: ''}</option : ''}</option
>{/each} >{/each}
</select> </Select>
</label> </Field>
<p id="timezone-help"> <p id="timezone-help">
Dates and times throughout the app use this timezone, including daylight saving Dates and times throughout the app use this timezone, including daylight saving
changes. Saved automatically in this browser. changes. Saved automatically in this browser.
@@ -193,23 +199,14 @@
</section> </section>
<style> <style>
.timezone { .panel :global(.timezone) {
display: grid; display: grid;
gap: 8px; gap: 8px;
margin-top: 16px; margin-top: 16px;
font-size: var(--text-sm); font-size: var(--text-sm);
max-width: 420px; max-width: 420px;
} }
.timezone select { .panel :global(.timezone ~ p) {
width: 100%;
padding: 10px 12px;
font: inherit;
color: var(--text-primary);
background: var(--surface-sunken);
border: 1px solid var(--border-control);
border-radius: var(--radius-sm);
}
.timezone ~ p {
font-size: var(--text-sm); font-size: var(--text-sm);
color: var(--text-secondary); color: var(--text-secondary);
} }
@@ -263,21 +260,8 @@
gap: 18px; gap: 18px;
min-width: 0; min-width: 0;
} }
label {
display: grid; .panel :global(.password-username) {
gap: 8px;
font-size: var(--text-sm);
}
input {
width: 100%;
padding: 12px;
color: var(--text-primary);
background: var(--surface-sunken);
border: 1px solid var(--border-control);
border-radius: var(--radius-md);
font: inherit;
}
.password-username {
position: absolute; position: absolute;
width: 1px; width: 1px;
height: 1px; height: 1px;
+28 -40
View File
@@ -1,4 +1,7 @@
<script lang="ts"> <script lang="ts">
import Input from '$ui/components/primitives/Input.svelte';
import Select from '$ui/components/primitives/Select.svelte';
import Field from '$ui/components/primitives/Field.svelte';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { page } from '$app/state'; import { page } from '$app/state';
import { confirmAction } from '$ui/confirm.js'; import { confirmAction } from '$ui/confirm.js';
@@ -215,8 +218,8 @@
> >
<fieldset disabled={busy}> <fieldset disabled={busy}>
<div class="fields"> <div class="fields">
<label <Field label="Game">
>Game<select <Select
bind:value={settings.game} bind:value={settings.game}
disabled={!!editing} disabled={!!editing}
onchange={() => { onchange={() => {
@@ -225,60 +228,60 @@
}} }}
><option value="t5">Black Ops I</option><option value="t6" ><option value="t5">Black Ops I</option><option value="t6"
>Black Ops II</option >Black Ops II</option
></select ></Select
></label ></Field
> >
<label <Field label="Scope">
>Scope<select bind:value={settings.mapSlug} disabled={!!editing} <Select bind:value={settings.mapSlug} disabled={!!editing}
><option value="*">Every map in this game</option ><option value="*">Every map in this game</option
>{#each zombieMaps(settings.game) as map}<option value={map.slug} >{#each zombieMaps(settings.game) as map}<option value={map.slug}
>{map.friendly}</option >{map.friendly}</option
>{/each}</select >{/each}</Select
></label ></Field
> >
<label <Field label="Autoscaling">
>Autoscaling<select bind:value={settings.mode} <Select bind:value={settings.mode}
><option value="off">Off</option ><option value="off">Off</option
>{#if report.removalAvailable}<option value="create-destroy" >{#if report.removalAvailable}<option value="create-destroy"
>On</option >On</option
>{/if}</select >{/if}</Select
></label ></Field
> >
<label <Field label="Minimum running lobbies">
>Minimum running lobbies<input <Input
type="number" type="number"
min="1" min="1"
max="20" max="20"
required required
bind:value={settings.minimum} bind:value={settings.minimum}
/></label /></Field
> >
<label <Field label="Maximum lobbies">
>Maximum lobbies<input <Input
type="number" type="number"
min="1" min="1"
max="20" max="20"
required required
bind:value={settings.maximum} bind:value={settings.maximum}
/></label /></Field
> >
<label <Field label="Empty spare target">
>Empty spare target<input <Input
type="number" type="number"
min="1" min="1"
max="20" max="20"
required required
bind:value={settings.spares} bind:value={settings.spares}
/></label /></Field
> >
<label <Field label="Empty duration in minutes">
>Empty duration in minutes<input <Input
type="number" type="number"
min="1" min="1"
max="1440" max="1440"
required required
bind:value={settings.emptyMinutes} bind:value={settings.emptyMinutes}
/></label /></Field
> >
</div> </div>
{#if settings.mapSlug === '*'}<p> {#if settings.mapSlug === '*'}<p>
@@ -356,22 +359,7 @@
gap: 16px; gap: 16px;
margin-bottom: 20px; margin-bottom: 20px;
} }
label {
display: flex;
flex-direction: column;
gap: 8px;
font-size: var(--text-sm);
}
input,
select {
padding: 10px;
min-width: 0;
color: var(--text-primary);
background: var(--surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
font: inherit;
}
fieldset { fieldset {
border: 0; border: 0;
padding: 0; padding: 0;
+14 -26
View File
@@ -1,4 +1,7 @@
<script lang="ts"> <script lang="ts">
import Select from '$ui/components/primitives/Select.svelte';
import Input from '$ui/components/primitives/Input.svelte';
import Field from '$ui/components/primitives/Field.svelte';
import RelativeTime from '$ui/components/RelativeTime.svelte'; import RelativeTime from '$ui/components/RelativeTime.svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { contextHref } from '$lib/navigation-context.js'; import { contextHref } from '$lib/navigation-context.js';
@@ -158,8 +161,8 @@
ariaLabel="Beta views" ariaLabel="Beta views"
/> />
</div> </div>
<label class="search" <Field class="search" label="Search">
>Search<input <Input
type="search" type="search"
bind:value={search} bind:value={search}
oninput={() => { oninput={() => {
@@ -167,11 +170,11 @@
saveContext(); saveContext();
}} }}
placeholder="Name, beta number, file, or server" placeholder="Name, beta number, file, or server"
maxlength="200" maxlength={200}
/></label /></Field
> >
<label <Field label="Status">
>Status<select <Select
bind:value={status} bind:value={status}
onchange={() => { onchange={() => {
pageNumber = 1; pageNumber = 1;
@@ -187,7 +190,7 @@
{:else}<option value="promoted">Promoted</option><option value="rolled-back" {:else}<option value="promoted">Promoted</option><option value="rolled-back"
>Rolled back</option >Rolled back</option
>{/if} >{/if}
</select></label </Select></Field
> >
{#if betas.length && (search || status)}<Button {#if betas.length && (search || status)}<Button
variant="ghost" variant="ghost"
@@ -320,27 +323,12 @@
align-self: flex-end; align-self: flex-end;
max-width: 100%; max-width: 100%;
} }
label {
display: grid; .toolbar :global(.search) {
gap: 6px;
font-size: var(--text-xs);
color: var(--text-secondary);
}
.search {
flex: 1; flex: 1;
min-width: 240px; min-width: 240px;
} }
input,
select {
padding: 10px 12px;
min-height: 40px;
background: var(--surface-sunken);
color: var(--text-primary);
border: 1px solid var(--border-control);
border-radius: var(--radius-sm);
font: inherit;
font-size: var(--text-sm);
}
.table-scroll { .table-scroll {
overflow-x: auto; overflow-x: auto;
} }
@@ -444,7 +432,7 @@
td { td {
padding: 12px 8px; padding: 12px 8px;
} }
.search { .toolbar :global(.search) {
flex-basis: 100%; flex-basis: 100%;
} }
.pagination { .pagination {
+37 -33
View File
@@ -1,4 +1,7 @@
<script lang="ts"> <script lang="ts">
import Select from '$ui/components/primitives/Select.svelte';
import Input from '$ui/components/primitives/Input.svelte';
import Field from '$ui/components/primitives/Field.svelte';
import { countLabel } from '$ui/plural.js'; import { countLabel } from '$ui/plural.js';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { contextHref, safeReturnHref } from '$lib/navigation-context.js'; import { contextHref, safeReturnHref } from '$lib/navigation-context.js';
@@ -667,29 +670,30 @@
<form onsubmit={openBeta} class="beta-form"> <form onsubmit={openBeta} class="beta-form">
<h2 bind:this={stepHeading} tabindex="-1">{steps[step]}</h2> <h2 bind:this={stepHeading} tabindex="-1">{steps[step]}</h2>
{#if step === 0} {#if step === 0}
<label <Field label="Beta label (optional)">
>Beta label (optional)<input <Input
bind:value={betaLabel} bind:value={betaLabel}
maxlength="120" maxlength={120}
placeholder="For example, Town balance update" placeholder="For example, Town balance update"
/><span class="muted" /><span class="muted"
>Shown in the beta list so others can identify this test.</span >Shown in the beta list so others can identify this test.</span
></label ></Field
> >
<div class="picks"> <div class="picks">
{#each picks as pick, index (pick.key)}<section class="pick-row"> {#each picks as pick, index (pick.key)}<section class="pick-row">
<label <Field
>File {index + 1}<select >{#snippet label()}File {index + 1}{/snippet}
<Select
bind:value={pick.scriptId} bind:value={pick.scriptId}
onchange={() => onPickFile(index)} onchange={() => onPickFile(index)}
><option value={null}>Choose a file</option ><option value={null}>Choose a file</option
>{#each eligibleFor(index) as file}<option value={file.id} >{#each eligibleFor(index) as file}<option value={file.id}
>[{file.kind}] {file.name}</option >[{file.kind}] {file.name}</option
>{/each}</select >{/each}</Select
></label ></Field
> >
<label <Field label="Exact version">
>Exact version<select <Select
bind:value={pick.versionId} bind:value={pick.versionId}
disabled={pick.scriptId === null || disabled={pick.scriptId === null ||
loadingVersions.has(pick.key)} loadingVersions.has(pick.key)}
@@ -702,8 +706,8 @@
0, 0,
8 8
)} · {version.state.replaceAll('-', ' ')}</option )} · {version.state.replaceAll('-', ' ')}</option
>{/each}</select >{/each}</Select
></label ></Field
> >
<Button <Button
ariaLabel={`Remove file ${index + 1}`} ariaLabel={`Remove file ${index + 1}`}
@@ -742,8 +746,8 @@
At launch, Electric Cherry chooses compatible servers with the fewest At launch, Electric Cherry chooses compatible servers with the fewest
current players. Every requested instance must pass the test plan. current players. Every requested instance must pass the test plan.
</p> </p>
<label class="instance-count" <Field class="instance-count" label="Instances per game">
>Instances per game<input <Input
type="number" type="number"
min="1" min="1"
max="1000" max="1000"
@@ -756,7 +760,7 @@
: event.currentTarget.valueAsNumber)} : event.currentTarget.valueAsNumber)}
/><span class="muted" /><span class="muted"
>The same number applies to every game in this beta.</span >The same number applies to every game in this beta.</span
></label ></Field
> >
<p class="muted"> <p class="muted">
{applicableGames {applicableGames
@@ -812,14 +816,14 @@
</fieldset> </fieldset>
{#if selectionMode === 'manual' && candidateServers.length} {#if selectionMode === 'manual' && candidateServers.length}
<div class="filters"> <div class="filters">
<label <Field label="Find servers">
>Find servers<input <Input
type="search" type="search"
bind:value={search} bind:value={search}
placeholder="Name, map, lobby…" placeholder="Name, map, lobby…"
/></label /></Field
><label ><Field label="Game">
>Game<select bind:value={serverGame} <Select bind:value={serverGame}
><option value="">All games</option ><option value="">All games</option
>{#each [...new Set(fleetServers >{#each [...new Set(fleetServers
.map((s) => s.game) .map((s) => s.game)
@@ -829,16 +833,16 @@
: game === 't6' : game === 't6'
? 'Black Ops II' ? 'Black Ops II'
: game}</option : game}</option
>{/each}</select >{/each}</Select
></label ></Field
><label ><Field label="Availability">
>Availability<select bind:value={availability} <Select bind:value={availability}
><option value="">All servers</option><option value="empty" ><option value="">All servers</option><option value="empty"
>Confirmed empty</option >Confirmed empty</option
><option value="occupied">Occupied</option><option ><option value="occupied">Occupied</option><option
value="unknown">Unknown / stale / offline</option value="unknown">Unknown / stale / offline</option
></select ></Select
></label ></Field
> >
</div> </div>
<div class="actions"> <div class="actions">
@@ -999,8 +1003,7 @@
</div> </div>
<style> <style>
.beta-form input:not([type='checkbox']):not([type='radio']), .beta-form input:not([type='checkbox']):not([type='radio']) {
.beta-form select {
border-color: var(--border-control); border-color: var(--border-control);
} }
.page, .page,
@@ -1081,8 +1084,7 @@
color: var(--text-secondary); color: var(--text-secondary);
min-width: 0; min-width: 0;
} }
input:not([type='checkbox']):not([type='radio']), input:not([type='checkbox']):not([type='radio']) {
select {
width: 100%; width: 100%;
min-height: 40px; min-height: 40px;
padding: 9px 12px; padding: 9px 12px;
@@ -1108,7 +1110,7 @@
display: grid; display: grid;
gap: 16px; gap: 16px;
} }
.instance-count { .automatic-selection :global(.instance-count) {
max-width: 400px; max-width: 400px;
} }
.advanced-selection { .advanced-selection {
@@ -1148,7 +1150,8 @@
.filters { .filters {
align-items: end; align-items: end;
} }
.filters label {
.filters :global(.field) {
flex: 1 1 180px; flex: 1 1 180px;
} }
.draft-status, .draft-status,
@@ -1217,7 +1220,8 @@
.pick-row { .pick-row {
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;
} }
.pick-row label:first-child {
.pick-row > :global(.field:first-child) {
grid-column: 1/-1; grid-column: 1/-1;
} }
.steps { .steps {
+19 -32
View File
@@ -1,4 +1,7 @@
<script lang="ts"> <script lang="ts">
import Select from '$ui/components/primitives/Select.svelte';
import Input from '$ui/components/primitives/Input.svelte';
import Field from '$ui/components/primitives/Field.svelte';
import RelativeTime from '$ui/components/RelativeTime.svelte'; import RelativeTime from '$ui/components/RelativeTime.svelte';
import { contextHref } from '$lib/navigation-context.js'; import { contextHref } from '$lib/navigation-context.js';
import { timeFormat } from '$ui/stores/time-zone.js'; import { timeFormat } from '$ui/stores/time-zone.js';
@@ -102,31 +105,31 @@
<div class="page"> <div class="page">
<SectionHeader title="Campaigns" /> <SectionHeader title="Campaigns" />
<div class="toolbar"> <div class="toolbar">
<label class="search" <Field class="search" label="Search">
>Search<input <Input
type="search" type="search"
bind:value={query} bind:value={query}
oninput={() => saveContext(true)} oninput={() => saveContext(true)}
placeholder="Script, campaign, or server" placeholder="Script, campaign, or server"
maxlength="100" maxlength={100}
/></label /></Field
> >
<label <Field label="Status">
>Status<select bind:value={status} onchange={() => saveContext(true)}> <Select bind:value={status} onchange={() => saveContext(true)}>
<option value="">All statuses</option><option value="progress">In progress</option <option value="">All statuses</option><option value="progress">In progress</option
><option value="attention">Needs attention</option><option value="complete" ><option value="attention">Needs attention</option><option value="complete"
>Complete</option >Complete</option
><option value="rolled-back">Rolled back</option><option value="inactive" ><option value="rolled-back">Rolled back</option><option value="inactive"
>Inactive</option >Inactive</option
> >
</select></label </Select></Field
> >
<label <Field label="Type">
>Type<select bind:value={action} onchange={() => saveContext(true)}> <Select bind:value={action} onchange={() => saveContext(true)}>
<option value="">All types</option><option value="promote">Promote</option><option <option value="">All types</option><option value="promote">Promote</option><option
value="rollback">Rollback</option value="rollback">Rollback</option
><option value="drift">Maintenance repair</option> ><option value="drift">Maintenance repair</option>
</select></label </Select></Field
> >
{#if filtered}<Button variant="ghost" onclick={clearFilters}>Clear filters</Button>{/if} {#if filtered}<Button variant="ghost" onclick={clearFilters}>Clear filters</Button>{/if}
</div> </div>
@@ -254,29 +257,12 @@
flex-wrap: wrap; flex-wrap: wrap;
gap: 12px; gap: 12px;
} }
label {
display: grid; .toolbar :global(.search) {
gap: 6px;
font-size: var(--text-xs);
color: var(--text-secondary);
}
.search {
flex: 1; flex: 1;
min-width: 220px; min-width: 220px;
} }
input,
select {
padding: 10px 12px;
min-height: 40px;
background: var(--surface-sunken);
color: var(--text-primary);
border: 1px solid var(--border-control);
border-radius: var(--radius-sm);
font: inherit;
font-size: var(--text-sm);
}
input:focus-visible,
select:focus-visible,
.timestamp:focus-visible { .timestamp:focus-visible {
outline: 2px solid var(--accent-primary); outline: 2px solid var(--accent-primary);
outline-offset: 2px; outline-offset: 2px;
@@ -438,10 +424,11 @@
td { td {
padding: 12px 8px; padding: 12px 8px;
} }
.search { .toolbar :global(.search) {
flex-basis: 100%; flex-basis: 100%;
} }
.toolbar > label:not(.search) {
.toolbar > :global(.field:not(.search)) {
flex: 1; flex: 1;
} }
} }
+8 -8
View File
@@ -1,4 +1,6 @@
<script lang="ts"> <script lang="ts">
import Select from '$ui/components/primitives/Select.svelte';
import Field from '$ui/components/primitives/Field.svelte';
import RelativeTime from '$ui/components/RelativeTime.svelte'; import RelativeTime from '$ui/components/RelativeTime.svelte';
import { gameTitle } from '$ui/server-labels.js'; import { gameTitle } from '$ui/server-labels.js';
import BackLink from '$ui/components/BackLink.svelte'; import BackLink from '$ui/components/BackLink.svelte';
@@ -716,15 +718,15 @@
downloaded automatically. downloaded automatically.
</p> </p>
<div class="download"> <div class="download">
<label <Field label="Host">
>Host<select bind:value={targets[item.id]} <Select bind:value={targets[item.id]}
><option value="">Select host</option>{#each onlineHosts as host}<option ><option value="">Select host</option>{#each onlineHosts as host}<option
value={host.id} value={host.id}
>{host.name} · {formatGameBytes( >{host.name} · {formatGameBytes(
Number(host.inventory.storageAvailable ?? 0) Number(host.inventory.storageAvailable ?? 0)
)} free</option )} free</option
>{/each}</select >{/each}</Select
></label ></Field
><Button ><Button
size="sm" size="sm"
disabled={busy || !targets[item.id]} disabled={busy || !targets[item.id]}
@@ -783,8 +785,7 @@
gap: 6px; gap: 6px;
font-size: var(--text-sm); font-size: var(--text-sm);
} }
input, input {
select {
width: 100%; width: 100%;
min-width: 0; min-width: 0;
box-sizing: border-box; box-sizing: border-box;
@@ -794,8 +795,7 @@
border: 1px solid var(--border-strong); border: 1px solid var(--border-strong);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
} }
input:disabled, input:disabled {
select:disabled {
opacity: 0.7; opacity: 0.7;
} }
input[type='file'] { input[type='file'] {
+18 -22
View File
@@ -1,4 +1,7 @@
<script lang="ts"> <script lang="ts">
import Field from '$ui/components/primitives/Field.svelte';
import Select from '$ui/components/primitives/Select.svelte';
import Input from '$ui/components/primitives/Input.svelte';
import { onMount, untrack } from 'svelte'; import { onMount, untrack } from 'svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { pollVisible } from '$ui/poll.js'; import { pollVisible } from '$ui/poll.js';
@@ -109,21 +112,26 @@
>{/if} >{/if}
</div> </div>
{#if filtersVisible}<div class="filters"> {#if filtersVisible}<div class="filters">
<input <Field label="Search hosts" hiddenLabel class="search"
><Input
type="search" type="search"
aria-label="Search hosts" aria-label="Search hosts"
placeholder="Search hosts…" placeholder="Search hosts…"
bind:value={search} bind:value={search}
/> /></Field
<select aria-label="Filter hosts by status" bind:value={status} >
<Field label="Filter hosts by status" hiddenLabel
><Select aria-label="Filter hosts by status" bind:value={status}
><option value="">All statuses</option><option value="attention" ><option value="">All statuses</option><option value="attention"
>Needs attention</option >Needs attention</option
><option value="offline">Offline</option><option value="preparing" ><option value="offline">Offline</option><option value="preparing"
>Preparing</option >Preparing</option
><option value="draining">Draining</option><option value="disabled" ><option value="draining">Draining</option><option value="disabled"
>Disabled</option >Disabled</option
><option value="deleting">Deleting</option><option value="ready">Ready</option ><option value="deleting">Deleting</option><option value="ready"
></select >Ready</option
></Select
></Field
> >
{#if search || status}<Button {#if search || status}<Button
variant="ghost" variant="ghost"
@@ -170,27 +178,15 @@
font: inherit; font: inherit;
padding: 4px; padding: 4px;
} }
input,
select {
box-sizing: border-box;
padding: 10px 12px;
color: var(--text-primary);
background: var(--surface-sunken);
border: 1px solid var(--border-control);
border-radius: var(--radius-sm);
font: var(--text-sm) var(--font-body);
}
input {
flex: 1 1 240px;
min-width: 0;
max-width: 440px;
}
input:focus-visible,
select:focus-visible,
button:focus-visible { button:focus-visible {
outline: 2px solid var(--accent-primary); outline: 2px solid var(--accent-primary);
outline-offset: 3px; outline-offset: 3px;
} }
.filters > :global(.search) {
flex: 1 1 240px;
max-width: 440px;
}
.empty { .empty {
padding: 24px; padding: 24px;
text-align: center; text-align: center;
+21 -42
View File
@@ -1,4 +1,6 @@
<script lang="ts"> <script lang="ts">
import Input from '$ui/components/primitives/Input.svelte';
import Field from '$ui/components/primitives/Field.svelte';
import { timeFormat } from '$ui/stores/time-zone.js'; import { timeFormat } from '$ui/stores/time-zone.js';
import RelativeTime from '$ui/components/RelativeTime.svelte'; import RelativeTime from '$ui/components/RelativeTime.svelte';
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte'; import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte';
@@ -468,11 +470,8 @@
}} }}
> >
<fieldset disabled={busy || cleaning}> <fieldset disabled={busy || cleaning}>
<label <Field label="Plutainer image">
>Plutainer image<input <Input required bind:value={runtimeImage} /></Field
required
bind:value={runtimeImage}
/></label
><Button type="submit" disabled={busy || !host.connected} ><Button type="submit" disabled={busy || !host.connected}
>Pull runtime image</Button >Pull runtime image</Button
> >
@@ -509,50 +508,43 @@
<h2>Host configuration</h2> <h2>Host configuration</h2>
{#if canManage}<form onsubmit={save}> {#if canManage}<form onsubmit={save}>
<fieldset disabled={busy || cleaning}> <fieldset disabled={busy || cleaning}>
<label <Field label="Friendly name">
>Friendly name<input <Input required maxlength={200} bind:value={name} /></Field
required
maxlength="200"
bind:value={name}
/></label
> >
<div class="fields"> <div class="fields">
<label <Field label="First game port">
>First game port<input <Input
type="number" type="number"
min="1" min="1"
max="65535" max="65535"
required required
bind:value={portMin} bind:value={portMin}
/></label /></Field
><label ><Field label="Last game port">
>Last game port<input <Input
type="number" type="number"
min={portMin} min={portMin}
max="65535" max="65535"
required required
bind:value={portMax} bind:value={portMax}
/></label /></Field
> >
</div> </div>
<label <Field label="Local bind address">
>Local bind address<input <Input required bind:value={bindAddress} /></Field
required ><Field label="Public / NAT address">
bind:value={bindAddress} <Input
/></label
><label
>Public / NAT address<input
bind:value={publicAddress} bind:value={publicAddress}
placeholder="Optional public hostname or address" placeholder="Optional public hostname or address"
/></label /></Field
><label ><Field label="Minimum free game storage (GiB)">
>Minimum free game storage (GiB)<input <Input
type="number" type="number"
min="0" min="0"
step="0.1" step="0.1"
required required
bind:value={minimumFreeGB} bind:value={minimumFreeGB}
/></label /></Field
><Button type="submit" disabled={busy}>Save settings</Button> ><Button type="submit" disabled={busy}>Save settings</Button>
</fieldset> </fieldset>
</form>{:else}<dl> </form>{:else}<dl>
@@ -745,20 +737,7 @@
padding: 0; padding: 0;
margin: 0; margin: 0;
} }
label {
display: grid;
gap: 8px;
font-size: var(--text-sm);
}
input {
width: 100%;
min-width: 0;
padding: 10px;
color: var(--text-primary);
background: var(--surface-sunken);
border: 1px solid var(--border-control);
border-radius: var(--radius-sm);
}
.fields > * { .fields > * {
flex: 1; flex: 1;
min-width: 120px; min-width: 120px;
+25 -28
View File
@@ -1,4 +1,7 @@
<script lang="ts"> <script lang="ts">
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 RelativeTime from '$ui/components/RelativeTime.svelte'; import RelativeTime from '$ui/components/RelativeTime.svelte';
let { data: initial }: { data: import('./$types').PageData } = $props(); let { data: initial }: { data: import('./$types').PageData } = $props();
import { onMount, untrack } from 'svelte'; import { onMount, untrack } from 'svelte';
@@ -262,9 +265,8 @@
{#snippet filterFields(suffix: string)} {#snippet filterFields(suffix: string)}
<div class="filters"> <div class="filters">
<div class="field"> <Field label="Game" forId={`leaderboard-game${suffix}`}>
<label for={`leaderboard-game${suffix}`}>Game</label> <Select
<select
id={`leaderboard-game${suffix}`} id={`leaderboard-game${suffix}`}
value={game} value={game}
onchange={(event) => onchange={(event) =>
@@ -273,11 +275,10 @@
<option value="">All games</option>{#each games as item (item.key)}<option <option value="">All games</option>{#each games as item (item.key)}<option
value={item.key}>{item.label}</option value={item.key}>{item.label}</option
>{/each} >{/each}
</select> </Select>
</div> </Field>
<div class="field"> <Field label="Map" forId={`leaderboard-map${suffix}`}>
<label for={`leaderboard-map${suffix}`}>Map</label> <Select
<select
id={`leaderboard-map${suffix}`} id={`leaderboard-map${suffix}`}
value={map} value={map}
onchange={(event) => updateFilters({ map: event.currentTarget.value })} onchange={(event) => updateFilters({ map: event.currentTarget.value })}
@@ -291,11 +292,10 @@
? ` · ${item.game === 't5' ? 'BO1' : 'BO2'}` ? ` · ${item.game === 't5' ? 'BO1' : 'BO2'}`
: ''}</option : ''}</option
>{/each} >{/each}
</select> </Select>
</div> </Field>
<div class="field"> <Field label="Players" forId={`leaderboard-players${suffix}`}>
<label for={`leaderboard-players${suffix}`}>Players</label> <Select
<select
id={`leaderboard-players${suffix}`} id={`leaderboard-players${suffix}`}
value={players} value={players}
onchange={(event) => updateFilters({ players: event.currentTarget.value })} onchange={(event) => updateFilters({ players: event.currentTarget.value })}
@@ -304,14 +304,13 @@
<option value="">All squad sizes</option>{#each squadSizes as size}<option <option value="">All squad sizes</option>{#each squadSizes as size}<option
value={size.key}>{size.label}</option value={size.key}>{size.label}</option
>{/each} >{/each}
</select><span id={`players-help${suffix}`} class="sr-only" </Select><span id={`players-help${suffix}`} class="sr-only"
>Players recorded at the start of each match. Matches with unknown starting >Players recorded at the start of each match. Matches with unknown starting
counts appear only under All squad sizes.</span counts appear only under All squad sizes.</span
> >
</div> </Field>
<div class="field"> <Field label="Rank by" forId={`leaderboard-metric${suffix}`}>
<label for={`leaderboard-metric${suffix}`}>Rank by</label> <Select
<select
id={`leaderboard-metric${suffix}`} id={`leaderboard-metric${suffix}`}
value={metric} value={metric}
onchange={(event) => updateFilters({ metric: event.currentTarget.value })} onchange={(event) => updateFilters({ metric: event.currentTarget.value })}
@@ -319,8 +318,8 @@
{#each leaderboardMetrics as item (item.key)}<option value={item.key} {#each leaderboardMetrics as item (item.key)}<option value={item.key}
>{item.label}</option >{item.label}</option
>{/each} >{/each}
</select> </Select>
</div> </Field>
<form <form
class="search" class="search"
onsubmit={(event) => { onsubmit={(event) => {
@@ -330,15 +329,16 @@
}} }}
role="search" role="search"
> >
<label for={`leaderboard-search${suffix}`}>Find a player</label> <Field label="Find a player" forId={`leaderboard-search${suffix}`}>
<input <Input
id={`leaderboard-search${suffix}`} id={`leaderboard-search${suffix}`}
type="search" type="search"
bind:value={search} bind:value={search}
maxlength="100" maxlength={100}
placeholder="Name or GUID" placeholder="Name or GUID"
aria-describedby={`search-help${suffix}`} aria-describedby={`search-help${suffix}`}
/><span id={`search-help${suffix}`} class="sr-only" /></Field
><span id={`search-help${suffix}`} class="sr-only"
>Press Enter to search. Player ranks are preserved.</span >Press Enter to search. Player ranks are preserved.</span
> >
</form> </form>
@@ -700,7 +700,6 @@
.search { .search {
grid-column: 1 / -1; grid-column: 1 / -1;
} }
.field,
.search { .search {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -712,12 +711,10 @@
font-size: var(--text-xs); font-size: var(--text-xs);
} }
input, input,
select,
button { button {
font: inherit; font: inherit;
} }
input, input {
select {
min-height: 42px; min-height: 42px;
width: 100%; width: 100%;
padding: 9px 12px; padding: 9px 12px;
@@ -1,4 +1,6 @@
<script lang="ts"> <script lang="ts">
import Select from '$ui/components/primitives/Select.svelte';
import Field from '$ui/components/primitives/Field.svelte';
import RelativeTime from '$ui/components/RelativeTime.svelte'; import RelativeTime from '$ui/components/RelativeTime.svelte';
import { playerCompletionLabel } from '$ui/player-match.js'; import { playerCompletionLabel } from '$ui/player-match.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
@@ -389,17 +391,17 @@
description="Choose the leaderboard ranking and match history. Lifetime records stay unchanged." description="Choose the leaderboard ranking and match history. Lifetime records stay unchanged."
> >
<div class="scope-controls" aria-busy={filtering}> <div class="scope-controls" aria-busy={filtering}>
<label <Field label="Game">
>Game<select <Select
value={scope.game} value={scope.game}
onchange={(event) => void updateScope({ game: event.currentTarget.value, map: '' })} onchange={(event) => void updateScope({ game: event.currentTarget.value, map: '' })}
><option value="">All games</option><option value="t5">Black Ops I</option><option ><option value="">All games</option><option value="t5">Black Ops I</option><option
value="t6">Black Ops II</option value="t6">Black Ops II</option
></select ></Select
></label ></Field
> >
<label <Field label="Map">
>Map<select <Select
value={scope.map} value={scope.map}
onchange={(event) => void updateScope({ map: event.currentTarget.value })} onchange={(event) => void updateScope({ map: event.currentTarget.value })}
><option value="">All maps</option ><option value="">All maps</option
@@ -408,26 +410,26 @@
>{/if}{#each filteredMaps as map (`${map.game}:${map.key}`)}<option >{/if}{#each filteredMaps as map (`${map.game}:${map.key}`)}<option
value={map.key} value={map.key}
>{map.name}{!scope.game ? ` · ${gameName(map.game)}` : ''}</option >{map.name}{!scope.game ? ` · ${gameName(map.game)}` : ''}</option
>{/each}</select >{/each}</Select
></label ></Field
> >
<label <Field label="Players">
>Players<select <Select
value={scope.players ?? ''} value={scope.players ?? ''}
onchange={(event) => void updateScope({ players: event.currentTarget.value })} onchange={(event) => void updateScope({ players: event.currentTarget.value })}
><option value="">All squad sizes</option><option value="1">Solo</option><option ><option value="">All squad sizes</option><option value="1">Solo</option><option
value="2">Duo</option value="2">Duo</option
><option value="3">Trio</option><option value="4">Four players</option></select ><option value="3">Trio</option><option value="4">Four players</option></Select
></label ></Field
> >
<label <Field label="Rank by">
>Rank by<select <Select
value={scope.metric} value={scope.metric}
onchange={(event) => void updateScope({ metric: event.currentTarget.value })} onchange={(event) => void updateScope({ metric: event.currentTarget.value })}
>{#each leaderboardMetrics as metric}<option value={metric.key} >{#each leaderboardMetrics as metric}<option value={metric.key}
>{metric.label}</option >{metric.label}</option
>{/each}</select >{/each}</Select
></label ></Field
> >
</div> </div>
{#snippet actions()}<Button onclick={() => (filterOpen = false)}>Done</Button>{/snippet} {#snippet actions()}<Button onclick={() => (filterOpen = false)}>Done</Button>{/snippet}
@@ -608,15 +610,7 @@
display: grid; display: grid;
gap: var(--space-4); gap: var(--space-4);
} }
.scope-controls label {
display: grid;
gap: var(--space-2);
color: var(--text-secondary);
font-size: var(--text-sm);
}
.scope-controls select {
width: 100%;
}
.mobile-records { .mobile-records {
display: none; display: none;
} }
+11 -22
View File
@@ -1,4 +1,6 @@
<script lang="ts"> <script lang="ts">
import Input from '$ui/components/primitives/Input.svelte';
import Field from '$ui/components/primitives/Field.svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
import Button from '$ui/components/primitives/Button.svelte'; import Button from '$ui/components/primitives/Button.svelte';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte'; import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
@@ -51,28 +53,28 @@
{#if error}<p class="error" role="alert">{error}</p>{/if} {#if error}<p class="error" role="alert">{error}</p>{/if}
<form onsubmit={login} aria-busy={busy}> <form onsubmit={login} aria-busy={busy}>
<fieldset disabled={busy}> <fieldset disabled={busy}>
<label for="username" <Field forId="username" label="Username">
>Username<input <Input
id="username" id="username"
name="username" name="username"
autocomplete="username" autocomplete="username"
autocapitalize="none" autocapitalize="none"
spellcheck="false" spellcheck="false"
required required
maxlength="64" maxlength={64}
bind:value={username} bind:value={username}
/></label /></Field
> >
<label for="password" <Field forId="password" label="Password">
>Password<input <Input
id="password" id="password"
name="password" name="password"
type="password" type="password"
autocomplete="current-password" autocomplete="current-password"
required required
maxlength="128" maxlength={128}
bind:value={password} bind:value={password}
/></label /></Field
> >
<Button type="submit" variant="primary" loading={busy} disabled={busy}>Sign in</Button> <Button type="submit" variant="primary" loading={busy} disabled={busy}>Sign in</Button>
</fieldset> </fieldset>
@@ -102,20 +104,7 @@
padding: 0; padding: 0;
min-width: 0; min-width: 0;
} }
label {
display: grid;
gap: 8px;
font-size: var(--text-sm);
}
input {
width: 100%;
padding: 12px;
background: var(--surface-sunken);
color: var(--text-primary);
border: 1px solid var(--border-control);
border-radius: var(--radius-md);
font: inherit;
}
.help, .help,
.setup-link { .setup-link {
color: var(--text-secondary); color: var(--text-secondary);
+18 -19
View File
@@ -1,4 +1,7 @@
<script lang="ts"> <script lang="ts">
import Select from '$ui/components/primitives/Select.svelte';
import Input from '$ui/components/primitives/Input.svelte';
import Field from '$ui/components/primitives/Field.svelte';
import RelativeTime from '$ui/components/RelativeTime.svelte'; import RelativeTime from '$ui/components/RelativeTime.svelte';
import ActivityLog from '$ui/components/ActivityLog.svelte'; import ActivityLog from '$ui/components/ActivityLog.svelte';
import BackLink from '$ui/components/BackLink.svelte'; import BackLink from '$ui/components/BackLink.svelte';
@@ -381,12 +384,9 @@
<label class="toggle" <label class="toggle"
><input type="checkbox" bind:checked={scheduleEnabled} /> Daily fleet restart</label ><input type="checkbox" bind:checked={scheduleEnabled} /> Daily fleet restart</label
> >
<label <Field
>Queue time ({$timeZone})<input >{#snippet label()}Queue time ({$timeZone}){/snippet}
type="time" <Input type="time" bind:value={scheduleTime} required /></Field
bind:value={scheduleTime}
required
/></label
> >
<Button type="submit" disabled={busy}>Save schedule</Button> <Button type="submit" disabled={busy}>Save schedule</Button>
</form> </form>
@@ -490,30 +490,30 @@
{/if} {/if}
{/if} {/if}
<div class="filters"> <div class="filters">
<label <Field label="Capability">
>Capability<select bind:value={kind} onchange={() => saveContext()} <Select bind:value={kind} onchange={() => saveContext()}
><option value="all">All capabilities</option><option value="health" ><option value="all">All capabilities</option><option value="health"
>Health recovery</option >Health recovery</option
><option value="drift">Configuration drift</option><option ><option value="drift">Configuration drift</option><option
value="updates">Plutonium updates</option value="updates">Plutonium updates</option
><option value="schedule">Scheduled restarts</option></select ><option value="schedule">Scheduled restarts</option></Select
></label ></Field
> >
<label <Field label="Server">
>Server<input <Input
bind:value={server} bind:value={server}
oninput={() => saveContext()} oninput={() => saveContext()}
placeholder="Filter servers" placeholder="Filter servers"
/></label /></Field
> >
{#if view === 'activity'}<label {#if view === 'activity'}<Field label="Status">
>Status<select bind:value={status} onchange={() => saveContext()} <Select bind:value={status} onchange={() => saveContext()}
><option value="all">All activity</option><option value="active" ><option value="all">All activity</option><option value="active"
>Active</option >Active</option
><option value="attention">Needs attention</option><option ><option value="attention">Needs attention</option><option
value="done">Finished</option value="done">Finished</option
></select ></Select
></label ></Field
>{/if} >{/if}
</div> </div>
{#if view === 'overview' && ['all', 'updates'].includes(kind)} {#if view === 'overview' && ['all', 'updates'].includes(kind)}
@@ -808,8 +808,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
} }
input:not([type='checkbox']), input:not([type='checkbox']) {
select {
color: inherit; color: inherit;
background: var(--surface-sunken); background: var(--surface-sunken);
padding: 9px; padding: 9px;
+17 -11
View File
@@ -303,31 +303,37 @@
{flash}<Button size="sm" onclick={load}>Retry</Button> {flash}<Button size="sm" onclick={load}>Retry</Button>
</div>{/if} </div>{/if}
{#if loading || plugins.length || flash}<div class="library-toolbar"> {#if loading || plugins.length || flash}<div class="library-toolbar">
<input <Field label="Search plugins" hiddenLabel class="search"
class="search" ><Input
type="search" type="search"
aria-label="Search plugins" aria-label="Search plugins"
bind:value={search} bind:value={search}
placeholder="Search names or files…" placeholder="Search names or files…"
/> /></Field
<select aria-label="Filter plugins by game" bind:value={game}
><option value="">All games</option><option value="t5">Black Ops I</option><option
value="t6">Black Ops II</option
></select
> >
<select aria-label="Filter plugins by status" bind:value={status} <Field label="Filter plugins by game" hiddenLabel
><Select aria-label="Filter plugins by game" bind:value={game}
><option value="">All games</option><option value="t5">Black Ops I</option
><option value="t6">Black Ops II</option></Select
></Field
>
<Field label="Filter plugins by status" hiddenLabel
><Select aria-label="Filter plugins by status" bind:value={status}
><option value="">All statuses</option><option value="untested" ><option value="">All statuses</option><option value="untested"
>Needs testing</option >Needs testing</option
><option value="testing">Beta active</option><option value="test-failed" ><option value="testing">Beta active</option><option value="test-failed"
>Test failed</option >Test failed</option
><option value="tested">Test passed</option><option value="promoted" ><option value="tested">Test passed</option><option value="promoted"
>Promoted</option >Promoted</option
><option value="empty">No versions</option></select ><option value="empty">No versions</option></Select
></Field
> >
<select aria-label="Sort plugins" bind:value={sort} <Field label="Sort plugins" hiddenLabel
><Select aria-label="Sort plugins" bind:value={sort}
><option value="name">Name A–Z</option><option value="recent" ><option value="name">Name A–Z</option><option value="recent"
>Recently uploaded</option >Recently uploaded</option
></select ></Select
></Field
> >
</div>{/if} </div>{/if}
{#if game}<p class="filter-hint"> {#if game}<p class="filter-hint">
+23 -35
View File
@@ -1,4 +1,7 @@
<script lang="ts"> <script lang="ts">
import Select from '$ui/components/primitives/Select.svelte';
import Input from '$ui/components/primitives/Input.svelte';
import Field from '$ui/components/primitives/Field.svelte';
import BackLink from '$ui/components/BackLink.svelte'; import BackLink from '$ui/components/BackLink.svelte';
import { timeFormat } from '$ui/stores/time-zone.js'; import { timeFormat } from '$ui/stores/time-zone.js';
import { onMount, untrack } from 'svelte'; import { onMount, untrack } from 'svelte';
@@ -302,14 +305,14 @@
panelId="deployment-panel" panelId="deployment-panel"
/> />
</div> </div>
<label class="search" <Field class="search" label="Server">
>Server<input <Input
type="search" type="search"
bind:value={search} bind:value={search}
oninput={() => saveContext(true)} oninput={() => saveContext(true)}
placeholder="Search server or lobby" placeholder="Search server or lobby"
maxlength="100" maxlength={100}
/></label /></Field
> >
<div class="desktop-filters"> <div class="desktop-filters">
{@render filterFields()} {@render filterFields()}
@@ -322,15 +325,15 @@
</div> </div>
</div> </div>
{#snippet filterFields()} {#snippet filterFields()}
<label <Field label="Operation">
>Operation<select bind:value={action} onchange={() => saveContext(true)} <Select bind:value={action} onchange={() => saveContext(true)}
><option value="all">All operations</option ><option value="all">All operations</option
>{#each provisioningActions as value}<option {value}>{actionLabels[value]}</option >{#each provisioningActions as value}<option {value}>{actionLabels[value]}</option
>{/each}</select >{/each}</Select
></label ></Field
> >
<label <Field label="Status">
>Status<select bind:value={historyState} onchange={() => saveContext(true)} <Select bind:value={historyState} onchange={() => saveContext(true)}
><option value="all">All statuses</option><option value="pending" ><option value="all">All statuses</option><option value="pending"
>Waiting / queued</option >Waiting / queued</option
><option value="running">In progress</option><option value="awaiting-health" ><option value="running">In progress</option><option value="awaiting-health"
@@ -341,16 +344,16 @@
>Interrupted</option >Interrupted</option
><option value="warning">Warnings</option><option value="cancelled" ><option value="warning">Warnings</option><option value="cancelled"
>Cancelled</option >Cancelled</option
></select ></Select
></label ></Field
> >
<label <Field label="Source">
>Source<select bind:value={source} onchange={() => saveContext(true)} <Select bind:value={source} onchange={() => saveContext(true)}
><option value="all">All sources</option ><option value="all">All sources</option
>{#each provisioningListSources as value}<option {value} >{#each provisioningListSources as value}<option {value}
>{provisioningSourceLabels[value]}</option >{provisioningSourceLabels[value]}</option
>{/each}</select >{/each}</Select
></label ></Field
> >
{/snippet} {/snippet}
<Dialog <Dialog
@@ -636,27 +639,12 @@
display: grid; display: grid;
gap: 16px; gap: 16px;
} }
label {
display: grid; .toolbar :global(.search) {
gap: 6px;
font-size: var(--text-xs);
color: var(--text-secondary);
}
.search {
flex: 1; flex: 1;
min-width: 220px; min-width: 220px;
} }
input,
select {
padding: 10px 12px;
min-height: 40px;
background: var(--surface-sunken);
color: var(--text-primary);
border: 1px solid var(--border-control);
border-radius: var(--radius-sm);
font: inherit;
font-size: var(--text-sm);
}
.queue-heading, .queue-heading,
.queue-tools, .queue-tools,
.pagination { .pagination {
@@ -789,7 +777,7 @@
table { table {
min-width: 820px; min-width: 820px;
} }
.search { .toolbar :global(.search) {
flex-basis: 100%; flex-basis: 100%;
} }
th, th,
+22 -22
View File
@@ -1,4 +1,7 @@
<script lang="ts"> <script lang="ts">
import Select from '$ui/components/primitives/Select.svelte';
import Input from '$ui/components/primitives/Input.svelte';
import Field from '$ui/components/primitives/Field.svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { contextHref } from '$lib/navigation-context.js'; import { contextHref } from '$lib/navigation-context.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
@@ -213,41 +216,39 @@
</SectionHeader> </SectionHeader>
<section class="library" aria-label="Script library"> <section class="library" aria-label="Script library">
{#if loading || scripts.length || error}<div class="library-toolbar"> {#if loading || scripts.length || error}<div class="library-toolbar">
<label class="search" <Field class="search" hiddenLabel label="Search scripts">
><span class="sr-only">Search scripts</span><input <Input
type="search" type="search"
bind:value={search} bind:value={search}
placeholder="Search names, files, or maps…" placeholder="Search names, files, or maps…"
/></label /></Field
> >
<label <Field hiddenLabel label="Filter by game">
><span class="sr-only">Filter by game</span><select <Select bind:value={game} onchange={() => (map = '')}
bind:value={game}
onchange={() => (map = '')}
><option value="">All games</option><option value="t5">Black Ops I</option ><option value="">All games</option><option value="t5">Black Ops I</option
><option value="t6">Black Ops II</option></select ><option value="t6">Black Ops II</option></Select
></label ></Field
> >
<label <Field hiddenLabel label="Filter by map">
><span class="sr-only">Filter by map</span><select bind:value={map} <Select bind:value={map}
><option value="">All maps</option>{#each maps as m}<option value={m} ><option value="">All maps</option>{#each maps as m}<option value={m}
>{mapLabel(m)}</option >{mapLabel(m)}</option
>{/each}</select >{/each}</Select
></label ></Field
> >
<label <Field hiddenLabel label="Filter by status">
><span class="sr-only">Filter by status</span><select bind:value={status} <Select bind:value={status}
>{#each tabs as tab}<option value={tab.value} >{#each tabs as tab}<option value={tab.value}
>{tab.value === 'all' ? 'All statuses' : tab.label}</option >{tab.value === 'all' ? 'All statuses' : tab.label}</option
>{/each}<option value="empty">No versions</option></select >{/each}<option value="empty">No versions</option></Select
></label ></Field
> >
<label <Field hiddenLabel label="Sort scripts">
><span class="sr-only">Sort scripts</span><select bind:value={sort} <Select bind:value={sort}
><option value="attention">Needs attention first</option><option ><option value="attention">Needs attention first</option><option
value="recent">Recently uploaded</option value="recent">Recently uploaded</option
><option value="name">Name A–Z</option></select ><option value="name">Name A–Z</option></Select
></label ></Field
> >
</div> </div>
<div class="list-heading"> <div class="list-heading">
@@ -408,7 +409,6 @@
} }
button:focus-visible, button:focus-visible,
input:focus-visible, input:focus-visible,
select:focus-visible,
a:focus-visible { a:focus-visible {
outline: 2px solid var(--accent-primary); outline: 2px solid var(--accent-primary);
outline-offset: 3px; outline-offset: 3px;
+16 -21
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import Select from '$ui/components/primitives/Select.svelte';
import RelativeTime from '$ui/components/RelativeTime.svelte'; import RelativeTime from '$ui/components/RelativeTime.svelte';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
@@ -173,7 +174,8 @@
{/each} {/each}
</div>{/if} </div>{/if}
<div class="ui-toolbar"> <div class="ui-toolbar">
<input <Field label="Search keys" hiddenLabel class="search"
><Input
type="search" type="search"
aria-label="Search keys" aria-label="Search keys"
bind:value={search} bind:value={search}
@@ -183,8 +185,10 @@
saveSearch(); saveSearch();
}} }}
placeholder="Search labels or assigned servers…" placeholder="Search labels or assigned servers…"
/> /></Field
<select >
<Field label="Filter keys by game" hiddenLabel
><Select
aria-label="Filter keys by game" aria-label="Filter keys by game"
bind:value={gameFilter} bind:value={gameFilter}
onchange={() => { onchange={() => {
@@ -193,7 +197,8 @@
}} }}
><option value="">All games</option><option value="t5">Black Ops I</option><option ><option value="">All games</option><option value="t5">Black Ops I</option><option
value="t6">Black Ops II</option value="t6">Black Ops II</option
></select ></Select
></Field
> >
</div> </div>
{#if error && !open}<div role="alert" class="ui-error"> {#if error && !open}<div role="alert" class="ui-error">
@@ -250,11 +255,11 @@
<Dialog bind:open title="Add key" {busy}> <Dialog bind:open title="Add key" {busy}>
{#if error}<p role="alert" class="error">{error}</p>{/if} {#if error}<p role="alert" class="error">{error}</p>{/if}
<form onsubmit={save}> <form onsubmit={save}>
<label <Field label="Game">
>Game<select bind:value={game} disabled={busy} <Select bind:value={game} disabled={busy}
><option value="t5">Black Ops I</option><option value="t6">Black Ops II</option ><option value="t5">Black Ops I</option><option value="t6">Black Ops II</option
></select ></Select
></label ></Field
> >
<Field <Field
label="Label (optional)" label="Label (optional)"
@@ -319,6 +324,7 @@
display: block; display: block;
margin-top: 6px; margin-top: 6px;
} }
.assignment { .assignment {
margin-top: 8px; margin-top: 8px;
} }
@@ -331,22 +337,11 @@
p { p {
color: var(--text-secondary); color: var(--text-secondary);
} }
form, form {
label {
display: grid; display: grid;
gap: var(--space-4); gap: var(--space-4);
} }
label {
gap: 8px;
font-size: var(--text-base);
}
select {
padding: 10px;
border: 1px solid var(--border-control);
border-radius: var(--radius-md);
background: var(--surface-sunken);
color: inherit;
}
.actions { .actions {
display: flex; display: flex;
gap: var(--space-2); gap: var(--space-2);
+38 -37
View File
@@ -1,4 +1,7 @@
<script lang="ts"> <script lang="ts">
import Select from '$ui/components/primitives/Select.svelte';
import Input from '$ui/components/primitives/Input.svelte';
import Field from '$ui/components/primitives/Field.svelte';
import RelativeTime from '$ui/components/RelativeTime.svelte'; import RelativeTime from '$ui/components/RelativeTime.svelte';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
@@ -361,58 +364,54 @@
<form onsubmit={create} aria-busy={busy}> <form onsubmit={create} aria-busy={busy}>
<fieldset disabled={busy}> <fieldset disabled={busy}>
{#if formError}<p class="error" role="alert">{formError}</p>{/if} {#if formError}<p class="error" role="alert">{formError}</p>{/if}
<label for="new-username" <Field forId="new-username" label="Username">
>Username<input <Input
id="new-username" id="new-username"
name="username" name="username"
required required
minlength="3" minlength={3}
maxlength="64" maxlength={64}
pattern={'[a-zA-Z0-9][a-zA-Z0-9_.\\-]{2,63}'} pattern={'[a-zA-Z0-9][a-zA-Z0-9_.\\-]{2,63}'}
autocomplete="off" autocomplete="off"
autocapitalize="none" autocapitalize="none"
spellcheck="false" spellcheck="false"
bind:value={username} bind:value={username}
aria-describedby="new-username-help" aria-describedby="new-username-help"
/></label /></Field
> >
<p class="hint" id="new-username-help"> <p class="hint" id="new-username-help">
3–64 characters: letters, numbers, periods, underscores, and hyphens. Start with a 3–64 characters: letters, numbers, periods, underscores, and hyphens. Start with a
letter or number. letter or number.
</p> </p>
<label for="new-name" <Field forId="new-name" label="Display name">
>Display name<input <Input
id="new-name" id="new-name"
name="displayName" name="displayName"
required required
maxlength="100" maxlength={100}
autocomplete="off" autocomplete="off"
bind:value={displayName} bind:value={displayName}
/></label /></Field
> >
<label for="new-role" <Field forId="new-role" label="Role">
>Role<select <Select id="new-role" name="role" bind:value={role} aria-describedby="new-role-help"
id="new-role"
name="role"
bind:value={role}
aria-describedby="new-role-help"
>{#each roles as item}<option value={item.value}>{item.label}</option >{#each roles as item}<option value={item.value}>{item.label}</option
>{/each}</select >{/each}</Select
></label ></Field
> >
<p class="hint" id="new-role-help">{roleDescription}</p> <p class="hint" id="new-role-help">{roleDescription}</p>
<label for="temporary-password" <Field forId="temporary-password" label="Temporary password">
>Temporary password<input <Input
id="temporary-password" id="temporary-password"
name="password" name="password"
type={showPassword ? 'text' : 'password'} type={showPassword ? 'text' : 'password'}
required required
minlength="8" minlength={8}
maxlength="128" maxlength={128}
autocomplete="new-password" autocomplete="new-password"
bind:value={password} bind:value={password}
aria-describedby="temporary-help" aria-describedby="temporary-help"
/></label /></Field
> >
<p class="hint" id="temporary-help"> <p class="hint" id="temporary-help">
8–128 characters. Save it before adding the user; it will not be displayed again. 8–128 characters. Save it before adding the user; it will not be displayed again.
@@ -445,25 +444,25 @@
<form onsubmit={save} aria-busy={busy}> <form onsubmit={save} aria-busy={busy}>
<fieldset disabled={busy}> <fieldset disabled={busy}>
{#if formError}<p class="error" role="alert">{formError}</p>{/if} {#if formError}<p class="error" role="alert">{formError}</p>{/if}
<label for="edit-name" <Field forId="edit-name" label="Display name">
>Display name<input <Input
id="edit-name" id="edit-name"
name="displayName" name="displayName"
required required
maxlength="100" maxlength={100}
autocomplete="off" autocomplete="off"
bind:value={displayName} bind:value={displayName}
/></label /></Field
> >
<label for="edit-role" <Field forId="edit-role" label="Role">
>Role<select <Select
id="edit-role" id="edit-role"
name="role" name="role"
bind:value={role} bind:value={role}
aria-describedby="edit-role-help" aria-describedby="edit-role-help"
>{#each roles as item}<option value={item.value}>{item.label}</option >{#each roles as item}<option value={item.value}>{item.label}</option
>{/each}</select >{/each}</Select
></label ></Field
> >
<p class="hint" id="edit-role-help">{roleDescription}</p> <p class="hint" id="edit-role-help">{roleDescription}</p>
<label class="checkbox" <label class="checkbox"
@@ -476,17 +475,20 @@
Use <a href="/account">My account</a> to change your own password. Changes to your Use <a href="/account">My account</a> to change your own password. Changes to your
access can sign you out. access can sign you out.
</p> </p>
{:else}<label for="reset-password" {:else}<Field forId="reset-password"
>New temporary password <span class="optional">(optional)</span><input >{#snippet label()}New temporary password <span class="optional"
>(optional)</span
>{/snippet}
<Input
id="reset-password" id="reset-password"
name="password" name="password"
type={showPassword ? 'text' : 'password'} type={showPassword ? 'text' : 'password'}
minlength="8" minlength={8}
maxlength="128" maxlength={128}
autocomplete="new-password" autocomplete="new-password"
bind:value={password} bind:value={password}
aria-describedby="reset-help" aria-describedby="reset-help"
/></label /></Field
> >
<p class="hint" id="reset-help"> <p class="hint" id="reset-help">
Leave blank to keep the current password. A reset requires the user to choose a Leave blank to keep the current password. A reset requires the user to choose a
@@ -663,8 +665,7 @@
gap: 8px; gap: 8px;
font-size: var(--text-sm); font-size: var(--text-sm);
} }
input:not([type='checkbox']), input:not([type='checkbox']) {
select {
width: 100%; width: 100%;
padding: 11px; padding: 11px;
color: var(--text-primary); color: var(--text-primary);
+26 -37
View File
@@ -1,4 +1,6 @@
<script lang="ts"> <script lang="ts">
import Input from '$ui/components/primitives/Input.svelte';
import Field from '$ui/components/primitives/Field.svelte';
import Button from '$ui/components/primitives/Button.svelte'; import Button from '$ui/components/primitives/Button.svelte';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte'; import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
let token = $state(''); let token = $state('');
@@ -54,78 +56,78 @@
{#if error}<p class="error" role="alert">{error}</p>{/if} {#if error}<p class="error" role="alert">{error}</p>{/if}
<form onsubmit={setup} aria-busy={busy}> <form onsubmit={setup} aria-busy={busy}>
<fieldset disabled={busy}> <fieldset disabled={busy}>
<label for="setup-token" <Field forId="setup-token" label="Setup token">
>Setup token<input <Input
id="setup-token" id="setup-token"
name="token" name="token"
type="password" type="password"
required required
minlength="32" minlength={32}
autocomplete="off" autocomplete="off"
spellcheck="false" spellcheck="false"
bind:value={token} bind:value={token}
aria-describedby="token-help" aria-describedby="token-help"
/></label /></Field
> >
<p id="token-help" class="hint"> <p id="token-help" class="hint">
Provided through the controller’s <code>EC_SETUP_TOKEN</code> setting. Provided through the controller’s <code>EC_SETUP_TOKEN</code> setting.
</p> </p>
<label for="setup-username" <Field forId="setup-username" label="Username">
>Username<input <Input
id="setup-username" id="setup-username"
name="username" name="username"
required required
minlength="3" minlength={3}
maxlength="64" maxlength={64}
pattern={'[a-zA-Z0-9][a-zA-Z0-9_.\\-]{2,63}'} pattern={'[a-zA-Z0-9][a-zA-Z0-9_.\\-]{2,63}'}
autocomplete="username" autocomplete="username"
autocapitalize="none" autocapitalize="none"
spellcheck="false" spellcheck="false"
bind:value={username} bind:value={username}
aria-describedby="username-help" aria-describedby="username-help"
/></label /></Field
> >
<p id="username-help" class="hint"> <p id="username-help" class="hint">
3–64 characters. Start with a letter or number; periods, underscores, and hyphens 3–64 characters. Start with a letter or number; periods, underscores, and hyphens
are allowed. are allowed.
</p> </p>
<label for="setup-name" <Field forId="setup-name" label="Display name">
>Display name<input <Input
id="setup-name" id="setup-name"
name="displayName" name="displayName"
required required
maxlength="100" maxlength={100}
autocomplete="name" autocomplete="name"
bind:value={displayName} bind:value={displayName}
/></label /></Field
> >
<label for="setup-password" <Field forId="setup-password" label="Password">
>Password<input <Input
id="setup-password" id="setup-password"
name="password" name="password"
type="password" type="password"
required required
minlength="8" minlength={8}
maxlength="128" maxlength={128}
autocomplete="new-password" autocomplete="new-password"
bind:value={password} bind:value={password}
aria-describedby="password-help" aria-describedby="password-help"
/></label /></Field
> >
<p id="password-help" class="hint"> <p id="password-help" class="hint">
Use 8–128 characters. A long, unique passphrase works well. Use 8–128 characters. A long, unique passphrase works well.
</p> </p>
<label for="setup-confirm" <Field forId="setup-confirm" label="Confirm password">
>Confirm password<input <Input
id="setup-confirm" id="setup-confirm"
name="confirmPassword" name="confirmPassword"
type="password" type="password"
required required
minlength="8" minlength={8}
maxlength="128" maxlength={128}
autocomplete="new-password" autocomplete="new-password"
bind:value={confirmPassword} bind:value={confirmPassword}
/></label /></Field
> >
<Button type="submit" variant="primary" disabled={busy} loading={busy} <Button type="submit" variant="primary" disabled={busy} loading={busy}
>Create administrator</Button >Create administrator</Button
@@ -153,20 +155,7 @@
padding: 0; padding: 0;
min-width: 0; min-width: 0;
} }
label {
display: grid;
gap: 8px;
font-size: var(--text-sm);
}
input {
width: 100%;
padding: 12px;
background: var(--surface-sunken);
color: var(--text-primary);
border: 1px solid var(--border-control);
border-radius: var(--radius-md);
font: inherit;
}
.help, .help,
.hint { .hint {
font-size: var(--text-sm); font-size: var(--text-sm);
+68
View File
@@ -0,0 +1,68 @@
<script lang="ts">
import Field from '../../src/lib/ui/components/primitives/Field.svelte';
import Input from '../../src/lib/ui/components/primitives/Input.svelte';
import Select from '../../src/lib/ui/components/primitives/Select.svelte';
let count = $state<number | undefined>(3);
let order = $state(false);
let version = $state<number | null>(null);
let password = $state('');
let showPassword = $state(false);
let element = $state<HTMLInputElement>();
let selected = $state<HTMLSelectElement>();
let inputEvents = $state(0);
let changes = $state('');
</script>
<form onsubmit={(event) => event.preventDefault()}>
<Field label="Lobbies" forId="count" hint="Choose one to five lobbies.">
<Input
id="count"
type="number"
min={1}
max={5}
step={1}
required
bind:value={count}
bind:element
oninput={(event) => (inputEvents = event.currentTarget.valueAsNumber)}
aria-describedby="count-extra"
/>
</Field>
<p id="count-extra">A separate explanation.</p>
<Field label="Order" forId="order">
<Select
id="order"
bind:value={order}
bind:element={selected}
onchange={(event) => (changes = event.currentTarget.value)}
>
<option value={true}>Newest</option>
<option value={false}>Oldest</option>
</Select>
</Field>
<Field label="Version" forId="version">
<Select id="version" bind:value={version}>
<option value={null} disabled>Choose a version</option>
<optgroup label="Published"
><option value={2}>Version 2</option><option value={7}>Version 7</option></optgroup
>
</Select>
</Field>
<Field label="Password" forId="password" error="Use at least eight characters.">
<Input
id="password"
type={showPassword ? 'text' : 'password'}
bind:value={password}
minlength={8}
maxlength={128}
autocomplete="new-password"
required
/>
</Field>
<Field><Input ariaLabel="Standalone search" /></Field>
<button type="button" onclick={() => (showPassword = !showPassword)}>Show password</button>
<button type="button" onclick={() => element?.focus()}>Focus count</button>
<button type="button" onclick={() => selected?.focus()}>Focus order</button>
<output id="values">{JSON.stringify({ count, order, version, inputEvents, changes })}</output>
</form>
+61
View File
@@ -0,0 +1,61 @@
import { expect, it } from 'vitest';
import { render } from 'svelte/server';
import FormControls from '../fixtures/FormControls.svelte';
import Select from '../../src/lib/ui/components/primitives/Select.svelte';
it('preserves native constraints and combines field help with explicit descriptions', () => {
const body = render(FormControls).body;
const count = body.match(/<input[^>]*id="count"[^>]*>/)?.[0] ?? '';
expect(count).toContain('type="number"');
expect(count).toContain('min="1"');
expect(count).toContain('max="5"');
expect(count).toContain('step="1"');
expect(count).toContain('required');
expect(count).toContain('value="3"');
const descriptions = count.match(/aria-describedby="([^"]+)"/)?.[1].split(' ') ?? [];
expect(descriptions).toHaveLength(2);
for (const id of descriptions) expect(body).toContain(`id="${id}"`);
expect(descriptions).toContain('count-extra');
const password = body.match(/<input[^>]*id="password"[^>]*>/)?.[0] ?? '';
expect(password).toContain('minlength="8"');
expect(password).toContain('maxlength="128"');
expect(password).toContain('autocomplete="new-password"');
expect(password).toContain('aria-invalid="true"');
const errorId = password.match(/aria-describedby="([^"]+)"/)?.[1];
expect(body).toContain(`id="${errorId}"`);
const standalone = body.match(/<input[^>]*aria-label="Standalone search"[^>]*>/)?.[0] ?? '';
expect(standalone).not.toContain('aria-labelledby');
});
it('renders typed child options, groups and the shared chevron with the selected value', () => {
const body = render(FormControls).body;
expect(body).toMatch(/<option[^>]*value="false"[^>]*selected[^>]*>Oldest<\/option>/);
expect(body).toMatch(/<option[^>]*disabled[^>]*selected[^>]*>Choose a version<\/option>/);
expect(body).toContain('<optgroup label="Published">');
expect(body.match(/aria-hidden="true">▾/g)).toHaveLength(2);
});
it('retains the options API, placeholders and explicitly supplied accessibility attributes', () => {
const body = render(Select<number>, {
props: {
value: 2,
options: [
{ value: 1, label: 'One', disabled: true },
{ value: 2, label: 'Two' }
],
placeholder: 'Choose',
disabled: true,
name: 'quantity',
'aria-label': 'Quantity',
'aria-describedby': 'quantity-help',
'aria-invalid': true
}
}).body;
expect(body).toMatch(/<option value="" disabled[^>]*>Choose<\/option>/);
expect(body).toMatch(/<option[^>]*value="1"[^>]*disabled[^>]*>One<\/option>/);
expect(body).toMatch(/<option[^>]*value="2"[^>]*selected[^>]*>Two<\/option>/);
expect(body).toContain('aria-label="Quantity"');
expect(body).toContain('aria-describedby="quantity-help"');
expect(body).toContain('aria-invalid="true"');
expect(body).toMatch(/<select[^>]*disabled/);
});
+71
View File
@@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest';
import { checkFormControls } from '../../scripts/check-ui-styles.mjs';
const filename = 'src/routes/example/+page.svelte';
describe('shared form control guard', () => {
it.each([
'',
' type="text"',
' type="search"',
' type="password"',
' type="email"',
' type="url"',
' type="tel"',
' type="number"',
' type="time"',
' type={visible ? "text" : "password"}'
])('rejects raw input%s with a useful location', (attributes) => {
expect(checkFormControls(`<div>\n<input${attributes} />\n</div>`, filename)).toEqual([
`${filename}:2: use Field with Input instead of raw <input>`
]);
});
it('finds selects nested in blocks without confusing event expressions for markup', () => {
const source =
'{#if ready}<select onchange={(event) => choose(event.currentTarget.value)}><option>One</option></select>{/if}';
expect(checkFormControls(source, filename)).toEqual([
`${filename}:1: use Field with Select instead of raw <select>`
]);
});
it('allows native controls that do not use the text field presentation', () => {
const source = [
'checkbox',
'radio',
'file',
'hidden',
'range',
'color',
'button',
'submit',
'reset',
'image'
]
.map((type) => `<input type="${type}" />`)
.join('\n');
expect(checkFormControls(source, filename)).toEqual([]);
expect(checkFormControls('<input type={"checkbox"} />', filename)).toEqual([]);
});
it('ignores comments, script strings and shared components', () => {
const source =
'<script>const example = "<input>";</script><!-- <select></select> --><Field label="Search"><Input type="search" /></Field><Select options={[]} />';
expect(checkFormControls(source, filename)).toEqual([]);
});
it('allows raw controls only in their implementing primitives', () => {
expect(
checkFormControls('<input />', 'src/lib/ui/components/primitives/Input.svelte')
).toEqual([]);
expect(
checkFormControls('<select></select>', 'src/lib/ui/components/primitives/Select.svelte')
).toEqual([]);
expect(
checkFormControls('<input />', 'src/lib/ui/components/primitives/Field.svelte')
).toHaveLength(1);
expect(checkFormControls('<input />', 'src/lib/ui/components/Input.svelte')).toHaveLength(
1
);
});
});