769 lines
28 KiB
Svelte
769 lines
28 KiB
Svelte
<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 { beforeNavigate, goto } from '$app/navigation';
|
|
import { confirmAction } from '$ui/confirm.js';
|
|
import Button from './primitives/Button.svelte';
|
|
import RetentionTools from './RetentionTools.svelte';
|
|
import {
|
|
defaultFleetSettings,
|
|
fleetSettingLabels,
|
|
settingsGroups,
|
|
settingHelp,
|
|
settingMinimum,
|
|
settingZeroMode,
|
|
settingUnit,
|
|
settingDisplayValue,
|
|
validateFleetSettings,
|
|
type FleetSettings
|
|
} from '$lib/fleet-settings.js';
|
|
|
|
type Key = keyof FleetSettings;
|
|
type Draft = { [K in Key]: number | undefined };
|
|
let { initial }: { initial: FleetSettings } = $props();
|
|
const id = $props.id();
|
|
const keys = Object.keys(defaultFleetSettings) as Key[];
|
|
let baseline = $state<FleetSettings>({ ...untrack(() => initial) });
|
|
let draft = $state<Draft>({ ...untrack(() => initial) });
|
|
let remembered = Object.fromEntries(
|
|
keys.filter((key) => baseline[key] > 0).map((key) => [key, baseline[key]])
|
|
) as Partial<FleetSettings>;
|
|
let query = $state('');
|
|
let selectedGroup = $state(settingsGroups[0].id);
|
|
let reviewInvalid = $state(false);
|
|
let saving = $state(false);
|
|
let error = $state('');
|
|
let notice = $state('');
|
|
let allowNavigation = false;
|
|
const changed = $derived(keys.filter((key) => draft[key] !== baseline[key]));
|
|
const invalid = $derived(keys.filter((key) => !!validationMessage(key, draft[key])));
|
|
const visibleGroups = $derived.by(() => {
|
|
const terms = query.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean);
|
|
return settingsGroups
|
|
.map((group) => ({
|
|
...group,
|
|
keys: group.keys.filter((key) => {
|
|
if (reviewInvalid && invalid.length) return invalid.includes(key);
|
|
if (!terms.length) return group.id === selectedGroup;
|
|
const text =
|
|
`${group.title} ${group.description} ${fleetSettingLabels[key]} ${settingHelp[key] ?? ''} ${settingZeroMode(key) ?? ''} ${key}`.toLocaleLowerCase();
|
|
return terms.every((term) => text.includes(term));
|
|
})
|
|
}))
|
|
.filter((group) => group.keys.length);
|
|
});
|
|
const shownCount = $derived(
|
|
visibleGroups.reduce((count, group) => count + group.keys.length, 0)
|
|
);
|
|
|
|
function validationMessage(key: Key, value: number | undefined): string {
|
|
if (
|
|
typeof value !== 'number' ||
|
|
!Number.isSafeInteger(value) ||
|
|
value < settingMinimum(key)
|
|
)
|
|
return 'Enter a valid whole number.';
|
|
if (key.endsWith('Days') && value > 36500) return 'Enter 36,500 days or fewer.';
|
|
return '';
|
|
}
|
|
function toggleMode(key: Key, enabled: boolean) {
|
|
if (saving) return;
|
|
const current = draft[key];
|
|
if (enabled) {
|
|
if (current !== undefined && Number.isSafeInteger(current) && current > 0)
|
|
remembered[key] = current;
|
|
draft[key] = 0;
|
|
} else {
|
|
draft[key] =
|
|
remembered[key] ?? (defaultFleetSettings[key] > 0 ? defaultFleetSettings[key] : 1);
|
|
}
|
|
error = '';
|
|
notice = '';
|
|
}
|
|
function selectGroup(groupId: string) {
|
|
selectedGroup = groupId;
|
|
query = '';
|
|
reviewInvalid = false;
|
|
}
|
|
function discard() {
|
|
if (saving) return;
|
|
draft = { ...baseline };
|
|
remembered = Object.fromEntries(
|
|
keys.filter((key) => baseline[key] > 0).map((key) => [key, baseline[key]])
|
|
) as Partial<FleetSettings>;
|
|
error = '';
|
|
notice = '';
|
|
reviewInvalid = false;
|
|
}
|
|
async function save() {
|
|
if (saving || !changed.length || invalid.length) return;
|
|
saving = true;
|
|
error = '';
|
|
notice = '';
|
|
try {
|
|
const values = validateFleetSettings(draft);
|
|
const response = await fetch('/api/settings', {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(values)
|
|
});
|
|
const result = await response.json();
|
|
if (!response.ok) throw new Error(result.error ?? 'Could not save fleet settings.');
|
|
baseline = validateFleetSettings(result);
|
|
draft = { ...baseline };
|
|
for (const key of keys) if (baseline[key] > 0) remembered[key] = baseline[key];
|
|
notice = 'Settings saved. Changes apply to the next queue or cleanup check.';
|
|
} catch (cause) {
|
|
error = cause instanceof Error ? cause.message : 'Could not save fleet settings.';
|
|
} finally {
|
|
saving = false;
|
|
}
|
|
}
|
|
|
|
beforeNavigate(({ cancel, to, from, type }) => {
|
|
if (
|
|
!changed.length ||
|
|
allowNavigation ||
|
|
type === 'leave' ||
|
|
!to ||
|
|
to.url.pathname === from?.url.pathname
|
|
)
|
|
return;
|
|
cancel();
|
|
void confirmAction({
|
|
title: 'Leave Settings?',
|
|
message: 'Your unsaved settings will be discarded.',
|
|
confirmLabel: 'Leave settings',
|
|
danger: true
|
|
}).then(async (accepted) => {
|
|
if (!accepted) return;
|
|
allowNavigation = true;
|
|
try {
|
|
await goto(to.url);
|
|
} finally {
|
|
allowNavigation = false;
|
|
}
|
|
});
|
|
});
|
|
function beforeUnload(event: BeforeUnloadEvent) {
|
|
if (changed.length) {
|
|
event.preventDefault();
|
|
event.returnValue = '';
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<svelte:window onbeforeunload={beforeUnload} />
|
|
<div class="settings-layout">
|
|
<aside class="section-rail">
|
|
<Field label="Find a setting" forId="settings-search">
|
|
<Input
|
|
id="settings-search"
|
|
type="search"
|
|
placeholder="Search settings"
|
|
bind:value={query}
|
|
disabled={saving}
|
|
oninput={() => (reviewInvalid = false)}
|
|
/></Field
|
|
>
|
|
<Field class="mobile-section" label="Section">
|
|
<Select
|
|
aria-label="Settings section"
|
|
value={selectedGroup}
|
|
disabled={saving}
|
|
onchange={(event) => selectGroup(event.currentTarget.value)}
|
|
>
|
|
{#each settingsGroups as group}<option value={group.id}
|
|
>{group.title}{changed.some((key) => group.keys.includes(key))
|
|
? ` · ${changed.filter((key) => group.keys.includes(key)).length} changed`
|
|
: ''}</option
|
|
>{/each}
|
|
</Select>
|
|
</Field>
|
|
<nav class="group-links" aria-label="Settings sections">
|
|
{#each settingsGroups as group}
|
|
{@const count = changed.filter((key) => group.keys.includes(key)).length}
|
|
<a
|
|
href={`#settings-group-${group.id}`}
|
|
aria-current={!query.trim() && !reviewInvalid && selectedGroup === group.id
|
|
? 'location'
|
|
: undefined}
|
|
onclick={(event) => {
|
|
event.preventDefault();
|
|
if (!saving) selectGroup(group.id);
|
|
}}
|
|
>
|
|
<span>{group.title}</span>{#if count}<span class="changed-count"
|
|
>{count} changed</span
|
|
>{/if}
|
|
</a>
|
|
{/each}
|
|
</nav>
|
|
</aside>
|
|
<div class="settings-content">
|
|
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
|
|
{#if notice}<p class="notice" role="status">{notice}</p>{/if}
|
|
{#if query.trim()}<p class="search-results">
|
|
{shownCount}
|
|
{shownCount === 1 ? 'setting matches' : 'settings match'} “{query.trim()}” across
|
|
all sections.
|
|
<button type="button" class="text-button" onclick={() => (query = '')}
|
|
>Clear search</button
|
|
>
|
|
</p>{/if}
|
|
{#if reviewInvalid && invalid.length}<p class="search-results">
|
|
Showing invalid settings across all sections. <button
|
|
type="button"
|
|
class="text-button"
|
|
onclick={() => (reviewInvalid = false)}>Show selected section</button
|
|
>
|
|
</p>{/if}
|
|
<form
|
|
id={`${id}-form`}
|
|
onsubmit={(event) => {
|
|
event.preventDefault();
|
|
void save();
|
|
}}
|
|
>
|
|
<fieldset disabled={saving}>
|
|
<legend class="sr-only">Fleet settings</legend>
|
|
{#each visibleGroups as group (group.id)}
|
|
<section
|
|
class="settings-group"
|
|
id={`settings-group-${group.id}`}
|
|
aria-labelledby={`settings-heading-${group.id}`}
|
|
>
|
|
<header>
|
|
<h2 id={`settings-heading-${group.id}`}>{group.title}</h2>
|
|
<p>{group.description}</p>
|
|
</header>
|
|
<div class="table-wrap">
|
|
<table>
|
|
<thead
|
|
><tr
|
|
><th scope="col">Setting</th><th scope="col">Value</th><th
|
|
scope="col">Default</th
|
|
><th scope="col">What it affects</th></tr
|
|
></thead
|
|
>
|
|
<tbody
|
|
>{#each group.keys as key (key)}
|
|
{@const zeroMode = settingZeroMode(key)}
|
|
{@const unlimited = !!zeroMode && draft[key] === 0}
|
|
{@const validation = validationMessage(key, draft[key])}
|
|
<tr
|
|
data-setting={key}
|
|
class:changed={changed.includes(key)}
|
|
>
|
|
<th scope="row"
|
|
><label for={`setting-${key}`}
|
|
>{fleetSettingLabels[key]}</label
|
|
>{#if changed.includes(key)}<span
|
|
class="changed-marker">Changed</span
|
|
>{/if}</th
|
|
>
|
|
<td class="value-cell">
|
|
<div class="numeric-value">
|
|
{#if unlimited}<Field
|
|
><Input
|
|
id={`setting-${key}`}
|
|
type="number"
|
|
disabled
|
|
placeholder="—"
|
|
aria-describedby={`setting-${key}-help setting-${key}-default`}
|
|
/></Field
|
|
>
|
|
{:else}<Field
|
|
><Input
|
|
id={`setting-${key}`}
|
|
type="number"
|
|
min={Math.max(
|
|
1,
|
|
settingMinimum(key)
|
|
)}
|
|
max={key.endsWith('Days')
|
|
? 36500
|
|
: undefined}
|
|
step="1"
|
|
required
|
|
bind:value={draft[key]}
|
|
aria-invalid={!!validation}
|
|
aria-describedby={`setting-${key}-help setting-${key}-default${validation ? ` setting-${key}-error` : ''}`}
|
|
oninput={() => {
|
|
error = '';
|
|
notice = '';
|
|
}}
|
|
/></Field
|
|
>{/if}
|
|
<span class="unit">{settingUnit(key)}</span>
|
|
</div>
|
|
{#if zeroMode}<label
|
|
class="zero-mode"
|
|
for={`setting-${key}-zero`}
|
|
><input
|
|
id={`setting-${key}-zero`}
|
|
type="checkbox"
|
|
checked={unlimited}
|
|
onchange={(event) =>
|
|
toggleMode(
|
|
key,
|
|
event.currentTarget.checked
|
|
)}
|
|
aria-label={`${zeroMode}: ${fleetSettingLabels[key]}`}
|
|
aria-describedby={`setting-${key}-help`}
|
|
/><span>{zeroMode}</span></label
|
|
>{/if}
|
|
{#if validation}<p
|
|
class="field-error"
|
|
id={`setting-${key}-error`}
|
|
>
|
|
{validation}
|
|
</p>{/if}
|
|
</td>
|
|
<td class="default-value" id={`setting-${key}-default`}
|
|
><span class="mobile-label"
|
|
>Default:
|
|
</span>{settingDisplayValue(
|
|
key,
|
|
defaultFleetSettings[key]
|
|
)}</td
|
|
>
|
|
<td class="help" id={`setting-${key}-help`}
|
|
>{settingHelp[key]}</td
|
|
>
|
|
</tr>
|
|
{/each}</tbody
|
|
>
|
|
</table>
|
|
</div>
|
|
{#if group.id === 'concurrency'}<p class="group-note">
|
|
Lowering a limit lets active operations finish; queued work waits
|
|
for capacity. Player safety checks still apply.
|
|
</p>{/if}
|
|
</section>
|
|
{:else}<p class="no-results">
|
|
No settings match this search. Try a setting name, a section, or “Forever”.
|
|
</p>{/each}
|
|
</fieldset>
|
|
</form>
|
|
<div class="retention-tools">
|
|
<fieldset disabled={saving || invalid.length > 0}>
|
|
<legend class="sr-only">Retention preview and protections</legend>
|
|
{#if invalid.length}<p>Correct invalid values to preview cleanup.</p>{/if}
|
|
<RetentionTools settings={draft as FleetSettings} />
|
|
</fieldset>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="save-bar">
|
|
<div class="save-summary">
|
|
<strong class:has-changes={changed.length > 0}
|
|
>{changed.length
|
|
? `${changed.length} unsaved ${changed.length === 1 ? 'change' : 'changes'}`
|
|
: 'All changes saved'}</strong
|
|
>
|
|
<span>Applies to everyone. No restart needed.</span>
|
|
{#if invalid.length}<button
|
|
type="button"
|
|
class="invalid-summary text-button"
|
|
onclick={() => {
|
|
query = '';
|
|
reviewInvalid = true;
|
|
}}
|
|
>{invalid.length} invalid {invalid.length === 1 ? 'setting' : 'settings'} · Review invalid
|
|
settings</button
|
|
>{/if}
|
|
</div>
|
|
<div class="save-actions">
|
|
<Button variant="ghost" disabled={saving || !changed.length} onclick={discard}
|
|
>Discard</Button
|
|
><button
|
|
class="save-button"
|
|
type="submit"
|
|
form={`${id}-form`}
|
|
disabled={saving || !changed.length || invalid.length > 0}
|
|
>{saving ? 'Saving…' : 'Save changes'}</button
|
|
>
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
.settings-layout {
|
|
display: grid;
|
|
grid-template-columns: 200px minmax(0, 1fr);
|
|
gap: var(--space-5);
|
|
align-items: start;
|
|
}
|
|
.section-rail {
|
|
position: sticky;
|
|
top: 80px;
|
|
display: grid;
|
|
gap: var(--space-2);
|
|
min-width: 0;
|
|
}
|
|
|
|
.group-links {
|
|
display: grid;
|
|
gap: var(--space-1);
|
|
margin-top: var(--space-3);
|
|
}
|
|
.group-links a {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: var(--space-2);
|
|
min-height: 40px;
|
|
padding: var(--space-2) var(--space-3);
|
|
border-radius: var(--radius-sm);
|
|
text-decoration: none;
|
|
font-size: var(--text-sm);
|
|
color: var(--text-secondary);
|
|
}
|
|
.group-links a:hover,
|
|
.group-links a[aria-current] {
|
|
background: var(--surface-raised);
|
|
color: var(--text-primary);
|
|
}
|
|
.group-links a[aria-current] {
|
|
font-weight: var(--weight-semibold);
|
|
}
|
|
.changed-count {
|
|
font-size: var(--text-xs);
|
|
color: var(--state-warning);
|
|
white-space: nowrap;
|
|
}
|
|
.settings-content,
|
|
fieldset {
|
|
min-width: 0;
|
|
}
|
|
fieldset {
|
|
border: 0;
|
|
padding: 0;
|
|
margin: 0;
|
|
}
|
|
.settings-group {
|
|
background: var(--surface);
|
|
border: 1px solid var(--border-strong);
|
|
border-radius: var(--radius-md);
|
|
overflow: hidden;
|
|
margin-bottom: var(--space-4);
|
|
}
|
|
.settings-group header {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
align-items: baseline;
|
|
gap: var(--space-2) var(--space-4);
|
|
padding: var(--space-4);
|
|
border-bottom: 1px solid var(--border-subtle);
|
|
}
|
|
h2 {
|
|
margin: 0;
|
|
font-size: var(--text-base);
|
|
font-weight: var(--weight-semibold);
|
|
}
|
|
p {
|
|
color: var(--text-secondary);
|
|
font-size: var(--text-sm);
|
|
line-height: var(--leading-body);
|
|
}
|
|
header p {
|
|
margin: 0;
|
|
font-size: var(--text-xs);
|
|
}
|
|
.table-wrap {
|
|
position: relative;
|
|
min-width: 0;
|
|
overflow-x: auto;
|
|
}
|
|
table {
|
|
width: 100%;
|
|
table-layout: fixed;
|
|
border-collapse: collapse;
|
|
font-size: var(--text-xs);
|
|
text-align: left;
|
|
}
|
|
th,
|
|
td {
|
|
padding: var(--space-3);
|
|
vertical-align: top;
|
|
border-bottom: 1px solid var(--border-subtle);
|
|
}
|
|
th {
|
|
font-weight: var(--weight-medium);
|
|
}
|
|
thead th {
|
|
color: var(--text-secondary);
|
|
font-weight: var(--weight-semibold);
|
|
}
|
|
thead th:nth-child(1) {
|
|
width: 23%;
|
|
}
|
|
thead th:nth-child(2) {
|
|
width: 25%;
|
|
}
|
|
thead th:nth-child(3) {
|
|
width: 15%;
|
|
}
|
|
tbody tr:last-child th,
|
|
tbody tr:last-child td {
|
|
border-bottom: 0;
|
|
}
|
|
.changed {
|
|
background: color-mix(in srgb, var(--state-warning) 3%, transparent);
|
|
}
|
|
.changed-marker {
|
|
display: block;
|
|
margin-top: var(--space-1);
|
|
color: var(--state-warning);
|
|
font-size: var(--text-xs);
|
|
}
|
|
.numeric-value {
|
|
display: flex;
|
|
align-items: center;
|
|
flex-wrap: wrap;
|
|
gap: var(--space-1);
|
|
}
|
|
|
|
input:disabled {
|
|
color: var(--text-tertiary);
|
|
opacity: 0.75;
|
|
}
|
|
.numeric-value > :global(.field) {
|
|
width: 100px;
|
|
flex: 0 0 auto;
|
|
}
|
|
.unit {
|
|
color: var(--text-secondary);
|
|
font-size: var(--text-xs);
|
|
}
|
|
.zero-mode {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: var(--space-1);
|
|
margin-top: var(--space-2);
|
|
color: var(--text-secondary);
|
|
font-size: var(--text-xs);
|
|
cursor: pointer;
|
|
}
|
|
.zero-mode input {
|
|
width: 16px;
|
|
height: 16px;
|
|
margin: 0;
|
|
accent-color: var(--accent-primary);
|
|
}
|
|
.default-value,
|
|
.help {
|
|
color: var(--text-secondary);
|
|
line-height: var(--leading-body);
|
|
}
|
|
.default-value {
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
.field-error {
|
|
margin: var(--space-1) 0 0;
|
|
color: var(--state-danger);
|
|
font-size: var(--text-xs);
|
|
}
|
|
.group-note {
|
|
margin: 0;
|
|
padding: var(--space-3) var(--space-4);
|
|
border-top: 1px solid var(--border-subtle);
|
|
font-size: var(--text-xs);
|
|
}
|
|
.search-results {
|
|
margin-top: 0;
|
|
}
|
|
.text-button {
|
|
padding: 0;
|
|
background: none;
|
|
border: 0;
|
|
color: var(--accent-primary-hover);
|
|
font: inherit;
|
|
text-decoration: underline;
|
|
cursor: pointer;
|
|
}
|
|
.retention-tools {
|
|
margin-top: var(--space-4);
|
|
}
|
|
.save-bar {
|
|
position: sticky;
|
|
bottom: var(--space-3);
|
|
z-index: 10;
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: var(--space-3);
|
|
padding: var(--space-3) var(--space-4);
|
|
margin-top: var(--space-5);
|
|
border: 1px solid var(--border-strong);
|
|
border-radius: var(--radius-md);
|
|
background: var(--surface-raised);
|
|
box-shadow: var(--shadow-raised);
|
|
}
|
|
.save-summary {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
gap: var(--space-1) var(--space-3);
|
|
font-size: var(--text-xs);
|
|
color: var(--text-secondary);
|
|
}
|
|
.save-summary strong {
|
|
font-weight: var(--weight-semibold);
|
|
color: var(--text-primary);
|
|
}
|
|
.save-summary strong.has-changes {
|
|
color: var(--state-warning);
|
|
}
|
|
.save-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--space-2);
|
|
margin-left: auto;
|
|
}
|
|
.save-button {
|
|
min-height: 40px;
|
|
padding: var(--space-2) var(--space-4);
|
|
border: 1px solid transparent;
|
|
border-radius: var(--radius-md);
|
|
background: var(--accent-primary);
|
|
color: var(--text-on-accent);
|
|
font-size: var(--text-sm);
|
|
font-weight: var(--weight-semibold);
|
|
cursor: pointer;
|
|
}
|
|
.save-button:hover:not(:disabled) {
|
|
background: var(--accent-primary-hover);
|
|
}
|
|
.save-button:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
.invalid-summary {
|
|
color: var(--state-danger);
|
|
}
|
|
.notice {
|
|
color: var(--state-online);
|
|
}
|
|
.section-rail :global(.mobile-section),
|
|
.mobile-label {
|
|
display: none;
|
|
}
|
|
@media (max-width: 1100px) {
|
|
.settings-layout {
|
|
grid-template-columns: 180px minmax(0, 1fr);
|
|
gap: var(--space-4);
|
|
}
|
|
thead th:nth-child(1) {
|
|
width: 25%;
|
|
}
|
|
thead th:nth-child(2) {
|
|
width: 27%;
|
|
}
|
|
thead th:nth-child(3) {
|
|
width: 16%;
|
|
}
|
|
th,
|
|
td {
|
|
padding: var(--space-2);
|
|
}
|
|
}
|
|
@media (max-width: 720px) {
|
|
.settings-layout {
|
|
grid-template-columns: minmax(0, 1fr);
|
|
gap: var(--space-4);
|
|
}
|
|
.section-rail {
|
|
position: static;
|
|
}
|
|
.group-links {
|
|
display: none;
|
|
}
|
|
.section-rail :global(.mobile-section) {
|
|
display: grid;
|
|
grid-template-columns: auto minmax(0, 1fr);
|
|
align-items: center;
|
|
gap: var(--space-3);
|
|
font-size: var(--text-sm);
|
|
color: var(--text-secondary);
|
|
}
|
|
.table-wrap {
|
|
overflow: visible;
|
|
}
|
|
table,
|
|
tbody {
|
|
display: block;
|
|
}
|
|
thead {
|
|
position: absolute;
|
|
width: 1px;
|
|
height: 1px;
|
|
overflow: hidden;
|
|
clip-path: inset(50%);
|
|
}
|
|
tbody tr {
|
|
display: grid;
|
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
|
gap: var(--space-2);
|
|
padding: var(--space-3);
|
|
border-bottom: 1px solid var(--border-subtle);
|
|
}
|
|
tbody tr:last-child {
|
|
border-bottom: 0;
|
|
}
|
|
th,
|
|
td {
|
|
display: block;
|
|
padding: 0;
|
|
border: 0;
|
|
}
|
|
tbody th {
|
|
grid-column: 1 / -1;
|
|
font-size: var(--text-sm);
|
|
}
|
|
.changed-marker {
|
|
display: inline;
|
|
margin-left: var(--space-2);
|
|
}
|
|
.value-cell {
|
|
grid-column: 1 / -1;
|
|
display: flex;
|
|
align-items: center;
|
|
flex-wrap: wrap;
|
|
gap: var(--space-2) var(--space-4);
|
|
}
|
|
.zero-mode {
|
|
min-height: 44px;
|
|
margin-top: 0;
|
|
}
|
|
.zero-mode input {
|
|
width: 20px;
|
|
height: 20px;
|
|
}
|
|
.default-value,
|
|
.help {
|
|
grid-column: 1 / -1;
|
|
}
|
|
.mobile-label {
|
|
display: inline;
|
|
}
|
|
.save-bar {
|
|
bottom: max(var(--space-2), env(safe-area-inset-bottom));
|
|
padding: var(--space-3);
|
|
gap: var(--space-2);
|
|
}
|
|
.save-summary {
|
|
gap: var(--space-1);
|
|
flex-direction: column;
|
|
align-items: flex-start;
|
|
}
|
|
.save-actions {
|
|
width: 100%;
|
|
justify-content: flex-end;
|
|
}
|
|
}
|
|
</style>
|