Tooltips - New v-tooltip directive + TooltipHost floating panel replace the native title attribute across 95+ sites (topbar, timeline, sidebar, properties, stage overlays, fullscreen preview, export dialog, clipart drawer, modals). Styled dark card with a configurable label + optional help line for inline documentation; screen readers still get an aria-label when the panel is disabled. - Settings → Interface toggle (`ruwio:tooltipsEnabled`) hides the visual panel for users who know the app. IconButton now routes its title prop through v-tooltip so every existing call site is tooltip-aware without churn. Editor-hide + export filter - New `Layer.editorHidden` flag, eye toggle per layer row, skipped by the Konva stage and the mask-preview canvas path but honoured by exports and fullscreen preview (runtime untouched). - Export dialog gets a "Skip hidden tracks" switch that strips editor-hidden layers from every format (scene JS, runtime, example HTML, MP4, WebM) on opt-in. Disabled when the scene has no hidden layers. Button editor - `+` buttons appear on empty state slots in button-timeline rows and duplicate the nearest keyframe (prev by preference, next otherwise) onto that slot. New `duplicateKeyframeAt` store action carries props, easing, hold, and bezier handles across. Stage / interaction - Plain left-click on the empty content view pans the viewport, but only when the pointer isn't over a Konva shape — transformer handles, vertex dots, and selection boxes that extend past the canvas edge keep their drag. Settings reshuffle - Timeline tab merged into Document (Duration + FPS sit under a new "Timing" sub-heading). New Interface tab gathers three global prefs: Show tooltips, Show locked tracks, Asset thumbnails — driven by shared composables so the existing toolbar toggles stay in sync. Interface-tab CSS defensively resets the scoped label flex-column + uppercase rule that was making the toggle row look like an accident. Project identity + UX polish - Favicon wired up (moved to public/favicon.ico, linked from index.html); scripts/pack.sh packs the minimum Vue project (src, index.html, configs, public) into a tarball with the right version in the filename, and `npm run pack` drives it. - About dialog now credits Torsten Hoeltge. Test infra - Progress bar turns red mid-run on the first failure: runAll gets an onResult callback that TestOverlay uses to push each result into the reactive array incrementally. - mountComponent in the test harness registers v-tooltip on the isolated app instance so IconButton's aria-label contract actually fires under test. Regression tests - duplicateKeyframeAt: copy fidelity (props, easing, hold), collision refusal, unknown-id guards, separated-track targeting. - Shared composables: useTooltipPref and useAssetThumbsPref both return the same ref across calls and persist to localStorage. - Tooltip contract: IconButton ships `aria-label` (not title); live-DOM smoke tests match on aria-label selectors now. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
879 lines
23 KiB
Vue
879 lines
23 KiB
Vue
<script setup lang="ts">
|
||
import { computed, ref, watch } from 'vue'
|
||
import {
|
||
PhFilmReel,
|
||
PhFolder,
|
||
PhFrameCorners,
|
||
PhMoon,
|
||
PhPalette,
|
||
PhSlidersHorizontal,
|
||
PhSun,
|
||
PhX,
|
||
} from '@phosphor-icons/vue'
|
||
import { useSceneStore } from '@/stores/scene'
|
||
import { useClipartStore } from '@/stores/clipart'
|
||
import { useTheme } from '@/composables/useTheme'
|
||
import type { Theme } from '@/composables/useTheme'
|
||
import { useTooltipPref } from '@/composables/useTooltipPref'
|
||
import { useAssetThumbsPref } from '@/composables/useAssetThumbsPref'
|
||
import { showLocked } from '@/composables/useShowLocked'
|
||
import { Modal, confirmDialog, promptDialog } from '@/components/ui'
|
||
import { t } from '@/i18n'
|
||
|
||
const props = defineProps<{ modelValue: boolean }>()
|
||
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
|
||
|
||
const store = useSceneStore()
|
||
const clipart = useClipartStore()
|
||
const { theme, setTheme } = useTheme()
|
||
const { enabled: tooltipsEnabled } = useTooltipPref()
|
||
const { enabled: thumbsEnabled } = useAssetThumbsPref()
|
||
|
||
// Re-check the stored clipart folder whenever settings opens — covers the
|
||
// case where initial App.vue init raced ahead of IDB or the user opens
|
||
// settings before the drawer ever mounted.
|
||
watch(
|
||
() => props.modelValue,
|
||
(open) => {
|
||
if (open) void clipart.initialize()
|
||
},
|
||
)
|
||
|
||
type TabId = 'document' | 'clipart' | 'interface' | 'theme'
|
||
|
||
const tabs: { id: TabId; label: string; icon: typeof PhFrameCorners }[] = [
|
||
{ id: 'document', label: t('settings.tab.document'), icon: PhFrameCorners },
|
||
{ id: 'clipart', label: t('settings.tab.clipart'), icon: PhFolder },
|
||
{ id: 'interface', label: t('settings.tab.interface'), icon: PhSlidersHorizontal },
|
||
{ id: 'theme', label: t('settings.tab.theme'), icon: PhPalette },
|
||
]
|
||
|
||
const activeTab = ref<TabId>('document')
|
||
|
||
const bgVideoError = ref<string | null>(null)
|
||
const bgFileInput = ref<HTMLInputElement | null>(null)
|
||
|
||
interface ProbedVideo {
|
||
src: string
|
||
width: number
|
||
height: number
|
||
duration: number
|
||
name: string
|
||
}
|
||
|
||
function probeVideo(src: string, name: string): Promise<ProbedVideo> {
|
||
return new Promise((resolve, reject) => {
|
||
const v = document.createElement('video')
|
||
v.preload = 'metadata'
|
||
v.muted = true
|
||
v.src = src
|
||
v.onloadedmetadata = () => {
|
||
resolve({
|
||
src,
|
||
width: v.videoWidth || 1280,
|
||
height: v.videoHeight || 720,
|
||
duration: Number.isFinite(v.duration) ? v.duration : 0,
|
||
name,
|
||
})
|
||
}
|
||
v.onerror = () => reject(new Error('Could not read video metadata.'))
|
||
})
|
||
}
|
||
|
||
async function applyProbedVideo(probe: ProbedVideo) {
|
||
const stageMatches =
|
||
probe.width === store.scene.canvas.width && probe.height === store.scene.canvas.height
|
||
if (!stageMatches) {
|
||
const ok = await confirmDialog({
|
||
title: t('settings.bgVideoStageSizeTitle'),
|
||
message: t('settings.bgVideoStageSizeMessage', {
|
||
w: probe.width,
|
||
h: probe.height,
|
||
cw: store.scene.canvas.width,
|
||
ch: store.scene.canvas.height,
|
||
}),
|
||
confirmLabel: t('settings.bgVideoStageSizeConfirm'),
|
||
cancelLabel: t('settings.bgVideoStageSizeCancel'),
|
||
})
|
||
if (ok) store.setCanvasSize(probe.width, probe.height)
|
||
}
|
||
if (probe.duration > 0 && probe.duration > store.scene.duration) {
|
||
const ok = await confirmDialog({
|
||
title: t('settings.bgVideoDurationTitle'),
|
||
message: t('settings.bgVideoDurationMessage', {
|
||
d: probe.duration.toFixed(2),
|
||
cd: store.scene.duration,
|
||
}),
|
||
confirmLabel: t('settings.bgVideoDurationConfirm'),
|
||
cancelLabel: t('settings.bgVideoDurationCancel'),
|
||
})
|
||
if (ok) store.setDuration(probe.duration)
|
||
}
|
||
store.setBackgroundVideo({ src: probe.src, muted: true })
|
||
}
|
||
|
||
async function pickBgVideoFile() {
|
||
bgVideoError.value = null
|
||
bgFileInput.value?.click()
|
||
}
|
||
|
||
async function onBgFileSelected(e: Event) {
|
||
const input = e.target as HTMLInputElement
|
||
const file = input.files?.[0]
|
||
input.value = ''
|
||
if (!file) return
|
||
try {
|
||
const reader = new FileReader()
|
||
const dataUrl = await new Promise<string>((resolve, reject) => {
|
||
reader.onload = () => resolve(reader.result as string)
|
||
reader.onerror = () => reject(reader.error)
|
||
reader.readAsDataURL(file)
|
||
})
|
||
const probe = await probeVideo(dataUrl, file.name)
|
||
await applyProbedVideo(probe)
|
||
} catch (err) {
|
||
bgVideoError.value = err instanceof Error ? err.message : String(err)
|
||
}
|
||
}
|
||
|
||
async function pickBgVideoUrl() {
|
||
bgVideoError.value = null
|
||
const url = await promptDialog({
|
||
title: t('settings.bgVideoUrlTitle'),
|
||
message: t('settings.bgVideoUrlMessage'),
|
||
defaultValue: store.scene.backgroundVideo?.src ?? '',
|
||
confirmLabel: t('settings.bgVideoUrlConfirm'),
|
||
})
|
||
if (!url) return
|
||
try {
|
||
const probe = await probeVideo(url.trim(), url.trim())
|
||
await applyProbedVideo(probe)
|
||
} catch (err) {
|
||
bgVideoError.value = err instanceof Error ? err.message : String(err)
|
||
}
|
||
}
|
||
|
||
function clearBgVideo() {
|
||
store.setBackgroundVideo(null)
|
||
bgVideoError.value = null
|
||
}
|
||
|
||
function bgVideoDisplayName(): string {
|
||
const v = store.scene.backgroundVideo
|
||
if (!v) return ''
|
||
if (v.src.startsWith('data:')) return t('settings.bgVideoLocalFile')
|
||
try {
|
||
const u = new URL(v.src)
|
||
return u.pathname.split('/').filter(Boolean).pop() || u.hostname
|
||
} catch {
|
||
return v.src.length > 48 ? v.src.slice(0, 48) + '…' : v.src
|
||
}
|
||
}
|
||
|
||
const width = computed({
|
||
get: () => store.scene.canvas.width,
|
||
set: (v: number) => store.setCanvasSize(v, store.scene.canvas.height),
|
||
})
|
||
|
||
const height = computed({
|
||
get: () => store.scene.canvas.height,
|
||
set: (v: number) => store.setCanvasSize(store.scene.canvas.width, v),
|
||
})
|
||
|
||
const background = computed({
|
||
get: () => store.scene.canvas.background,
|
||
set: (v: string) => store.setCanvasBackground(v),
|
||
})
|
||
|
||
const duration = computed({
|
||
get: () => store.scene.duration,
|
||
set: (v: number) => store.setDuration(v),
|
||
})
|
||
|
||
const fps = computed({
|
||
get: () => store.scene.fps,
|
||
set: (v: number) => store.setFps(v),
|
||
})
|
||
|
||
const presets: { label: string; w: number; h: number }[] = [
|
||
{ label: '16:9 · 1280×720', w: 1280, h: 720 },
|
||
{ label: '16:9 · 1920×1080', w: 1920, h: 1080 },
|
||
{ label: '1:1 · 1080×1080', w: 1080, h: 1080 },
|
||
{ label: '9:16 · 1080×1920', w: 1080, h: 1920 },
|
||
{ label: '4:5 · 1080×1350', w: 1080, h: 1350 },
|
||
]
|
||
|
||
function applyPreset(p: { w: number; h: number }) {
|
||
store.setCanvasSize(p.w, p.h)
|
||
}
|
||
|
||
function close() {
|
||
emit('update:modelValue', false)
|
||
}
|
||
|
||
function applyTheme(t: Theme) {
|
||
setTheme(t)
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<Modal
|
||
:model-value="modelValue"
|
||
:title="t('settings.title')"
|
||
size="lg"
|
||
@update:model-value="emit('update:modelValue', $event)"
|
||
>
|
||
<div class="body">
|
||
<nav class="tabs">
|
||
<button
|
||
v-for="tab in tabs"
|
||
:key="tab.id"
|
||
class="tab"
|
||
:class="{ active: activeTab === tab.id }"
|
||
@click="activeTab = tab.id"
|
||
>
|
||
<component :is="tab.icon" :size="14" />
|
||
{{ tab.label }}
|
||
</button>
|
||
</nav>
|
||
|
||
<div class="content">
|
||
<section v-if="activeTab === 'document'">
|
||
<h3>{{ t('settings.canvas') }}</h3>
|
||
<div class="row">
|
||
<label>
|
||
{{ t('settings.width') }}
|
||
<input type="number" min="1" step="1" v-model.number="width" />
|
||
</label>
|
||
<label>
|
||
{{ t('settings.height') }}
|
||
<input type="number" min="1" step="1" v-model.number="height" />
|
||
</label>
|
||
</div>
|
||
<div class="presets">
|
||
<button
|
||
v-for="p in presets"
|
||
:key="p.label"
|
||
class="preset"
|
||
:class="{ active: width === p.w && height === p.h }"
|
||
@click="applyPreset(p)"
|
||
>
|
||
{{ p.label }}
|
||
</button>
|
||
</div>
|
||
<label class="stacked">
|
||
{{ t('settings.background') }}
|
||
<div class="color-row">
|
||
<input
|
||
type="color"
|
||
class="color-swatch"
|
||
:value="background"
|
||
@input="background = ($event.target as HTMLInputElement).value"
|
||
/>
|
||
<span class="hex">{{ background.toUpperCase() }}</span>
|
||
</div>
|
||
</label>
|
||
|
||
<div class="clipart-row">
|
||
<div class="clipart-label">{{ t('settings.bgVideoLabel') }}</div>
|
||
<div class="clipart-value">
|
||
<PhFilmReel :size="13" />
|
||
<span v-if="store.scene.backgroundVideo" class="clipart-folder-name">
|
||
{{ bgVideoDisplayName() }}
|
||
</span>
|
||
<span v-else class="clipart-folder-empty">{{ t('settings.bgVideoNone') }}</span>
|
||
<button
|
||
v-if="store.scene.backgroundVideo"
|
||
class="ghost-btn inline-clear"
|
||
v-tooltip="t('settings.bgVideoClear')"
|
||
@click="clearBgVideo"
|
||
>
|
||
<PhX :size="12" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="clipart-actions">
|
||
<button class="primary-btn" @click="pickBgVideoFile">
|
||
{{ t('settings.bgVideoPickFile') }}
|
||
</button>
|
||
<button class="ghost-btn" @click="pickBgVideoUrl">
|
||
{{ t('settings.bgVideoPickUrl') }}
|
||
</button>
|
||
</div>
|
||
<input
|
||
ref="bgFileInput"
|
||
type="file"
|
||
accept="video/*"
|
||
style="display: none"
|
||
@change="onBgFileSelected"
|
||
/>
|
||
<p v-if="bgVideoError" class="error-line">{{ bgVideoError }}</p>
|
||
<p class="hint">{{ t('settings.bgVideoHint') }}</p>
|
||
|
||
<h3 class="sub-heading">{{ t('settings.timing') }}</h3>
|
||
<div class="row">
|
||
<label>
|
||
{{ t('settings.duration') }}
|
||
<input type="number" min="0.1" step="0.1" v-model.number="duration" />
|
||
</label>
|
||
<label>
|
||
{{ t('settings.fps') }}
|
||
<input type="number" min="1" step="1" v-model.number="fps" />
|
||
</label>
|
||
</div>
|
||
<p class="hint">{{ t('settings.timelineHint') }}</p>
|
||
</section>
|
||
|
||
|
||
<section v-else-if="activeTab === 'clipart'">
|
||
<h3>{{ t('settings.tab.clipart') }}</h3>
|
||
<div class="clipart-row">
|
||
<div class="clipart-label">{{ t('clipart.folderLabel') }}</div>
|
||
<div class="clipart-value">
|
||
<PhFolder :size="13" />
|
||
<span v-if="clipart.folderName" class="clipart-folder-name">
|
||
{{ clipart.folderName }}
|
||
</span>
|
||
<span v-else class="clipart-folder-empty">{{ t('clipart.noFolder') }}</span>
|
||
</div>
|
||
</div>
|
||
<div class="clipart-actions">
|
||
<button
|
||
class="primary-btn"
|
||
:disabled="!clipart.supported"
|
||
@click="clipart.pickFolder()"
|
||
>
|
||
{{ t('clipart.chooseFolder') }}
|
||
</button>
|
||
<button
|
||
v-if="clipart.handle"
|
||
class="ghost-btn"
|
||
@click="clipart.clearFolder()"
|
||
>
|
||
{{ t('clipart.clearFolder') }}
|
||
</button>
|
||
</div>
|
||
<p v-if="!clipart.supported" class="error-line">
|
||
{{ t('clipart.unsupported') }}
|
||
</p>
|
||
<p v-if="clipart.error" class="error-line">{{ clipart.error }}</p>
|
||
<p class="hint">{{ t('clipart.settingsHint') }}</p>
|
||
</section>
|
||
|
||
<section v-else-if="activeTab === 'interface'">
|
||
<h3>{{ t('settings.tab.interface') }}</h3>
|
||
<label class="row-toggle">
|
||
<span class="switch" :class="{ on: tooltipsEnabled }">
|
||
<input type="checkbox" v-model="tooltipsEnabled" />
|
||
</span>
|
||
<div class="row-toggle-text">
|
||
<div class="row-toggle-label">{{ t('settings.tooltipsLabel') }}</div>
|
||
<div class="row-toggle-desc">{{ t('settings.tooltipsDesc') }}</div>
|
||
</div>
|
||
</label>
|
||
|
||
<label class="row-toggle">
|
||
<span class="switch" :class="{ on: showLocked }">
|
||
<input type="checkbox" v-model="showLocked" />
|
||
</span>
|
||
<div class="row-toggle-text">
|
||
<div class="row-toggle-label">{{ t('settings.showLockedLabel') }}</div>
|
||
<div class="row-toggle-desc">{{ t('settings.showLockedDesc') }}</div>
|
||
</div>
|
||
</label>
|
||
|
||
<label class="row-toggle">
|
||
<span class="switch" :class="{ on: thumbsEnabled }">
|
||
<input type="checkbox" v-model="thumbsEnabled" />
|
||
</span>
|
||
<div class="row-toggle-text">
|
||
<div class="row-toggle-label">{{ t('settings.assetThumbsLabel') }}</div>
|
||
<div class="row-toggle-desc">{{ t('settings.assetThumbsDesc') }}</div>
|
||
</div>
|
||
</label>
|
||
</section>
|
||
|
||
<section v-else-if="activeTab === 'theme'">
|
||
<h3>{{ t('settings.tab.theme') }}</h3>
|
||
<div class="theme-grid">
|
||
<button
|
||
class="theme-card"
|
||
:class="{ active: theme === 'dark' }"
|
||
@click="applyTheme('dark')"
|
||
>
|
||
<div class="theme-preview theme-preview-dark">
|
||
<div class="tp-panel"></div>
|
||
<div class="tp-accent"></div>
|
||
</div>
|
||
<div class="theme-label">
|
||
<PhMoon :size="14" />
|
||
<span>{{ t('settings.themeDark') }}</span>
|
||
</div>
|
||
</button>
|
||
<button
|
||
class="theme-card"
|
||
:class="{ active: theme === 'light' }"
|
||
@click="applyTheme('light')"
|
||
>
|
||
<div class="theme-preview theme-preview-light">
|
||
<div class="tp-panel"></div>
|
||
<div class="tp-accent"></div>
|
||
</div>
|
||
<div class="theme-label">
|
||
<PhSun :size="14" />
|
||
<span>{{ t('settings.themeLight') }}</span>
|
||
</div>
|
||
</button>
|
||
</div>
|
||
<p class="hint">{{ t('settings.themeHint') }}</p>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
<template #footer>
|
||
<button class="primary" @click="close">{{ t('settings.done') }}</button>
|
||
</template>
|
||
</Modal>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.body {
|
||
display: flex;
|
||
min-height: 340px;
|
||
}
|
||
|
||
.tabs {
|
||
width: 150px;
|
||
flex-shrink: 0;
|
||
border-right: 1px solid var(--border);
|
||
padding: 10px 8px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
background: var(--panel-alt);
|
||
}
|
||
|
||
.tab {
|
||
justify-content: flex-start;
|
||
padding: 8px 10px;
|
||
background: transparent;
|
||
border: 1px solid transparent;
|
||
color: var(--text-dim);
|
||
font-size: 12px;
|
||
border-radius: var(--radius-sm);
|
||
}
|
||
|
||
.tab:hover:not(:disabled) {
|
||
background: var(--panel-hover);
|
||
color: var(--text);
|
||
}
|
||
|
||
.tab.active {
|
||
background: var(--accent-soft);
|
||
color: var(--accent);
|
||
border-color: var(--accent);
|
||
}
|
||
|
||
.content {
|
||
flex: 1;
|
||
padding: 16px 20px;
|
||
overflow: auto;
|
||
min-width: 0;
|
||
}
|
||
|
||
section {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
}
|
||
|
||
section h3 {
|
||
margin: 0;
|
||
font-size: 13px;
|
||
text-transform: uppercase;
|
||
color: var(--text-dim-strong);
|
||
letter-spacing: 0.08em;
|
||
font-weight: 600;
|
||
}
|
||
|
||
/* A second h3 inside the same section (e.g. "Timing" under the
|
||
Document tab) needs breathing room so it reads as its own subgroup
|
||
rather than colliding with the preceding hint paragraph. */
|
||
section h3.sub-heading {
|
||
margin-top: 12px;
|
||
}
|
||
|
||
.row {
|
||
display: flex;
|
||
gap: 10px;
|
||
}
|
||
|
||
label {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
font-size: 10px;
|
||
color: var(--text-dim);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.05em;
|
||
flex: 1 1 0;
|
||
min-width: 0;
|
||
}
|
||
|
||
label.stacked {
|
||
flex: 0 0 auto;
|
||
}
|
||
|
||
input[type='number'] {
|
||
width: 100%;
|
||
background: var(--bg);
|
||
border: 1px solid var(--border);
|
||
color: var(--text);
|
||
padding: 6px 8px;
|
||
border-radius: var(--radius-sm);
|
||
font: inherit;
|
||
font-size: 12px;
|
||
text-transform: none;
|
||
letter-spacing: 0;
|
||
}
|
||
|
||
.presets {
|
||
display: flex;
|
||
gap: 6px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.preset {
|
||
padding: 4px 10px;
|
||
font-size: 11px;
|
||
background: var(--panel-alt);
|
||
border: 1px solid var(--border);
|
||
color: var(--text-dim);
|
||
border-radius: 999px;
|
||
}
|
||
|
||
.preset:hover:not(:disabled) {
|
||
background: var(--panel-hover);
|
||
color: var(--text);
|
||
}
|
||
|
||
.preset.active {
|
||
background: var(--accent-soft);
|
||
border-color: var(--accent);
|
||
color: var(--accent);
|
||
}
|
||
|
||
.color-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 4px 0;
|
||
}
|
||
|
||
.color-swatch {
|
||
width: 36px;
|
||
height: 24px;
|
||
padding: 0;
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm);
|
||
background: transparent;
|
||
cursor: pointer;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.color-swatch::-webkit-color-swatch-wrapper {
|
||
padding: 0;
|
||
}
|
||
.color-swatch::-webkit-color-swatch {
|
||
border: none;
|
||
border-radius: 3px;
|
||
}
|
||
.color-swatch::-moz-color-swatch {
|
||
border: none;
|
||
border-radius: 3px;
|
||
}
|
||
|
||
.hex {
|
||
font-size: 12px;
|
||
font-variant-numeric: tabular-nums;
|
||
color: var(--text-dim-strong);
|
||
}
|
||
|
||
.hint {
|
||
font-size: 13px;
|
||
line-height: 1.45;
|
||
color: var(--text-dim-strong);
|
||
margin: 0;
|
||
}
|
||
|
||
/* Switch + two-line label. Defensive resets defeat the scoped
|
||
blanket `label { flex-direction: column; text-transform: uppercase;
|
||
font-size: 10px; flex: 1 1 0; }` that form-row labels in this file
|
||
depend on — without the reset that rule would stack the switch on
|
||
top of the text and uppercase everything inside. */
|
||
.row-toggle {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 10px 12px;
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
background: var(--panel-alt);
|
||
cursor: pointer;
|
||
user-select: none;
|
||
-webkit-user-select: none;
|
||
font-size: 13px;
|
||
text-transform: none;
|
||
letter-spacing: normal;
|
||
color: var(--text);
|
||
flex: 0 0 auto;
|
||
}
|
||
|
||
.row-toggle-text {
|
||
flex: 1 1 auto;
|
||
min-width: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 3px;
|
||
text-transform: none;
|
||
}
|
||
|
||
.row-toggle-label {
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
color: var(--text);
|
||
letter-spacing: normal;
|
||
text-transform: none;
|
||
}
|
||
|
||
.row-toggle-desc {
|
||
font-size: 12px;
|
||
color: var(--text-dim);
|
||
line-height: 1.4;
|
||
letter-spacing: normal;
|
||
text-transform: none;
|
||
}
|
||
|
||
.row-toggle .switch {
|
||
position: relative;
|
||
width: 32px;
|
||
height: 18px;
|
||
flex-shrink: 0;
|
||
background: var(--border);
|
||
border-radius: 999px;
|
||
transition: background 140ms var(--ease);
|
||
}
|
||
|
||
.row-toggle .switch.on {
|
||
background: var(--accent);
|
||
}
|
||
|
||
.row-toggle .switch input {
|
||
position: absolute;
|
||
inset: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
margin: 0;
|
||
opacity: 0;
|
||
cursor: inherit;
|
||
}
|
||
|
||
.row-toggle .switch::after {
|
||
content: '';
|
||
position: absolute;
|
||
top: 2px;
|
||
left: 2px;
|
||
width: 14px;
|
||
height: 14px;
|
||
background: #fff;
|
||
border-radius: 50%;
|
||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||
transition: transform 140ms var(--ease);
|
||
}
|
||
|
||
.row-toggle .switch.on::after {
|
||
transform: translateX(14px);
|
||
}
|
||
|
||
.clipart-row {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.clipart-label {
|
||
font-size: 10px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.05em;
|
||
color: var(--text-dim);
|
||
}
|
||
|
||
.clipart-value {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 8px 10px;
|
||
background: var(--panel-alt);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm);
|
||
color: var(--text);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.clipart-folder-name {
|
||
font-variant-numeric: tabular-nums;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.clipart-folder-empty {
|
||
color: var(--text-dim);
|
||
font-style: italic;
|
||
}
|
||
|
||
.clipart-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.primary-btn {
|
||
padding: 6px 12px;
|
||
border-radius: var(--radius-sm);
|
||
background: var(--accent);
|
||
color: var(--accent-text, #fff);
|
||
border: 1px solid transparent;
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.primary-btn:hover:not(:disabled) {
|
||
filter: brightness(1.1);
|
||
}
|
||
|
||
.primary-btn:disabled {
|
||
opacity: 0.5;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.ghost-btn {
|
||
padding: 6px 12px;
|
||
border-radius: var(--radius-sm);
|
||
background: transparent;
|
||
color: var(--text);
|
||
border: 1px solid var(--border);
|
||
font-size: 12px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.ghost-btn:hover {
|
||
background: var(--panel-alt);
|
||
}
|
||
|
||
.error-line {
|
||
margin: 0 0 8px;
|
||
color: var(--danger);
|
||
font-size: 11px;
|
||
}
|
||
|
||
.theme-grid {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 10px;
|
||
}
|
||
|
||
.theme-card {
|
||
flex-direction: column;
|
||
padding: 12px;
|
||
gap: 10px;
|
||
background: var(--panel-alt);
|
||
border: 2px solid var(--border);
|
||
border-radius: var(--radius);
|
||
align-items: stretch;
|
||
}
|
||
|
||
.theme-card:hover:not(:disabled) {
|
||
background: var(--panel-hover);
|
||
border-color: var(--border-strong);
|
||
}
|
||
|
||
.theme-card.active {
|
||
border-color: var(--accent);
|
||
background: var(--accent-soft);
|
||
}
|
||
|
||
.theme-preview {
|
||
height: 74px;
|
||
border-radius: var(--radius-sm);
|
||
display: flex;
|
||
align-items: stretch;
|
||
gap: 6px;
|
||
padding: 8px;
|
||
}
|
||
|
||
.theme-preview-dark {
|
||
background: #17171b;
|
||
border: 1px solid #2e2e37;
|
||
}
|
||
|
||
.theme-preview-dark .tp-panel {
|
||
background: #272730;
|
||
flex: 1;
|
||
border-radius: 3px;
|
||
}
|
||
|
||
.theme-preview-dark .tp-accent {
|
||
background: #6ea8ff;
|
||
width: 22px;
|
||
border-radius: 3px;
|
||
}
|
||
|
||
.theme-preview-light {
|
||
background: #f3f3f6;
|
||
border: 1px solid #e2e2e7;
|
||
}
|
||
|
||
.theme-preview-light .tp-panel {
|
||
background: #ffffff;
|
||
flex: 1;
|
||
border-radius: 3px;
|
||
border: 1px solid #e2e2e7;
|
||
}
|
||
|
||
.theme-preview-light .tp-accent {
|
||
background: #2b6af5;
|
||
width: 22px;
|
||
border-radius: 3px;
|
||
}
|
||
|
||
.theme-label {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-size: 12px;
|
||
color: var(--text);
|
||
font-weight: 500;
|
||
justify-content: center;
|
||
}
|
||
|
||
.theme-card.active .theme-label {
|
||
color: var(--accent);
|
||
}
|
||
|
||
.primary {
|
||
padding: 6px 14px;
|
||
background: var(--accent-soft);
|
||
border: 1px solid var(--accent);
|
||
color: var(--accent);
|
||
border-radius: var(--radius-sm);
|
||
font-weight: 500;
|
||
}
|
||
|
||
.primary:hover:not(:disabled) {
|
||
background: var(--accent-soft-strong);
|
||
}
|
||
</style>
|