initial commit with full project

This commit is contained in:
2026-04-26 17:50:04 +03:00
parent 021f0bcb23
commit 824591084b
71 changed files with 34115 additions and 537 deletions
+202
View File
@@ -0,0 +1,202 @@
<script lang="ts">
import '../app.css';
import '@tabler/icons-webfont/dist/tabler-icons.min.css';
import TitleBar from '$lib/components/TitleBar.svelte';
import ToastContainer from '$lib/components/ToastContainer.svelte';
import { themeStore } from '$lib/stores/theme.svelte';
import { config } from '$lib/stores/config.svelte';
import { registerShortcuts, unregisterShortcuts, setShortcuts } from '$lib/utils/keyboard';
import { app } from '$lib/stores/app.svelte';
import { toasts } from '$lib/stores/toast.svelte';
import { initApp, checkRecovery, cleanupRecovery } from '$lib/utils/tauri';
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { getStreamPort, setStreamBase } from '$lib/utils/tauri';
let { children } = $props();
let onSettings = $derived(page.url.pathname === '/settings');
let prevSettings = $state(false);
let slideDir = $state<'left' | 'right' | null>(null);
$effect(() => {
if (onSettings !== prevSettings) {
slideDir = onSettings ? 'left' : 'right';
prevSettings = onSettings;
}
});
function handleSettingsClick() {
if (onSettings) {
goto('/');
} else {
goto('/settings');
}
}
onMount(async () => {
await config.load();
themeStore.init(config.current.theme);
// apply ui zoom
if (config.current.ui_zoom && config.current.ui_zoom !== 100) {
document.documentElement.style.fontSize = `${(config.current.ui_zoom / 100) * 16}px`;
}
// check ffmpeg availability (with timeout so app doesn't hang)
try {
const status = await Promise.race([
initApp(),
new Promise<never>((_, reject) => setTimeout(() => reject(new Error('timeout')), 5000))
]);
app.ffmpegStatus = status;
if (!status.found) {
app.state = 'setup';
}
} catch {
app.state = 'setup';
}
// initialize stream server base URL
try {
const port = await getStreamPort();
if (port > 0) {
setStreamBase(`http://127.0.0.1:${port}/`);
}
} catch (e) {
console.error('Failed to get stream port:', e);
}
// check for interrupted job (non-blocking - don't hold up the app)
checkRecovery().then(async (interrupted) => {
if (interrupted) {
toasts.add('info', 'A previous job was interrupted.');
await cleanupRecovery();
}
}).catch(() => {});
setShortcuts([
{
key: 's',
ctrl: true,
handler: () => {
if (app.state === 'loaded' && app.activeMode !== 'none') {
app.startProcess();
}
},
when: () => app.state === 'loaded'
},
{
key: ',',
ctrl: true,
handler: () => {
goto(onSettings ? '/' : '/settings');
}
},
{
key: 'o',
ctrl: true,
handler: () => {
window.dispatchEvent(new CustomEvent('cinch-open-file'));
},
when: () => app.state !== 'processing' && app.state !== 'analyzing'
},
{
key: ' ',
handler: () => { app.togglePlayback(); },
when: () => app.state === 'loaded' || app.state === 'processing'
},
{
key: 'Escape',
handler: () => {
if (app.state === 'processing') {
app.cancelProcess();
} else if (onSettings) {
goto('/');
}
}
},
{
key: 'i',
handler: () => {
const out = app.trimRange?.end ?? app.videoInfo?.duration ?? 0;
app.setTrimRange(app.currentTime, out);
},
when: () => app.state === 'loaded'
},
{
key: 'o',
handler: () => {
const inPt = app.trimRange?.start ?? 0;
app.setTrimRange(inPt, app.currentTime);
},
when: () => app.state === 'loaded'
},
{
key: 'ArrowLeft',
handler: () => { app.seek(app.currentTime - 1); },
when: () => app.state === 'loaded'
},
{
key: 'ArrowRight',
handler: () => { app.seek(app.currentTime + 1); },
when: () => app.state === 'loaded'
},
{
key: 'ArrowLeft',
shift: true,
handler: () => { app.seek(app.currentTime - 5); },
when: () => app.state === 'loaded'
},
{
key: 'ArrowRight',
shift: true,
handler: () => { app.seek(app.currentTime + 5); },
when: () => app.state === 'loaded'
},
{
key: 'Home',
handler: () => { app.seek(0); },
when: () => app.state === 'loaded'
},
{
key: 'End',
handler: () => { app.seek(app.videoInfo?.duration ?? 0); },
when: () => app.state === 'loaded'
}
]);
registerShortcuts();
return () => {
unregisterShortcuts();
themeStore.destroy();
};
});
</script>
<div class="flex flex-col h-screen overflow-hidden" style="background: var(--color-bg-base)">
<TitleBar onSettingsClick={handleSettingsClick} showBack={onSettings} />
<ToastContainer />
<div
class="flex-1 flex flex-col overflow-hidden"
class:slide-in-left={slideDir === 'left'}
class:slide-in-right={slideDir === 'right'}
onanimationend={() => { slideDir = null; }}
>
{@render children()}
</div>
</div>
<style>
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.slide-in-left {
animation: fadeIn 200ms ease-out;
}
.slide-in-right {
animation: fadeIn 200ms ease-out;
}
</style>
+2
View File
@@ -0,0 +1,2 @@
export const prerender = true;
export const ssr = false;
+5
View File
@@ -0,0 +1,5 @@
<script lang="ts">
import MainView from '$lib/components/MainView.svelte';
</script>
<MainView />
+774
View File
@@ -0,0 +1,774 @@
<script lang="ts">
import { config } from '$lib/stores/config.svelte';
import { themeStore } from '$lib/stores/theme.svelte';
import { app } from '$lib/stores/app.svelte';
import { checkFfmpeg } from '$lib/utils/tauri';
import { toasts } from '$lib/stores/toast.svelte';
import CustomSelect from '$lib/components/CustomSelect.svelte';
import Slider from '$lib/components/Slider.svelte';
import Toggle from '$lib/components/Toggle.svelte';
import type { Theme, CompressSettings } from '$lib/types';
let detecting = $state(false);
const themeChoices: { value: Theme; icon: string; label: string }[] = [
{ value: 'light', icon: 'ti-sun', label: 'Light' },
{ value: 'dark', icon: 'ti-moon', label: 'Dark' },
{ value: 'system', icon: 'ti-device-desktop', label: 'System' }
];
function handleThemeChange(t: Theme) {
themeStore.setTheme(t);
config.update({ theme: t });
}
function handleZoom(val: number) {
const zoom = Math.round(val);
config.update({ ui_zoom: zoom });
document.documentElement.style.fontSize = `${(zoom / 100) * 16}px`;
}
const targetOptions = [
{ value: '8', label: '8 MB', icon: 'ti-file-zip' },
{ value: '25', label: '25 MB', icon: 'ti-file-zip' },
{ value: '50', label: '50 MB', icon: 'ti-file-zip' },
{ value: '100', label: '100 MB', icon: 'ti-file-zip' }
];
const codecOptions = [
{ value: 'H264', label: 'H.264', icon: 'ti-badge-hd' },
{ value: 'HEVC', label: 'HEVC', icon: 'ti-badge-4k' },
{ value: 'AV1', label: 'AV1', icon: 'ti-atom' }
];
const containerOptions = [
{ value: 'MP4', label: 'MP4', icon: 'ti-box' },
{ value: 'MKV', label: 'MKV', icon: 'ti-package' },
{ value: 'WebM', label: 'WebM', icon: 'ti-world' },
{ value: 'MOV', label: 'MOV', icon: 'ti-movie' },
{ value: 'AVI', label: 'AVI', icon: 'ti-archive' },
{ value: 'TS', label: 'TS', icon: 'ti-broadcast' }
];
const audioCodecOptions = [
{ value: 'AAC', label: 'AAC', icon: 'ti-headphones' },
{ value: 'Opus', label: 'Opus', icon: 'ti-vinyl' },
{ value: 'None', label: 'None (strip)', icon: 'ti-volume-off' }
];
const audioBitrateOptions = [
{ value: '64', label: '64 kbps', icon: 'ti-antenna-bars-1' },
{ value: '96', label: '96 kbps', icon: 'ti-antenna-bars-2' },
{ value: '128', label: '128 kbps', icon: 'ti-antenna-bars-3' },
{ value: '192', label: '192 kbps', icon: 'ti-antenna-bars-4' },
{ value: '256', label: '256 kbps', icon: 'ti-antenna-bars-5' },
{ value: '320', label: '320 kbps', icon: 'ti-antenna-bars-5' }
];
const retryThresholdOptions = [
{ value: '1', label: '1%', icon: 'ti-target' },
{ value: '2', label: '2%', icon: 'ti-target' },
{ value: '3', label: '3%', icon: 'ti-target' },
{ value: '5', label: '5%', icon: 'ti-target' }
];
const maxRetryOptions = [
{ value: '1', label: '1 attempt', icon: 'ti-refresh' },
{ value: '2', label: '2 attempts', icon: 'ti-refresh' },
{ value: '3', label: '3 attempts', icon: 'ti-refresh' },
{ value: '5', label: '5 attempts', icon: 'ti-refresh' },
{ value: '10', label: '10 attempts', icon: 'ti-refresh' }
];
const namingOptions = [
{ value: '{name}_{mode}_{timestamp}', label: 'Name + mode + time', icon: 'ti-file-text' },
{ value: '{name}_{mode}', label: 'Name + mode', icon: 'ti-file-text' },
{ value: '{name}_cinch', label: 'Name + cinch', icon: 'ti-file-text' }
];
let defaultCodec = $derived(config.current.last_compress_settings?.video_codec ?? 'H264');
let defaultContainer = $derived(config.current.last_compress_settings?.container ?? 'MP4');
let defaultAudioCodec = $derived(config.current.last_compress_settings?.audio_codec ?? 'AAC');
let defaultAudioBitrate = $derived(String(config.current.last_compress_settings?.audio_bitrate ?? 128));
const defaultCompressSettings: CompressSettings = {
strategy: { type: 'TargetSize', mb: 8 },
video_codec: 'H264',
audio_codec: 'AAC',
audio_bitrate: 128,
container: 'MP4',
resolution: { type: 'Original' },
speed_preset: 'medium',
hw_accel: 'Auto'
};
function updateLastSetting<K extends keyof CompressSettings>(key: K, value: CompressSettings[K]) {
config.update({
last_compress_settings: { ...defaultCompressSettings, ...config.current.last_compress_settings, [key]: value }
});
}
async function detectFfmpeg() {
detecting = true;
try {
const status = await checkFfmpeg();
app.ffmpegStatus = status;
if (status.found) {
config.update({ ffmpeg_path: status.path });
toasts.add('success', `FFmpeg ${status.version} detected.`);
} else {
toasts.add('warning', 'FFmpeg not found on this system.');
}
} catch {
toasts.add('error', 'Detection failed.');
}
detecting = false;
}
async function browseFfmpeg() {
try {
const { open } = await import('@tauri-apps/plugin-dialog');
const result = await open({ multiple: false, filters: [{ name: 'FFmpeg', extensions: ['exe'] }] });
if (result) {
const path = typeof result === 'string' ? result : result.path;
if (path) {
config.update({ ffmpeg_path: path });
app.ffmpegStatus = { ...app.ffmpegStatus, path };
toasts.add('success', 'FFmpeg path updated.');
}
}
} catch {
toasts.add('error', 'Could not open file browser.');
}
}
async function browseOutputDir() {
try {
const { open } = await import('@tauri-apps/plugin-dialog');
const result = await open({ directory: true });
if (result) {
const path = typeof result === 'string' ? result : result.path;
if (path) config.update({ default_output_dir: path });
}
} catch {
toasts.add('error', 'Could not open folder browser.');
}
}
async function resetDefaults() {
await config.reset();
themeStore.setTheme(config.current.theme);
document.documentElement.style.fontSize = `${(config.current.ui_zoom / 100) * 16}px`;
toasts.add('success', 'Settings reset to defaults.');
}
</script>
<div class="settings-page">
<div class="settings-header">
<h1 class="settings-title">Settings</h1>
<p class="settings-subtitle">Configure how Cinch works</p>
</div>
<div class="settings-grid">
<!-- Appearance -->
<div class="settings-card settings-card--small">
<div class="card-header">
<div class="card-icon-wrap" style="--card-icon-color: var(--color-accent-compress)">
<i class="ti ti-palette card-icon"></i>
</div>
<h2 class="card-title">Appearance</h2>
</div>
<div class="card-body">
<div class="field-group">
<span class="field-label">Theme</span>
<div class="theme-pills">
{#each themeChoices as t}
<button
type="button"
class="theme-pill"
class:theme-pill--active={themeStore.theme === t.value}
onclick={() => handleThemeChange(t.value)}
>
<i class="ti {t.icon}"></i>
{t.label}
</button>
{/each}
</div>
</div>
<div class="field-group">
<span class="field-label">UI Zoom</span>
<div class="zoom-row">
<button type="button" class="zoom-btn" onclick={() => handleZoom(Math.max(75, config.current.ui_zoom - 5))} aria-label="Decrease zoom">
<i class="ti ti-minus"></i>
</button>
<div class="zoom-slider"><Slider min={75} max={200} step={5} value={config.current.ui_zoom} onChange={handleZoom} /></div>
<button type="button" class="zoom-btn" onclick={() => handleZoom(Math.min(200, config.current.ui_zoom + 5))} aria-label="Increase zoom">
<i class="ti ti-plus"></i>
</button>
<span class="zoom-value">{config.current.ui_zoom}%</span>
</div>
</div>
</div>
</div>
<!-- Defaults -->
<div class="settings-card settings-card--large">
<div class="card-header">
<div class="card-icon-wrap" style="--card-icon-color: var(--color-accent-trim)">
<i class="ti ti-settings card-icon"></i>
</div>
<h2 class="card-title">Default settings</h2>
</div>
<div class="card-body">
<div class="selects-grid">
<div class="field-group">
<span class="field-label">Target size</span>
<CustomSelect options={targetOptions} value={String(config.current.default_target_size)} onChange={(v) => config.update({ default_target_size: parseInt(v) })} />
</div>
<div class="field-group">
<span class="field-label">Video codec</span>
<CustomSelect options={codecOptions} value={defaultCodec} onChange={(v) => updateLastSetting('video_codec', v as any)} />
</div>
<div class="field-group">
<span class="field-label">Container</span>
<CustomSelect options={containerOptions} value={defaultContainer} onChange={(v) => updateLastSetting('container', v as any)} />
</div>
<div class="field-group">
<span class="field-label">Audio codec</span>
<CustomSelect options={audioCodecOptions} value={defaultAudioCodec} onChange={(v) => updateLastSetting('audio_codec', v as any)} />
</div>
<div class="field-group">
<span class="field-label">Audio bitrate</span>
<CustomSelect options={audioBitrateOptions} value={defaultAudioBitrate} onChange={(v) => updateLastSetting('audio_bitrate', parseInt(v))} />
</div>
</div>
<div class="field-row" style="margin-top: 12px;">
<div class="field-row-left">
<i class="ti ti-cut"></i>
<div>
<span>Smart cut by default</span>
<span class="field-hint">Use smart re-encode instead of keyframe snap</span>
</div>
</div>
<Toggle checked={config.current.default_smart_cut} onChange={(v) => config.update({ default_smart_cut: v })} />
</div>
</div>
</div>
<!-- Processing -->
<div class="settings-card settings-card--medium">
<div class="card-header">
<div class="card-icon-wrap" style="--card-icon-color: var(--color-accent-warning)">
<i class="ti ti-bolt card-icon"></i>
</div>
<h2 class="card-title">Processing</h2>
</div>
<div class="card-body">
<div class="field-row">
<div class="field-row-left">
<i class="ti ti-refresh"></i>
<div>
<span>Auto-retry when over target</span>
<span class="field-hint">Automatically re-encode if output exceeds target</span>
</div>
</div>
<Toggle checked={config.current.auto_retry} onChange={(v) => config.update({ auto_retry: v })} />
</div>
{#if config.current.auto_retry}
<div class="selects-grid selects-grid--2">
<div class="field-group">
<span class="field-label">Threshold</span>
<CustomSelect options={retryThresholdOptions} value={String(config.current.retry_threshold_percent)} onChange={(v) => config.update({ retry_threshold_percent: parseFloat(v) })} />
</div>
<div class="field-group">
<span class="field-label">Max retries</span>
<CustomSelect options={maxRetryOptions} value={String(config.current.max_retry_attempts)} onChange={(v) => config.update({ max_retry_attempts: parseInt(v) })} />
</div>
</div>
{/if}
<div class="field-row" style="margin-top: 8px;">
<div class="field-row-left">
<i class="ti ti-terminal-2"></i>
<div>
<span>Show FFmpeg log</span>
<span class="field-hint">Display raw FFmpeg output during encoding</span>
</div>
</div>
<Toggle checked={config.current.show_ffmpeg_log} onChange={(v) => config.update({ show_ffmpeg_log: v })} />
</div>
</div>
</div>
<!-- FFmpeg -->
<div class="settings-card settings-card--medium">
<div class="card-header">
<div class="card-icon-wrap" style="--card-icon-color: #a78bfa">
<i class="ti ti-terminal-2 card-icon"></i>
</div>
<h2 class="card-title">FFmpeg</h2>
{#if app.ffmpegStatus.found}
<span class="card-badge card-badge--ok"><i class="ti ti-circle-check"></i> Ready</span>
{:else}
<span class="card-badge card-badge--warn"><i class="ti ti-alert-circle"></i> Missing</span>
{/if}
</div>
<div class="card-body">
<div class="field-group">
<span class="field-label">Binary path</span>
<div class="path-bar">
<i class="ti ti-folder-code path-bar-icon"></i>
<span class="path-bar-text" class:path-bar-text--empty={!config.current.ffmpeg_path}>
{config.current.ffmpeg_path ?? 'Not configured - will search system PATH'}
</span>
<div class="path-bar-actions">
<button type="button" class="path-bar-btn" onclick={browseFfmpeg} title="Browse for ffmpeg.exe">
<i class="ti ti-folder-open"></i> Browse
</button>
<button type="button" class="path-bar-btn" class:path-bar-btn--spinning={detecting} disabled={detecting} onclick={detectFfmpeg} title="Auto-detect FFmpeg">
<i class="ti ti-search"></i> Detect
</button>
</div>
</div>
{#if app.ffmpegStatus.version}
<div class="version-pill">
<i class="ti ti-circle-check"></i>
Version {app.ffmpegStatus.version} detected
</div>
{/if}
</div>
</div>
</div>
<!-- Output -->
<div class="settings-card settings-card--wide">
<div class="card-header">
<div class="card-icon-wrap" style="--card-icon-color: #f472b6">
<i class="ti ti-download card-icon"></i>
</div>
<h2 class="card-title">Output</h2>
</div>
<div class="card-body">
<div class="field-group">
<span class="field-label">Default folder</span>
<div class="path-bar">
<i class="ti ti-folder path-bar-icon"></i>
<span class="path-bar-text" class:path-bar-text--empty={!config.current.default_output_dir}>
{config.current.default_output_dir ?? 'Same folder as source file'}
</span>
<div class="path-bar-actions">
<button type="button" class="path-bar-btn" onclick={browseOutputDir} title="Choose output folder">
<i class="ti ti-folder-open"></i> Browse
</button>
{#if config.current.default_output_dir}
<button type="button" class="path-bar-btn path-bar-btn--danger" onclick={() => config.update({ default_output_dir: null })} title="Reset to source folder">
<i class="ti ti-rotate"></i> Reset
</button>
{/if}
</div>
</div>
</div>
<div class="field-group" style="margin-top: 12px;">
<span class="field-label">Naming pattern</span>
<CustomSelect options={namingOptions} value={config.current.naming_pattern} onChange={(v) => config.update({ naming_pattern: v })} />
</div>
</div>
</div>
</div>
<div class="settings-footer">
<button type="button" class="reset-btn" onclick={resetDefaults}>
<i class="ti ti-rotate"></i>
Reset all to defaults
</button>
</div>
</div>
<style>
/* ── Page shell ── */
.settings-page {
flex: 1;
overflow-y: auto;
scrollbar-width: none;
padding: 28px 32px 48px;
}
.settings-header {
margin-bottom: 24px;
}
.settings-title {
font-family: var(--font-display);
font-size: var(--text-2xl);
font-weight: 700;
color: var(--color-text-primary);
line-height: 1.2;
}
.settings-subtitle {
font-family: var(--font-body);
font-size: var(--text-sm);
color: var(--color-text-secondary);
margin-top: 4px;
}
/* ── Bento grid ── */
.settings-grid {
display: grid;
grid-template-columns: repeat(12, 1fr);
gap: 16px;
align-items: start;
}
.settings-card {
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: 20px;
transition: box-shadow var(--transition-fast), border-color var(--transition-fast);
display: flex;
flex-direction: column;
gap: 16px;
}
.settings-card:hover {
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
border-color: rgba(255, 255, 255, 0.14);
}
.settings-card--small { grid-column: span 4; }
.settings-card--large { grid-column: span 8; }
.settings-card--medium { grid-column: span 6; }
.settings-card--wide { grid-column: span 12; }
@media (max-width: 900px) {
.settings-card--small,
.settings-card--large,
.settings-card--medium,
.settings-card--wide {
grid-column: span 12;
}
}
/* ── Card header ── */
.card-header {
display: flex;
align-items: center;
gap: 10px;
padding-bottom: 14px;
border-bottom: 1px solid var(--color-border);
}
.card-icon-wrap {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--card-icon-color) 12%, transparent);
color: var(--card-icon-color);
flex-shrink: 0;
}
.card-icon {
font-size: 16px;
}
.card-title {
font-family: var(--font-display);
font-size: var(--text-base);
font-weight: 700;
color: var(--color-text-primary);
flex: 1;
}
.card-badge {
display: flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
border-radius: var(--radius-pill);
font-family: var(--font-body);
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
flex-shrink: 0;
}
.card-badge--ok {
background: rgba(5, 150, 105, 0.12);
color: var(--color-accent-compress);
}
.card-badge--warn {
background: rgba(245, 158, 11, 0.12);
color: var(--color-accent-warning);
}
/* ── Card body ── */
.card-body {
display: flex;
flex-direction: column;
gap: 14px;
}
/* ── Field groups ── */
.field-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.field-label {
font-family: var(--font-body);
font-size: 10px;
font-weight: 700;
color: var(--color-text-disabled);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.field-hint {
display: block;
font-family: var(--font-body);
font-size: 11px;
color: var(--color-text-disabled);
margin-top: 1px;
}
/* ── Field rows (toggle rows) ── */
.field-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 4px 0;
}
.field-row-left {
display: flex;
align-items: flex-start;
gap: 10px;
color: var(--color-text-primary);
font-family: var(--font-body);
font-size: var(--text-sm);
}
.field-row-left > i {
font-size: 16px;
color: var(--color-text-disabled);
margin-top: 1px;
flex-shrink: 0;
}
.field-row-left > div {
display: flex;
flex-direction: column;
}
/* ── Selects grid ── */
.selects-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.selects-grid--2 {
grid-template-columns: repeat(2, 1fr);
}
@media (max-width: 700px) {
.selects-grid {
grid-template-columns: 1fr 1fr;
}
}
/* ── Theme pills ── */
.theme-pills {
display: flex;
gap: 2px;
background: var(--color-bg-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-pill);
padding: 3px;
}
.theme-pill {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 12px;
border-radius: var(--radius-pill);
border: none;
background: transparent;
color: var(--color-text-secondary);
font-family: var(--font-body);
font-size: var(--text-xs);
font-weight: 600;
cursor: pointer;
transition: background var(--transition-fast), color var(--transition-fast);
flex: 1;
justify-content: center;
}
.theme-pill:hover {
color: var(--color-text-primary);
}
.theme-pill--active {
background: var(--color-accent-compress);
color: white;
}
.theme-pill--active:hover {
color: white;
}
.theme-pill > i {
font-size: 13px;
}
/* ── Zoom row ── */
.zoom-row {
display: flex;
align-items: center;
gap: 10px;
}
.zoom-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: var(--radius-sm);
background: var(--color-bg-surface);
border: 1px solid var(--color-border);
color: var(--color-text-secondary);
cursor: pointer;
flex-shrink: 0;
transition: background var(--transition-fast), color var(--transition-fast);
padding: 0;
}
.zoom-btn:hover {
background: var(--color-bg-elevated);
color: var(--color-text-primary);
}
.zoom-btn > i {
font-size: 12px;
}
.zoom-slider {
flex: 1;
min-width: 0;
}
.zoom-value {
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--color-text-secondary);
min-width: 36px;
text-align: right;
flex-shrink: 0;
}
/* ── Path bar ── */
.path-bar {
display: flex;
align-items: center;
gap: 8px;
background: var(--color-bg-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
overflow: hidden;
min-height: 38px;
}
.path-bar-icon {
font-size: 15px;
color: var(--color-text-disabled);
flex-shrink: 0;
margin-left: 12px;
}
.path-bar-text {
flex: 1;
padding: 8px 0;
font-family: var(--font-mono);
font-size: 11px;
color: var(--color-text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.path-bar-text--empty {
color: var(--color-text-disabled);
font-style: italic;
}
.path-bar-actions {
display: flex;
flex-shrink: 0;
}
.path-bar-btn {
display: flex;
align-items: center;
gap: 5px;
padding: 8px 14px;
background: none;
border: none;
border-left: 1px solid var(--color-border);
color: var(--color-text-secondary);
font-family: var(--font-body);
font-size: 11px;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
transition: background var(--transition-fast), color var(--transition-fast);
}
.path-bar-btn:hover {
background: var(--color-bg-elevated);
color: var(--color-text-primary);
}
.path-bar-btn--danger:hover {
color: var(--color-accent-error);
background: rgba(239, 68, 68, 0.06);
}
.path-bar-btn--spinning {
opacity: 0.6;
pointer-events: none;
}
.path-bar-btn > i {
font-size: 13px;
}
.version-pill {
display: flex;
align-items: center;
gap: 5px;
margin-top: 6px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--color-accent-compress);
}
.version-pill > i {
font-size: 13px;
}
/* ── Footer ── */
.settings-footer {
display: flex;
justify-content: center;
margin-top: 32px;
}
.reset-btn {
display: flex;
align-items: center;
gap: 7px;
padding: 10px 20px;
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
background: var(--color-bg-elevated);
color: var(--color-text-secondary);
font-family: var(--font-body);
font-size: var(--text-xs);
font-weight: 600;
cursor: pointer;
transition: background var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.reset-btn:hover {
background: var(--color-bg-surface);
color: var(--color-accent-error);
border-color: rgba(239, 68, 68, 0.3);
box-shadow: 0 0 12px rgba(239, 68, 68, 0.08);
}
.reset-btn > i {
font-size: 13px;
}
</style>