mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-27 00:55:16 -07:00
feat(macos): configuration window + curl-backed macOS updater (#71)
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self'; style-src 'self';"
|
||||
/>
|
||||
<title>SubMiner Configuration</title>
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main id="app" class="settings-shell">
|
||||
<aside class="settings-nav" aria-label="Configuration categories">
|
||||
<div class="brand-block">
|
||||
<div class="brand-title">SubMiner</div>
|
||||
<div class="brand-subtitle">Configuration</div>
|
||||
</div>
|
||||
<nav id="categoryNav" class="category-nav"></nav>
|
||||
</aside>
|
||||
<section class="settings-main">
|
||||
<header class="settings-toolbar">
|
||||
<div class="toolbar-title-block">
|
||||
<h1 id="categoryTitle">Configuration</h1>
|
||||
<div id="categoryMeta" class="toolbar-meta"></div>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<input
|
||||
id="searchInput"
|
||||
class="search-input"
|
||||
type="search"
|
||||
placeholder="Search"
|
||||
aria-label="Search settings"
|
||||
/>
|
||||
<button id="openFileButton" class="secondary-button" type="button">Open File</button>
|
||||
<button id="saveButton" class="primary-button" type="button" disabled>Save</button>
|
||||
</div>
|
||||
</header>
|
||||
<div id="statusBanner" class="status-banner hidden" role="status"></div>
|
||||
<div id="warningsPanel" class="warnings-panel hidden"></div>
|
||||
<div id="settingsContent" class="settings-content"></div>
|
||||
</section>
|
||||
</main>
|
||||
<script type="module" src="settings.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,17 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { parseOptionalNumberInputValue } from './input-values';
|
||||
|
||||
test('parseOptionalNumberInputValue treats empty input as unset', () => {
|
||||
assert.deepEqual(parseOptionalNumberInputValue(''), { ok: true, value: undefined });
|
||||
assert.deepEqual(parseOptionalNumberInputValue(' '), { ok: true, value: undefined });
|
||||
});
|
||||
|
||||
test('parseOptionalNumberInputValue parses finite numeric input', () => {
|
||||
assert.deepEqual(parseOptionalNumberInputValue('42'), { ok: true, value: 42 });
|
||||
assert.deepEqual(parseOptionalNumberInputValue(' 3.5 '), { ok: true, value: 3.5 });
|
||||
});
|
||||
|
||||
test('parseOptionalNumberInputValue rejects invalid numeric input', () => {
|
||||
assert.deepEqual(parseOptionalNumberInputValue('abc'), { ok: false });
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
export type OptionalNumberInputParseResult =
|
||||
| {
|
||||
ok: true;
|
||||
value: number | undefined;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
};
|
||||
|
||||
export function parseOptionalNumberInputValue(value: string): OptionalNumberInputParseResult {
|
||||
const raw = value.trim();
|
||||
if (raw.length === 0) {
|
||||
return { ok: true, value: undefined };
|
||||
}
|
||||
const next = Number(raw);
|
||||
if (!Number.isFinite(next)) {
|
||||
return { ok: false };
|
||||
}
|
||||
return { ok: true, value: next };
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createSettingsDraft,
|
||||
filterSettingsFields,
|
||||
setDraftValue,
|
||||
getDirtyOperations,
|
||||
} from './settings-model';
|
||||
import type { ConfigSettingsField } from '../types/settings';
|
||||
|
||||
const fields: ConfigSettingsField[] = [
|
||||
{
|
||||
id: 'subtitleStyle.autoPauseVideoOnHover',
|
||||
label: 'Pause on subtitle hover',
|
||||
description: 'Pause while hovering subtitles.',
|
||||
configPath: 'subtitleStyle.autoPauseVideoOnHover',
|
||||
category: 'viewing',
|
||||
section: 'Playback pause behavior',
|
||||
control: 'boolean',
|
||||
defaultValue: true,
|
||||
restartBehavior: 'hot-reload',
|
||||
},
|
||||
{
|
||||
id: 'ankiConnect.enabled',
|
||||
label: 'Enable AnkiConnect',
|
||||
description: 'Enable Anki integration.',
|
||||
configPath: 'ankiConnect.enabled',
|
||||
category: 'mining-anki',
|
||||
section: 'Connection',
|
||||
control: 'boolean',
|
||||
defaultValue: true,
|
||||
restartBehavior: 'restart',
|
||||
},
|
||||
];
|
||||
|
||||
test('filterSettingsFields searches label, section, and config path', () => {
|
||||
assert.deepEqual(
|
||||
filterSettingsFields(fields, { category: 'viewing', query: 'hover' }).map(
|
||||
(field) => field.configPath,
|
||||
),
|
||||
['subtitleStyle.autoPauseVideoOnHover'],
|
||||
);
|
||||
assert.deepEqual(filterSettingsFields(fields, { category: 'viewing', query: 'anki' }), []);
|
||||
});
|
||||
|
||||
test('settings draft tracks dirty set and emits save operations', () => {
|
||||
const draft = createSettingsDraft({
|
||||
'subtitleStyle.autoPauseVideoOnHover': true,
|
||||
});
|
||||
|
||||
setDraftValue(draft, 'subtitleStyle.autoPauseVideoOnHover', false);
|
||||
assert.deepEqual(getDirtyOperations(draft), [
|
||||
{
|
||||
op: 'set',
|
||||
path: 'subtitleStyle.autoPauseVideoOnHover',
|
||||
value: false,
|
||||
},
|
||||
]);
|
||||
|
||||
setDraftValue(draft, 'subtitleStyle.autoPauseVideoOnHover', true);
|
||||
assert.deepEqual(getDirtyOperations(draft), []);
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import type {
|
||||
ConfigSettingsCategory,
|
||||
ConfigSettingsField,
|
||||
ConfigSettingsPatchOperation,
|
||||
ConfigSettingsSnapshotValue,
|
||||
} from '../types/settings';
|
||||
|
||||
export interface SettingsFilter {
|
||||
category: ConfigSettingsCategory;
|
||||
query?: string;
|
||||
}
|
||||
|
||||
export interface SettingsDraft {
|
||||
readonly initialValues: Record<string, ConfigSettingsSnapshotValue>;
|
||||
readonly values: Record<string, ConfigSettingsSnapshotValue>;
|
||||
readonly resetPaths: Set<string>;
|
||||
}
|
||||
|
||||
function normalizeQuery(query: string | undefined): string {
|
||||
return (query ?? '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function valuesEqual(a: unknown, b: unknown): boolean {
|
||||
return JSON.stringify(a) === JSON.stringify(b);
|
||||
}
|
||||
|
||||
export function filterSettingsFields(
|
||||
fields: ConfigSettingsField[],
|
||||
filter: SettingsFilter,
|
||||
): ConfigSettingsField[] {
|
||||
const query = normalizeQuery(filter.query);
|
||||
return fields.filter((field) => {
|
||||
if (field.category !== filter.category || field.legacyHidden) {
|
||||
return false;
|
||||
}
|
||||
if (!query) {
|
||||
return true;
|
||||
}
|
||||
const haystack = [
|
||||
field.label,
|
||||
field.description,
|
||||
field.configPath,
|
||||
field.section,
|
||||
field.enumValues?.join(' ') ?? '',
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
return haystack.includes(query);
|
||||
});
|
||||
}
|
||||
|
||||
export function createSettingsDraft(
|
||||
values: Record<string, ConfigSettingsSnapshotValue>,
|
||||
): SettingsDraft {
|
||||
return {
|
||||
initialValues: structuredClone(values),
|
||||
values: structuredClone(values),
|
||||
resetPaths: new Set(),
|
||||
};
|
||||
}
|
||||
|
||||
export function setDraftValue(
|
||||
draft: SettingsDraft,
|
||||
path: string,
|
||||
value: ConfigSettingsSnapshotValue,
|
||||
): void {
|
||||
draft.values[path] = value;
|
||||
draft.resetPaths.delete(path);
|
||||
}
|
||||
|
||||
export function resetDraftPath(draft: SettingsDraft, path: string, defaultValue: unknown): void {
|
||||
draft.values[path] = structuredClone(defaultValue);
|
||||
draft.resetPaths.add(path);
|
||||
}
|
||||
|
||||
export function getDirtyOperations(draft: SettingsDraft): ConfigSettingsPatchOperation[] {
|
||||
const operations: ConfigSettingsPatchOperation[] = [];
|
||||
const paths = new Set([...Object.keys(draft.initialValues), ...Object.keys(draft.values)]);
|
||||
|
||||
for (const path of [...paths].sort()) {
|
||||
if (draft.resetPaths.has(path)) {
|
||||
operations.push({ op: 'reset', path });
|
||||
continue;
|
||||
}
|
||||
if (!valuesEqual(draft.values[path], draft.initialValues[path])) {
|
||||
operations.push({
|
||||
op: 'set',
|
||||
path,
|
||||
value: draft.values[path],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return operations;
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
import type {
|
||||
ConfigSettingsAPI,
|
||||
ConfigSettingsCategory,
|
||||
ConfigSettingsField,
|
||||
ConfigSettingsPatchOperation,
|
||||
ConfigSettingsSnapshot,
|
||||
ConfigSettingsSnapshotValue,
|
||||
} from '../types/settings';
|
||||
import { parseOptionalNumberInputValue } from './input-values';
|
||||
import {
|
||||
createSettingsDraft,
|
||||
filterSettingsFields,
|
||||
getDirtyOperations,
|
||||
resetDraftPath,
|
||||
setDraftValue,
|
||||
type SettingsDraft,
|
||||
} from './settings-model';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
configSettingsAPI: ConfigSettingsAPI;
|
||||
}
|
||||
}
|
||||
|
||||
const CATEGORY_LABELS: Record<ConfigSettingsCategory, string> = {
|
||||
viewing: 'Viewing',
|
||||
'mining-anki': 'Mining & Anki',
|
||||
'playback-sources': 'Playback & Sources',
|
||||
input: 'Input',
|
||||
integrations: 'Integrations',
|
||||
'tracking-app': 'Tracking & App',
|
||||
advanced: 'Advanced',
|
||||
};
|
||||
|
||||
const CATEGORY_ORDER: ConfigSettingsCategory[] = [
|
||||
'viewing',
|
||||
'mining-anki',
|
||||
'playback-sources',
|
||||
'input',
|
||||
'integrations',
|
||||
'tracking-app',
|
||||
'advanced',
|
||||
];
|
||||
|
||||
const state: {
|
||||
snapshot: ConfigSettingsSnapshot | null;
|
||||
draft: SettingsDraft | null;
|
||||
category: ConfigSettingsCategory;
|
||||
query: string;
|
||||
inputErrors: Map<string, string>;
|
||||
} = {
|
||||
snapshot: null,
|
||||
draft: null,
|
||||
category: 'viewing',
|
||||
query: '',
|
||||
inputErrors: new Map(),
|
||||
};
|
||||
|
||||
function getElement<T extends HTMLElement>(id: string): T {
|
||||
const element = document.getElementById(id);
|
||||
if (!element) {
|
||||
throw new Error(`Missing settings element: ${id}`);
|
||||
}
|
||||
return element as T;
|
||||
}
|
||||
|
||||
const dom = {
|
||||
categoryNav: getElement<HTMLElement>('categoryNav'),
|
||||
categoryTitle: getElement<HTMLHeadingElement>('categoryTitle'),
|
||||
categoryMeta: getElement<HTMLElement>('categoryMeta'),
|
||||
searchInput: getElement<HTMLInputElement>('searchInput'),
|
||||
openFileButton: getElement<HTMLButtonElement>('openFileButton'),
|
||||
saveButton: getElement<HTMLButtonElement>('saveButton'),
|
||||
statusBanner: getElement<HTMLElement>('statusBanner'),
|
||||
warningsPanel: getElement<HTMLElement>('warningsPanel'),
|
||||
settingsContent: getElement<HTMLElement>('settingsContent'),
|
||||
};
|
||||
|
||||
function isSecretSnapshotValue(
|
||||
value: ConfigSettingsSnapshotValue,
|
||||
): value is { configured: boolean } {
|
||||
return Boolean(value && typeof value === 'object' && 'configured' in value);
|
||||
}
|
||||
|
||||
function setStatus(message: string, tone: 'info' | 'error' | 'success' = 'info'): void {
|
||||
dom.statusBanner.textContent = message;
|
||||
dom.statusBanner.className = `status-banner ${tone}`;
|
||||
}
|
||||
|
||||
function clearStatus(): void {
|
||||
dom.statusBanner.textContent = '';
|
||||
dom.statusBanner.className = 'status-banner hidden';
|
||||
}
|
||||
|
||||
function getDirtyCount(): number {
|
||||
return state.draft ? getDirtyOperations(state.draft).length : 0;
|
||||
}
|
||||
|
||||
function syncSaveButton(): void {
|
||||
const dirtyCount = getDirtyCount();
|
||||
dom.saveButton.disabled = dirtyCount === 0 || state.inputErrors.size > 0;
|
||||
dom.saveButton.textContent = dirtyCount > 0 ? `Save ${dirtyCount}` : 'Save';
|
||||
}
|
||||
|
||||
function createElement<K extends keyof HTMLElementTagNameMap>(
|
||||
tagName: K,
|
||||
className?: string,
|
||||
): HTMLElementTagNameMap[K] {
|
||||
const element = document.createElement(tagName);
|
||||
if (className) {
|
||||
element.className = className;
|
||||
}
|
||||
return element;
|
||||
}
|
||||
|
||||
function createFieldMeta(field: ConfigSettingsField): HTMLElement {
|
||||
const meta = createElement('div', 'field-meta');
|
||||
const path = createElement('code');
|
||||
path.textContent = field.configPath;
|
||||
meta.append(path);
|
||||
|
||||
const restart = createElement('span', `restart-chip ${field.restartBehavior}`);
|
||||
restart.textContent = field.restartBehavior === 'hot-reload' ? 'Live' : 'Restart';
|
||||
meta.append(restart);
|
||||
|
||||
if (field.advanced) {
|
||||
const advanced = createElement('span', 'advanced-chip');
|
||||
advanced.textContent = 'Advanced';
|
||||
meta.append(advanced);
|
||||
}
|
||||
return meta;
|
||||
}
|
||||
|
||||
function valueForField(field: ConfigSettingsField): ConfigSettingsSnapshotValue {
|
||||
return state.draft?.values[field.configPath] ?? field.defaultValue;
|
||||
}
|
||||
|
||||
function setFieldError(path: string, message: string | null): void {
|
||||
if (message) {
|
||||
state.inputErrors.set(path, message);
|
||||
} else {
|
||||
state.inputErrors.delete(path);
|
||||
}
|
||||
syncSaveButton();
|
||||
}
|
||||
|
||||
function updateDraft(path: string, value: ConfigSettingsSnapshotValue): void {
|
||||
if (!state.draft) return;
|
||||
setDraftValue(state.draft, path, value);
|
||||
syncSaveButton();
|
||||
}
|
||||
|
||||
function renderJsonInput(
|
||||
field: ConfigSettingsField,
|
||||
value: ConfigSettingsSnapshotValue,
|
||||
): HTMLElement {
|
||||
const textarea = createElement('textarea', 'config-textarea') as HTMLTextAreaElement;
|
||||
textarea.spellcheck = false;
|
||||
textarea.value = JSON.stringify(value ?? {}, null, 2);
|
||||
textarea.addEventListener('input', () => {
|
||||
try {
|
||||
updateDraft(field.configPath, JSON.parse(textarea.value));
|
||||
textarea.classList.remove('invalid');
|
||||
setFieldError(field.configPath, null);
|
||||
} catch {
|
||||
textarea.classList.add('invalid');
|
||||
setFieldError(field.configPath, 'Invalid JSON');
|
||||
}
|
||||
});
|
||||
return textarea;
|
||||
}
|
||||
|
||||
function renderStringListInput(
|
||||
field: ConfigSettingsField,
|
||||
value: ConfigSettingsSnapshotValue,
|
||||
): HTMLElement {
|
||||
const textarea = createElement('textarea', 'config-textarea compact') as HTMLTextAreaElement;
|
||||
textarea.spellcheck = false;
|
||||
textarea.value = Array.isArray(value) ? value.join('\n') : '';
|
||||
textarea.addEventListener('input', () => {
|
||||
updateDraft(
|
||||
field.configPath,
|
||||
textarea.value
|
||||
.split('\n')
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
});
|
||||
return textarea;
|
||||
}
|
||||
|
||||
function renderControl(field: ConfigSettingsField): HTMLElement {
|
||||
const value = valueForField(field);
|
||||
|
||||
if (field.control === 'boolean') {
|
||||
const label = createElement('label', 'switch-control');
|
||||
const input = createElement('input') as HTMLInputElement;
|
||||
input.type = 'checkbox';
|
||||
input.checked = Boolean(value);
|
||||
input.addEventListener('change', () => updateDraft(field.configPath, input.checked));
|
||||
const track = createElement('span', 'switch-track');
|
||||
label.append(input, track);
|
||||
return label;
|
||||
}
|
||||
|
||||
if (field.control === 'number') {
|
||||
const input = createElement('input', 'config-input') as HTMLInputElement;
|
||||
input.type = 'number';
|
||||
input.value = typeof value === 'number' ? String(value) : '';
|
||||
input.addEventListener('input', () => {
|
||||
const next = parseOptionalNumberInputValue(input.value);
|
||||
if (next.ok) {
|
||||
input.classList.remove('invalid');
|
||||
setFieldError(field.configPath, null);
|
||||
updateDraft(field.configPath, next.value);
|
||||
} else {
|
||||
input.classList.add('invalid');
|
||||
setFieldError(field.configPath, 'Invalid number');
|
||||
}
|
||||
});
|
||||
return input;
|
||||
}
|
||||
|
||||
if (field.control === 'select') {
|
||||
const select = createElement('select', 'config-input') as HTMLSelectElement;
|
||||
for (const enumValue of field.enumValues ?? []) {
|
||||
const option = createElement('option') as HTMLOptionElement;
|
||||
option.value = enumValue;
|
||||
option.textContent = enumValue;
|
||||
option.selected = enumValue === value;
|
||||
select.append(option);
|
||||
}
|
||||
select.addEventListener('change', () => updateDraft(field.configPath, select.value));
|
||||
return select;
|
||||
}
|
||||
|
||||
if (field.control === 'string-list') {
|
||||
return renderStringListInput(field, value);
|
||||
}
|
||||
|
||||
if (field.control === 'json') {
|
||||
return renderJsonInput(field, value);
|
||||
}
|
||||
|
||||
if (field.control === 'textarea') {
|
||||
const textarea = createElement('textarea', 'config-textarea compact') as HTMLTextAreaElement;
|
||||
textarea.spellcheck = false;
|
||||
textarea.value = typeof value === 'string' ? value : '';
|
||||
textarea.addEventListener('input', () => updateDraft(field.configPath, textarea.value));
|
||||
return textarea;
|
||||
}
|
||||
|
||||
const input = createElement('input', 'config-input') as HTMLInputElement;
|
||||
input.type = field.control === 'secret' ? 'password' : field.control;
|
||||
if (field.control === 'secret') {
|
||||
input.placeholder =
|
||||
isSecretSnapshotValue(value) && value.configured ? 'Configured' : 'Not configured';
|
||||
input.addEventListener('input', () => {
|
||||
if (input.value.trim().length === 0) {
|
||||
if (state.draft) {
|
||||
setDraftValue(state.draft, field.configPath, state.draft.initialValues[field.configPath]);
|
||||
}
|
||||
syncSaveButton();
|
||||
return;
|
||||
}
|
||||
updateDraft(field.configPath, input.value);
|
||||
});
|
||||
} else {
|
||||
input.value = typeof value === 'string' ? value : '';
|
||||
input.addEventListener('input', () => updateDraft(field.configPath, input.value));
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
function renderWarnings(snapshot: ConfigSettingsSnapshot): void {
|
||||
dom.warningsPanel.replaceChildren();
|
||||
if (snapshot.warnings.length === 0) {
|
||||
dom.warningsPanel.className = 'warnings-panel hidden';
|
||||
return;
|
||||
}
|
||||
|
||||
const title = createElement('div', 'warnings-title');
|
||||
title.textContent = `${snapshot.warnings.length} validation warning${
|
||||
snapshot.warnings.length === 1 ? '' : 's'
|
||||
}`;
|
||||
dom.warningsPanel.append(title);
|
||||
|
||||
for (const warning of snapshot.warnings.slice(0, 6)) {
|
||||
const row = createElement('div', 'warning-row');
|
||||
const path = createElement('code');
|
||||
path.textContent = warning.path;
|
||||
const message = createElement('span');
|
||||
message.textContent = warning.message;
|
||||
row.append(path, message);
|
||||
dom.warningsPanel.append(row);
|
||||
}
|
||||
dom.warningsPanel.className = 'warnings-panel';
|
||||
}
|
||||
|
||||
function renderCategoryNav(snapshot: ConfigSettingsSnapshot): void {
|
||||
dom.categoryNav.replaceChildren();
|
||||
for (const category of CATEGORY_ORDER) {
|
||||
const count = snapshot.fields.filter(
|
||||
(field) => field.category === category && !field.legacyHidden,
|
||||
).length;
|
||||
if (count === 0) continue;
|
||||
const button = createElement('button', 'category-button') as HTMLButtonElement;
|
||||
button.type = 'button';
|
||||
button.classList.toggle('active', state.category === category);
|
||||
const label = createElement('span');
|
||||
label.textContent = CATEGORY_LABELS[category];
|
||||
const badge = createElement('strong');
|
||||
badge.textContent = String(count);
|
||||
button.append(label, badge);
|
||||
button.addEventListener('click', () => {
|
||||
state.category = category;
|
||||
render();
|
||||
});
|
||||
dom.categoryNav.append(button);
|
||||
}
|
||||
}
|
||||
|
||||
function renderField(field: ConfigSettingsField): HTMLElement {
|
||||
const row = createElement('article', 'field-row');
|
||||
const header = createElement('div', 'field-copy');
|
||||
const label = createElement('h3');
|
||||
label.textContent = field.label;
|
||||
const description = createElement('p');
|
||||
description.textContent = field.description;
|
||||
header.append(label, description, createFieldMeta(field));
|
||||
|
||||
const controlWrap = createElement('div', 'field-control');
|
||||
controlWrap.append(renderControl(field));
|
||||
const resetButton = createElement('button', 'reset-button') as HTMLButtonElement;
|
||||
resetButton.type = 'button';
|
||||
resetButton.textContent = 'Reset';
|
||||
resetButton.addEventListener('click', () => {
|
||||
if (!state.draft) return;
|
||||
resetDraftPath(state.draft, field.configPath, field.defaultValue);
|
||||
state.inputErrors.delete(field.configPath);
|
||||
render();
|
||||
});
|
||||
controlWrap.append(resetButton);
|
||||
row.append(header, controlWrap);
|
||||
return row;
|
||||
}
|
||||
|
||||
function renderSettingsContent(snapshot: ConfigSettingsSnapshot): void {
|
||||
dom.settingsContent.replaceChildren();
|
||||
const fields = filterSettingsFields(snapshot.fields, {
|
||||
category: state.category,
|
||||
query: state.query,
|
||||
});
|
||||
|
||||
dom.categoryTitle.textContent = CATEGORY_LABELS[state.category];
|
||||
dom.categoryMeta.textContent = `${fields.length} setting${fields.length === 1 ? '' : 's'}`;
|
||||
|
||||
if (fields.length === 0) {
|
||||
const empty = createElement('div', 'empty-state');
|
||||
empty.textContent = 'No matching settings';
|
||||
dom.settingsContent.append(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
const sections = new Map<string, ConfigSettingsField[]>();
|
||||
for (const field of fields) {
|
||||
const sectionFields = sections.get(field.section) ?? [];
|
||||
sectionFields.push(field);
|
||||
sections.set(field.section, sectionFields);
|
||||
}
|
||||
|
||||
for (const [section, sectionFields] of sections) {
|
||||
const sectionEl = createElement('section', 'settings-section');
|
||||
const title = createElement('h2');
|
||||
title.textContent = section;
|
||||
sectionEl.append(title);
|
||||
for (const field of sectionFields) {
|
||||
sectionEl.append(renderField(field));
|
||||
}
|
||||
dom.settingsContent.append(sectionEl);
|
||||
}
|
||||
}
|
||||
|
||||
function render(): void {
|
||||
const snapshot = state.snapshot;
|
||||
if (!snapshot) return;
|
||||
renderCategoryNav(snapshot);
|
||||
renderWarnings(snapshot);
|
||||
renderSettingsContent(snapshot);
|
||||
syncSaveButton();
|
||||
}
|
||||
|
||||
async function loadSnapshot(): Promise<void> {
|
||||
clearStatus();
|
||||
const snapshot = await window.configSettingsAPI.getSnapshot();
|
||||
state.snapshot = snapshot;
|
||||
state.draft = createSettingsDraft(snapshot.values);
|
||||
state.inputErrors.clear();
|
||||
render();
|
||||
}
|
||||
|
||||
async function save(): Promise<void> {
|
||||
if (!state.draft) return;
|
||||
const operations: ConfigSettingsPatchOperation[] = getDirtyOperations(state.draft);
|
||||
if (operations.length === 0) return;
|
||||
|
||||
dom.saveButton.disabled = true;
|
||||
setStatus('Saving...', 'info');
|
||||
try {
|
||||
const result = await window.configSettingsAPI.savePatch({ operations });
|
||||
if (!result.ok || !result.snapshot) {
|
||||
const message =
|
||||
result.error ??
|
||||
result.warnings?.map((warning) => `${warning.path}: ${warning.message}`).join('\n') ??
|
||||
'Save failed';
|
||||
setStatus(message, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
state.snapshot = result.snapshot;
|
||||
state.draft = createSettingsDraft(result.snapshot.values);
|
||||
state.inputErrors.clear();
|
||||
const restartSections = result.restartRequiredSections ?? [];
|
||||
if (restartSections.length > 0) {
|
||||
setStatus(`Saved. Restart required: ${restartSections.join(', ')}`, 'info');
|
||||
} else if (result.hotReloadFields.length > 0) {
|
||||
setStatus('Saved. Live settings applied.', 'success');
|
||||
} else {
|
||||
setStatus('Saved.', 'success');
|
||||
}
|
||||
render();
|
||||
} catch (error) {
|
||||
setStatus(error instanceof Error ? error.message : 'Save failed', 'error');
|
||||
} finally {
|
||||
syncSaveButton();
|
||||
}
|
||||
}
|
||||
|
||||
dom.searchInput.addEventListener('input', () => {
|
||||
state.query = dom.searchInput.value;
|
||||
render();
|
||||
});
|
||||
dom.saveButton.addEventListener('click', () => {
|
||||
void save();
|
||||
});
|
||||
dom.openFileButton.addEventListener('click', () => {
|
||||
void window.configSettingsAPI.openSettingsFile();
|
||||
});
|
||||
|
||||
void loadSnapshot().catch((error) => {
|
||||
setStatus(error instanceof Error ? error.message : 'Failed to load settings', 'error');
|
||||
});
|
||||
@@ -0,0 +1,655 @@
|
||||
@font-face {
|
||||
font-family: 'M PLUS 1';
|
||||
src: url('./fonts/MPLUS1[wght].ttf') format('truetype');
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
:root {
|
||||
/* Catppuccin Macchiato */
|
||||
--ctp-rosewater: #f4dbd6;
|
||||
--ctp-flamingo: #f0c6c6;
|
||||
--ctp-pink: #f5bde6;
|
||||
--ctp-mauve: #c6a0f6;
|
||||
--ctp-red: #ed8796;
|
||||
--ctp-maroon: #ee99a0;
|
||||
--ctp-peach: #f5a97f;
|
||||
--ctp-yellow: #eed49f;
|
||||
--ctp-green: #a6da95;
|
||||
--ctp-teal: #8bd5ca;
|
||||
--ctp-sky: #91d7e3;
|
||||
--ctp-sapphire: #7dc4e4;
|
||||
--ctp-blue: #8aadf4;
|
||||
--ctp-lavender: #b7bdf8;
|
||||
--ctp-text: #cad3f5;
|
||||
--ctp-subtext1: #b8c0e0;
|
||||
--ctp-subtext0: #a5adcb;
|
||||
--ctp-overlay2: #939ab7;
|
||||
--ctp-overlay1: #8087a2;
|
||||
--ctp-overlay0: #6e738d;
|
||||
--ctp-surface2: #5b6078;
|
||||
--ctp-surface1: #494d64;
|
||||
--ctp-surface0: #363a4f;
|
||||
--ctp-base: #24273a;
|
||||
--ctp-mantle: #1e2030;
|
||||
--ctp-crust: #181926;
|
||||
|
||||
/* Semantic */
|
||||
--bg: var(--ctp-base);
|
||||
--panel: rgba(36, 39, 58, 0.85);
|
||||
--panel-elevated: rgba(54, 58, 79, 0.55);
|
||||
--line: rgba(110, 115, 141, 0.28);
|
||||
--line-soft: rgba(110, 115, 141, 0.14);
|
||||
--text: var(--ctp-text);
|
||||
--muted: var(--ctp-subtext0);
|
||||
--faint: var(--ctp-overlay1);
|
||||
--accent: var(--ctp-blue);
|
||||
--accent-strong: var(--ctp-lavender);
|
||||
--highlight: var(--ctp-mauve);
|
||||
--danger: var(--ctp-red);
|
||||
--ok: var(--ctp-green);
|
||||
--warn: var(--ctp-peach);
|
||||
--shadow: rgba(0, 0, 0, 0.42);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
background: var(--ctp-base);
|
||||
color: var(--text);
|
||||
font-family:
|
||||
'M PLUS 1', 'Avenir Next', 'Hiragino Sans', 'Hiragino Kaku Gothic ProN', 'Yu Gothic', sans-serif;
|
||||
letter-spacing: 0;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
border: 2px solid transparent;
|
||||
border-radius: 999px;
|
||||
background-clip: padding-box;
|
||||
background-color: rgba(110, 115, 141, 0.35);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(138, 173, 244, 0.45);
|
||||
}
|
||||
|
||||
.settings-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 244px minmax(0, 1fr);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.settings-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
padding: 22px 16px;
|
||||
border-right: 1px solid var(--line);
|
||||
background: var(--ctp-mantle);
|
||||
}
|
||||
|
||||
.brand-block {
|
||||
padding: 6px 8px 14px;
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-size: 21px;
|
||||
font-weight: 820;
|
||||
color: var(--ctp-lavender);
|
||||
}
|
||||
|
||||
.brand-subtitle {
|
||||
margin-top: 4px;
|
||||
color: var(--ctp-overlay2);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.category-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.category-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
min-height: 38px;
|
||||
padding: 8px 11px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 9px;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
text-align: left;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 140ms ease,
|
||||
border-color 140ms ease,
|
||||
color 140ms ease;
|
||||
}
|
||||
|
||||
.category-button:hover {
|
||||
border-color: var(--line-soft);
|
||||
background: rgba(138, 173, 244, 0.06);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.category-button.active {
|
||||
border-color: rgba(138, 173, 244, 0.42);
|
||||
background: rgba(138, 173, 244, 0.14);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.category-button strong {
|
||||
min-width: 24px;
|
||||
padding: 2px 7px;
|
||||
border-radius: 999px;
|
||||
background: rgba(110, 115, 141, 0.2);
|
||||
color: var(--ctp-subtext0);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.category-button.active strong {
|
||||
background: rgba(138, 173, 244, 0.22);
|
||||
color: var(--ctp-lavender);
|
||||
}
|
||||
|
||||
.settings-main {
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.settings-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
min-height: 78px;
|
||||
padding: 18px 24px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--ctp-mantle);
|
||||
}
|
||||
|
||||
.toolbar-title-block {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
line-height: 1.15;
|
||||
font-weight: 800;
|
||||
color: var(--ctp-text);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.toolbar-meta {
|
||||
margin-top: 5px;
|
||||
color: var(--ctp-overlay2);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.search-input,
|
||||
.config-input,
|
||||
.config-textarea {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: rgba(24, 25, 38, 0.85);
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
transition:
|
||||
border-color 140ms ease,
|
||||
box-shadow 140ms ease,
|
||||
background 140ms ease;
|
||||
}
|
||||
|
||||
.search-input::placeholder,
|
||||
.config-input::placeholder,
|
||||
.config-textarea::placeholder {
|
||||
color: var(--ctp-overlay0);
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 210px;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.config-input {
|
||||
width: min(320px, 100%);
|
||||
min-height: 36px;
|
||||
padding: 7px 10px;
|
||||
}
|
||||
|
||||
.config-textarea {
|
||||
width: min(420px, 100%);
|
||||
min-height: 138px;
|
||||
padding: 9px 11px;
|
||||
resize: vertical;
|
||||
font-family:
|
||||
'JetBrains Mono', 'SF Mono', 'M PLUS 1', 'Avenir Next', ui-monospace, SFMono-Regular, Menlo,
|
||||
monospace;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.config-textarea.compact {
|
||||
min-height: 86px;
|
||||
}
|
||||
|
||||
.search-input:hover,
|
||||
.config-input:hover,
|
||||
.config-textarea:hover {
|
||||
border-color: rgba(138, 173, 244, 0.32);
|
||||
}
|
||||
|
||||
.search-input:focus,
|
||||
.config-input:focus,
|
||||
.config-textarea:focus {
|
||||
border-color: rgba(138, 173, 244, 0.65);
|
||||
background: rgba(24, 25, 38, 0.95);
|
||||
box-shadow: 0 0 0 3px rgba(138, 173, 244, 0.15);
|
||||
}
|
||||
|
||||
select.config-input {
|
||||
appearance: none;
|
||||
padding-right: 32px;
|
||||
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6' fill='none'><path d='M1 1l4 4 4-4' stroke='%23a5adcb' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/></svg>");
|
||||
background-position: right 12px center;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
select.config-input option {
|
||||
background: var(--ctp-mantle);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.invalid,
|
||||
.invalid:focus {
|
||||
border-color: rgba(237, 135, 150, 0.65);
|
||||
box-shadow: 0 0 0 3px rgba(237, 135, 150, 0.12);
|
||||
}
|
||||
|
||||
.primary-button,
|
||||
.secondary-button,
|
||||
.reset-button {
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--line);
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
transition:
|
||||
background 140ms ease,
|
||||
border-color 140ms ease,
|
||||
color 140ms ease,
|
||||
transform 60ms ease;
|
||||
}
|
||||
|
||||
.primary-button:active,
|
||||
.secondary-button:active,
|
||||
.reset-button:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
min-width: 92px;
|
||||
padding: 0 16px;
|
||||
border-color: transparent;
|
||||
background: var(--ctp-blue);
|
||||
color: var(--ctp-crust);
|
||||
}
|
||||
|
||||
.primary-button:hover:not(:disabled) {
|
||||
filter: brightness(1.06);
|
||||
}
|
||||
|
||||
.primary-button:disabled {
|
||||
border-color: var(--line);
|
||||
background: rgba(54, 58, 79, 0.55);
|
||||
color: var(--ctp-overlay0);
|
||||
box-shadow: none;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.secondary-button,
|
||||
.reset-button {
|
||||
padding: 0 13px;
|
||||
background: rgba(54, 58, 79, 0.5);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.secondary-button:hover,
|
||||
.reset-button:hover {
|
||||
border-color: rgba(138, 173, 244, 0.45);
|
||||
background: rgba(73, 77, 100, 0.6);
|
||||
color: var(--ctp-lavender);
|
||||
}
|
||||
|
||||
.reset-button {
|
||||
height: 32px;
|
||||
padding: 0 10px;
|
||||
font-size: 12px;
|
||||
color: var(--ctp-subtext0);
|
||||
}
|
||||
|
||||
.status-banner,
|
||||
.warnings-panel {
|
||||
margin: 14px 24px 0;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--ctp-surface0);
|
||||
}
|
||||
|
||||
.status-banner {
|
||||
padding: 11px 14px;
|
||||
white-space: pre-wrap;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--ctp-subtext1);
|
||||
}
|
||||
|
||||
.status-banner.success {
|
||||
border-color: rgba(166, 218, 149, 0.45);
|
||||
background: rgba(166, 218, 149, 0.1);
|
||||
color: var(--ctp-green);
|
||||
}
|
||||
|
||||
.status-banner.error {
|
||||
border-color: rgba(237, 135, 150, 0.55);
|
||||
background: rgba(237, 135, 150, 0.1);
|
||||
color: var(--ctp-red);
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.warnings-panel {
|
||||
padding: 12px 14px;
|
||||
border-color: rgba(238, 212, 159, 0.32);
|
||||
background: rgba(238, 212, 159, 0.07);
|
||||
}
|
||||
|
||||
.warnings-title {
|
||||
margin-bottom: 8px;
|
||||
color: var(--ctp-yellow);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.warning-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(120px, 220px) minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
padding: 5px 0;
|
||||
color: var(--ctp-subtext1);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
code {
|
||||
padding: 2px 6px;
|
||||
border-radius: 5px;
|
||||
background: rgba(24, 25, 38, 0.7);
|
||||
border: 1px solid var(--line-soft);
|
||||
color: var(--ctp-teal);
|
||||
font-family: 'JetBrains Mono', 'SF Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 20px 24px 32px;
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
margin-bottom: 24px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--line-soft);
|
||||
background: var(--ctp-mantle);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.settings-section h2 {
|
||||
margin: 0;
|
||||
padding: 12px 16px;
|
||||
background: var(--ctp-crust);
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
color: var(--ctp-lavender);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.field-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(220px, 430px);
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
padding: 16px 16px;
|
||||
border-top: 1px solid var(--line-soft);
|
||||
}
|
||||
|
||||
.settings-section h2 + .field-row {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.field-copy h3 {
|
||||
margin: 0 0 5px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--ctp-text);
|
||||
letter-spacing: -0.005em;
|
||||
}
|
||||
|
||||
.field-copy p {
|
||||
max-width: 640px;
|
||||
margin: 0;
|
||||
color: var(--ctp-subtext0);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.field-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.restart-chip,
|
||||
.advanced-chip {
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(54, 58, 79, 0.5);
|
||||
color: var(--ctp-overlay2);
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.restart-chip.hot-reload {
|
||||
border-color: rgba(166, 218, 149, 0.42);
|
||||
background: rgba(166, 218, 149, 0.1);
|
||||
color: var(--ctp-green);
|
||||
}
|
||||
|
||||
.restart-chip.restart {
|
||||
border-color: rgba(245, 169, 127, 0.42);
|
||||
background: rgba(245, 169, 127, 0.1);
|
||||
color: var(--ctp-peach);
|
||||
}
|
||||
|
||||
.advanced-chip {
|
||||
border-color: rgba(198, 160, 246, 0.4);
|
||||
background: rgba(198, 160, 246, 0.1);
|
||||
color: var(--ctp-mauve);
|
||||
}
|
||||
|
||||
.field-control {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.switch-control {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
width: 46px;
|
||||
height: 26px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.switch-control input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
inset: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.switch-track {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: rgba(24, 25, 38, 0.85);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 140ms ease,
|
||||
border-color 140ms ease;
|
||||
}
|
||||
|
||||
.switch-track::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 4px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: var(--ctp-overlay1);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.35);
|
||||
transition:
|
||||
transform 160ms cubic-bezier(0.4, 0, 0.2, 1),
|
||||
background 140ms ease;
|
||||
}
|
||||
|
||||
.switch-control input:checked + .switch-track {
|
||||
border-color: rgba(138, 173, 244, 0.6);
|
||||
background: rgba(138, 173, 244, 0.3);
|
||||
}
|
||||
|
||||
.switch-control input:checked + .switch-track::after {
|
||||
transform: translateX(20px);
|
||||
background: var(--ctp-lavender);
|
||||
}
|
||||
|
||||
.switch-control input:focus-visible + .switch-track {
|
||||
box-shadow: 0 0 0 3px rgba(138, 173, 244, 0.22);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 40px;
|
||||
border: 1px dashed var(--line);
|
||||
border-radius: 10px;
|
||||
color: var(--ctp-overlay1);
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 780px) {
|
||||
.settings-shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.settings-nav {
|
||||
max-height: 200px;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.category-nav {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.settings-toolbar,
|
||||
.field-row,
|
||||
.field-control {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user