Files
RUWIO/src/components/PropertiesPanel.vue
T
thandClaude Opus 4.7 1d84c34c15 0.8.3: custom tooltips, editor-hide, pack script, settings reshuffle
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>
2026-04-21 23:57:35 +02:00

959 lines
27 KiB
Vue

<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import {
PhCaretDown,
PhCaretRight,
PhDiamond,
PhEye,
PhEyeSlash,
PhMaskHappy,
PhMinus,
PhPolygon,
PhPlus,
PhTrash,
PhX,
} from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import { sampleLayer } from '@/lib/interpolate'
import type { Action, AnimatableProps } from '@/types/scene'
import RangeSlider from './RangeSlider.vue'
import ActionEditor from './ActionEditor.vue'
import { t } from '@/i18n'
const SECTION_STORAGE_KEY = 'animatr:propSections'
type SectionKey = 'transform' | 'text' | 'polygon' | 'appearance' | 'shadow' | 'mask' | 'button'
const defaultOpen: Record<SectionKey, boolean> = {
transform: true,
text: true,
polygon: true,
appearance: true,
shadow: false,
mask: false,
button: true,
}
function readStoredOpen(): Record<SectionKey, boolean> {
try {
const raw = localStorage.getItem(SECTION_STORAGE_KEY)
if (!raw) return { ...defaultOpen }
const parsed = JSON.parse(raw) as Partial<Record<SectionKey, boolean>>
return { ...defaultOpen, ...parsed }
} catch {
return { ...defaultOpen }
}
}
const sectionOpen = ref<Record<SectionKey, boolean>>(readStoredOpen())
watch(
sectionOpen,
(val) => {
try {
localStorage.setItem(SECTION_STORAGE_KEY, JSON.stringify(val))
} catch {
/* ignore */
}
},
{ deep: true },
)
function toggleSection(key: SectionKey) {
sectionOpen.value[key] = !sectionOpen.value[key]
}
const formatPercent = (v: number) => `${Math.round(v * 100)}%`
const store = useSceneStore()
const selected = computed(() =>
store.activeLayers.find((l) => l.id === store.selectedLayerId) ?? null,
)
const sampled = computed<AnimatableProps | null>(() =>
selected.value ? sampleLayer(selected.value, store.playhead) : null,
)
const hasKeyframeHere = computed(() => {
if (!selected.value) return false
return selected.value.keyframes.some((k) => Math.abs(k.time - store.playhead) < 1e-3)
})
const selectedAsset = computed(() => {
if (!selected.value) return null
return store.scene.assets.find((a) => a.id === selected.value!.assetId) ?? null
})
const textAsset = computed(() => {
const a = selectedAsset.value
return a && a.kind === 'text' ? a : null
})
const polygonAsset = computed(() => {
const a = selectedAsset.value
return a && a.kind === 'polygon' ? a : null
})
const buttonAsset = computed(() => {
const a = selectedAsset.value
return a && a.kind === 'button' ? a : null
})
const polygonPointCount = computed<number>({
get: () => polygonAsset.value?.points.length ?? 0,
set: (v: number) => {
const a = polygonAsset.value
if (!a) return
store.setPolygonPointCount(a.id, v)
},
})
const polygonClosed = computed<boolean>({
get: () => polygonAsset.value?.closed !== false,
set: (v: boolean) => {
const a = polygonAsset.value
if (!a) return
store.setPolygonClosed(a.id, v)
},
})
const canStroke = computed(() => {
const k = selectedAsset.value?.kind
return k === 'polygon' || k === 'rect' || k === 'circle' || k === 'text'
})
// Remember the last non-transparent stroke color so we can restore it when
// the user flips "Transparent" back off. Updates whenever the sampled stroke
// has a real color, regardless of whether the user set it via picker, keyframe
// interpolation or layer selection.
const lastStrokeColor = ref<string>('#000000')
watch(
() => [sampled.value?.stroke, selectedAsset.value?.id] as const,
([s]) => {
if (s && s !== 'transparent') lastStrokeColor.value = s
},
{ immediate: true },
)
const strokeColor = computed<string>({
get: () => {
const s = sampled.value?.stroke
if (s && s !== 'transparent') return s
return lastStrokeColor.value
},
set: (v: string) => {
if (!selected.value) return
if (v !== 'transparent') lastStrokeColor.value = v
store.upsertKeyframe(selected.value.id, store.playhead, { stroke: v })
},
})
const strokeWidth = computed<number>({
get: () => {
if (sampled.value?.strokeWidth !== undefined) return sampled.value.strokeWidth
const a = selectedAsset.value
if (a && 'strokeWidth' in a && a.strokeWidth !== undefined) return a.strokeWidth
return 0
},
set: (v: number) => {
if (!selected.value) return
const patch: Partial<AnimatableProps> = { strokeWidth: v }
// Always co-write a stroke color alongside a non-zero width so the
// outline stays visible regardless of prior width=0 states.
if (v > 0) {
const a = selectedAsset.value
const assetStroke = a && 'stroke' in a ? a.stroke : undefined
patch.stroke = sampled.value?.stroke ?? assetStroke ?? '#000000'
}
store.upsertKeyframe(selected.value.id, store.playhead, patch)
},
})
const blurAsset = computed(() => {
const a = selectedAsset.value
return a && (a.kind === 'rect' || a.kind === 'circle' || a.kind === 'polygon') ? a : null
})
const canBeMask = computed(() => {
const a = selectedAsset.value
return !!a && (a.kind === 'rect' || a.kind === 'circle' || a.kind === 'text' || a.kind === 'polygon')
})
const maskInGroup = computed(() => {
if (!selected.value) return false
const id = selected.value.id
return (store.scene.groups ?? []).some((g) => g.childIds.includes(id))
})
const blurValue = computed<number>({
get: () => {
const a = blurAsset.value
if (!a) return 0
return sampled.value?.blur ?? a.blur ?? 0
},
set: (v: number) => {
if (!selected.value) return
store.upsertKeyframe(selected.value.id, store.playhead, { blur: v })
},
})
type ShadowNumKey = 'shadowBlur' | 'shadowOpacity' | 'shadowAngle' | 'shadowDistance'
interface ShadowAll {
shadowBlur: number
shadowOpacity: number
shadowAngle: number
shadowDistance: number
shadowColor: string
}
const SHADOW_DEFAULTS: ShadowAll = {
shadowBlur: 8,
shadowOpacity: 0,
shadowAngle: 90,
shadowDistance: 6,
shadowColor: '#000000',
}
function currentShadow(): ShadowAll {
const samp = sampled.value
return {
shadowBlur: samp?.shadowBlur ?? SHADOW_DEFAULTS.shadowBlur,
shadowOpacity: samp?.shadowOpacity ?? SHADOW_DEFAULTS.shadowOpacity,
shadowAngle: samp?.shadowAngle ?? SHADOW_DEFAULTS.shadowAngle,
shadowDistance: samp?.shadowDistance ?? SHADOW_DEFAULTS.shadowDistance,
shadowColor: samp?.shadowColor ?? SHADOW_DEFAULTS.shadowColor,
}
}
function writeShadow(patch: Partial<ShadowAll>) {
if (!selected.value) return
const next = { ...currentShadow(), ...patch }
store.upsertKeyframe(selected.value.id, store.playhead, next)
}
function shadowBinding(key: ShadowNumKey, decimals = 0) {
const factor = 10 ** decimals
return computed({
get: () => Math.round(currentShadow()[key] * factor) / factor,
set: (v: number) => writeShadow({ [key]: v } as Partial<ShadowAll>),
})
}
const shadowBlur = shadowBinding('shadowBlur')
const shadowOpacity = shadowBinding('shadowOpacity', 2)
const shadowAngle = shadowBinding('shadowAngle')
const shadowDistance = shadowBinding('shadowDistance')
const shadowColorValue = computed<string>({
get: () => currentShadow().shadowColor,
set: (v: string) => writeShadow({ shadowColor: v }),
})
const canFill = computed(() => {
const a = selectedAsset.value
return !!a && (a.kind === 'rect' || a.kind === 'circle' || a.kind === 'text' || a.kind === 'polygon')
})
const lastFillColor = ref<string>('#000000')
watch(
() => [sampled.value?.fill, selectedAsset.value?.id] as const,
([f]) => {
if (f && f !== 'transparent') lastFillColor.value = f
},
{ immediate: true },
)
const fillColor = computed<string>({
get: () => {
const samp = sampled.value?.fill
if (samp && samp !== 'transparent') return samp
return lastFillColor.value
},
set: (v: string) => {
if (!selected.value) return
if (v !== 'transparent') lastFillColor.value = v
store.upsertKeyframe(selected.value.id, store.playhead, { fill: v })
},
})
function resolveRestoreFill(): string {
// Prefer the cached last non-transparent color, but fall back to the layer
// base or asset fill so that the restore is visibly different from
// 'transparent' even if the user never picked a color via the picker.
if (lastFillColor.value && lastFillColor.value !== 'transparent') {
return lastFillColor.value
}
const layer = selected.value
const base = layer?.base?.fill
if (base && base !== 'transparent') return base
const a = selectedAsset.value
if (a && 'fill' in a && a.fill && a.fill !== 'transparent') return a.fill
return '#6ea8ff'
}
const fillTransparent = computed<boolean>({
get: () => {
const samp = sampled.value?.fill
if (samp !== undefined) return samp === 'transparent'
const a = selectedAsset.value
if (a && 'fill' in a) return a.fill === 'transparent'
return false
},
set: (v: boolean) => {
if (!selected.value) return
if (v) {
store.upsertKeyframe(selected.value.id, store.playhead, { fill: 'transparent' })
} else {
const color = resolveRestoreFill()
lastFillColor.value = color
store.upsertKeyframe(selected.value.id, store.playhead, { fill: color })
}
},
})
function resolveRestoreStroke(): string {
if (lastStrokeColor.value && lastStrokeColor.value !== 'transparent') {
return lastStrokeColor.value
}
const layer = selected.value
const base = layer?.base?.stroke
if (base && base !== 'transparent') return base
const a = selectedAsset.value
if (a && 'stroke' in a && a.stroke && a.stroke !== 'transparent') return a.stroke
return '#000000'
}
const strokeTransparent = computed<boolean>({
get: () => {
const s = sampled.value?.stroke
if (s !== undefined) return s === 'transparent'
const a = selectedAsset.value
if (a && 'stroke' in a) return a.stroke === 'transparent'
return false
},
set: (v: boolean) => {
if (!selected.value) return
if (v) {
store.upsertKeyframe(selected.value.id, store.playhead, { stroke: 'transparent' })
} else {
const color = resolveRestoreStroke()
lastStrokeColor.value = color
store.upsertKeyframe(selected.value.id, store.playhead, { stroke: color })
}
},
})
type NumericKey = 'x' | 'y' | 'scaleX' | 'scaleY' | 'rotation' | 'opacity'
function bind(key: NumericKey, decimals = 0) {
const factor = 10 ** decimals
return computed({
get: () => (sampled.value ? Math.round(sampled.value[key] * factor) / factor : 0),
set: (v: number) => {
if (!selected.value) return
store.upsertKeyframe(selected.value.id, store.playhead, { [key]: v })
},
})
}
const x = bind('x')
const y = bind('y')
const scaleX = bind('scaleX', 2)
const scaleY = bind('scaleY', 2)
const rotation = bind('rotation')
const opacity = bind('opacity', 2)
function removeKeyframeHere() {
if (!selected.value) return
store.removeKeyframeAt(selected.value.id, store.playhead)
}
</script>
<template>
<div class="panel">
<h3>Properties</h3>
<template v-if="selected && sampled">
<label>Name<input v-model="selected.name" /></label>
<div class="kf-row" :class="{ on: hasKeyframeHere }">
<PhDiamond :size="11" :weight="hasKeyframeHere ? 'fill' : 'regular'" class="kf-dot" />
<span class="kf-label">
{{ hasKeyframeHere ? t('properties.keyframeLabel') : t('properties.interpolated') }}
<span class="kf-time">@ {{ store.playhead.toFixed(2) }}s</span>
</span>
<button v-if="hasKeyframeHere" class="kf-btn" v-tooltip="t('properties.removeKeyframe')" @click="removeKeyframeHere">
<PhX :size="12" weight="bold" />
</button>
</div>
<section class="prop-group">
<button class="group-header" @click="toggleSection('transform')">
<component :is="sectionOpen.transform ? PhCaretDown : PhCaretRight" :size="11" />
<span>{{ t('properties.sections.transform') }}</span>
</button>
<div v-if="sectionOpen.transform" class="group-body">
<div class="row">
<label>X<input type="number" v-model.number="x" /></label>
<label>Y<input type="number" v-model.number="y" /></label>
</div>
<div class="row">
<label>Scale X<input type="number" step="0.1" v-model.number="scaleX" /></label>
<label>Scale Y<input type="number" step="0.1" v-model.number="scaleY" /></label>
</div>
<label>Rotation<input type="number" v-model.number="rotation" /></label>
</div>
</section>
<section v-if="textAsset" class="prop-group">
<button class="group-header" @click="toggleSection('text')">
<component :is="sectionOpen.text ? PhCaretDown : PhCaretRight" :size="11" />
<span>{{ t('properties.sections.text') }}</span>
</button>
<div v-if="sectionOpen.text" class="group-body">
<label>Text<input type="text" v-model="textAsset.text" /></label>
<label>Font Size<input type="number" min="1" v-model.number="textAsset.fontSize" /></label>
</div>
</section>
<section v-if="polygonAsset" class="prop-group">
<button class="group-header" @click="toggleSection('polygon')">
<component :is="sectionOpen.polygon ? PhCaretDown : PhCaretRight" :size="11" />
<span>{{ t('properties.sections.polygon') }}</span>
</button>
<div v-if="sectionOpen.polygon" class="group-body">
<div class="toggle-row">
<span class="toggle-label">{{ t('properties.polygonClosed') }}</span>
<button
type="button"
class="icon-toggle"
:class="{ active: polygonClosed }"
v-tooltip="t('properties.polygonClosed')"
@click="polygonClosed = !polygonClosed"
>
<PhPolygon :size="14" :weight="polygonClosed ? 'fill' : 'regular'" />
</button>
</div>
<label>
{{ t('properties.polygonPoints') }}
<input type="number" min="3" max="64" v-model.number="polygonPointCount" />
</label>
<p class="hint">{{ t('properties.polygonPointsHint') }}</p>
</div>
</section>
<section class="prop-group">
<button class="group-header" @click="toggleSection('appearance')">
<component :is="sectionOpen.appearance ? PhCaretDown : PhCaretRight" :size="11" />
<span>{{ t('properties.sections.appearance') }}</span>
</button>
<div v-if="sectionOpen.appearance" class="group-body">
<label v-if="canFill" class="color-label">
{{ t('properties.fill') }}
<div class="color-row">
<input
type="color"
class="color-swatch"
:value="fillColor"
:disabled="fillTransparent"
@input="fillColor = ($event.target as HTMLInputElement).value"
/>
<span class="color-hex">{{ fillTransparent ? '—' : fillColor.toUpperCase() }}</span>
<button
type="button"
class="icon-toggle alpha-toggle-btn"
:class="{ active: fillTransparent }"
v-tooltip="t('properties.transparent')"
@click="fillTransparent = !fillTransparent"
>
<component :is="fillTransparent ? PhEyeSlash : PhEye" :size="13" />
</button>
</div>
</label>
<label v-if="canStroke" class="color-label">
{{ t('properties.stroke') }}
<div class="color-row">
<input
type="color"
class="color-swatch"
:value="strokeColor"
:disabled="strokeTransparent"
@input="strokeColor = ($event.target as HTMLInputElement).value"
/>
<span class="color-hex">{{ strokeTransparent ? '—' : strokeColor.toUpperCase() }}</span>
<button
type="button"
class="icon-toggle alpha-toggle-btn"
:class="{ active: strokeTransparent }"
v-tooltip="t('properties.transparent')"
@click="strokeTransparent = !strokeTransparent"
>
<component :is="strokeTransparent ? PhEyeSlash : PhEye" :size="13" />
</button>
</div>
</label>
<label v-if="canStroke" class="slider-label">
{{ t('properties.strokeWidth') }}
<RangeSlider
v-model="strokeWidth"
:min="0"
:max="32"
:step="0.5"
:format="(v: number) => `${v}px`"
/>
</label>
<label class="slider-label">
{{ t('properties.opacity') }}
<RangeSlider v-model="opacity" :min="0" :max="1" :step="0.01" :format="formatPercent" />
</label>
<label v-if="blurAsset" class="slider-label">
{{ t('properties.edgeBlur') }}
<RangeSlider
v-model="blurValue"
:min="0"
:max="64"
:step="1"
:format="(v: number) => `${Math.round(v)}px`"
/>
</label>
</div>
</section>
<section class="prop-group">
<button class="group-header" @click="toggleSection('shadow')">
<component :is="sectionOpen.shadow ? PhCaretDown : PhCaretRight" :size="11" />
<span>{{ t('properties.shadow') }}</span>
</button>
<div v-if="sectionOpen.shadow" class="group-body">
<label class="color-label">
{{ t('properties.shadowColor') }}
<div class="color-row">
<input
type="color"
class="color-swatch"
:value="shadowColorValue"
@input="shadowColorValue = ($event.target as HTMLInputElement).value"
/>
<span class="color-hex">{{ shadowColorValue.toUpperCase() }}</span>
</div>
</label>
<label class="slider-label">
{{ t('properties.shadowOpacity') }}
<RangeSlider v-model="shadowOpacity" :min="0" :max="1" :step="0.01" :format="formatPercent" />
</label>
<label class="slider-label">
{{ t('properties.shadowBlur') }}
<RangeSlider
v-model="shadowBlur"
:min="0"
:max="64"
:step="1"
:format="(v: number) => `${Math.round(v)}px`"
/>
</label>
<label class="slider-label">
{{ t('properties.shadowDistance') }}
<RangeSlider
v-model="shadowDistance"
:min="0"
:max="100"
:step="1"
:format="(v: number) => `${Math.round(v)}px`"
/>
</label>
<label class="slider-label">
{{ t('properties.shadowAngle') }}
<RangeSlider
v-model="shadowAngle"
:min="0"
:max="360"
:step="1"
:format="(v: number) => `${Math.round(v)}°`"
/>
</label>
</div>
</section>
<section v-if="canBeMask" class="prop-group">
<button class="group-header" @click="toggleSection('mask')">
<component :is="sectionOpen.mask ? PhCaretDown : PhCaretRight" :size="11" />
<span>{{ t('properties.mask') }}</span>
</button>
<div v-if="sectionOpen.mask" class="group-body">
<div class="toggle-row">
<span class="toggle-label">{{ t('properties.maskUse') }}</span>
<button
type="button"
class="icon-toggle"
:class="{ active: !!selected.isMask }"
v-tooltip="t('properties.maskUse')"
@click="store.setLayerMask(selected.id, !selected.isMask)"
>
<PhMaskHappy :size="14" />
</button>
</div>
<div v-if="selected.isMask" class="toggle-row">
<span class="toggle-label">{{ t('properties.maskMode') }}</span>
<div class="segmented">
<button
type="button"
class="icon-toggle"
:class="{ active: (selected.maskMode ?? 'negative') === 'negative', danger: (selected.maskMode ?? 'negative') === 'negative' }"
v-tooltip="t('properties.maskNegative')"
@click="store.setLayerMaskMode(selected.id, 'negative')"
>
<PhMinus :size="13" weight="bold" />
</button>
<button
type="button"
class="icon-toggle"
:class="{ active: selected.maskMode === 'positive' }"
v-tooltip="t('properties.maskPositive')"
@click="store.setLayerMaskMode(selected.id, 'positive')"
>
<PhPlus :size="13" weight="bold" />
</button>
</div>
</div>
<p v-if="selected.isMask && maskInGroup" class="status-active">
{{ t('properties.maskActive') }}
</p>
<p v-if="selected.isMask && !maskInGroup" class="status-warn">
{{ t('properties.maskNeedsGroup') }}
</p>
<p v-if="selected.isMask" class="hint">{{ t('properties.maskHint') }}</p>
</div>
</section>
<section v-if="buttonAsset" class="prop-group">
<button class="group-header" @click="toggleSection('button')">
<component :is="sectionOpen.button ? PhCaretDown : PhCaretRight" :size="11" />
<span>{{ t('properties.sections.button') }}</span>
</button>
<div v-if="sectionOpen.button" class="group-body">
<ActionEditor
:actions="selected.onClick ?? []"
@update:actions="(v: Action[]) => store.setLayerOnClick(selected!.id, v)"
/>
</div>
</section>
<button class="danger-btn" @click="store.removeLayer(selected.id)">
<PhTrash :size="13" />
{{ t('properties.deleteLayer') }}
</button>
</template>
<p v-else class="hint">{{ t('properties.noLayer') }}</p>
</div>
</template>
<style scoped>
.panel {
padding: 14px;
display: flex;
flex-direction: column;
gap: 8px;
}
h3 {
margin: 0 0 2px;
font-size: 13px;
font-weight: 600;
}
label {
display: flex;
flex-direction: column;
gap: 3px;
font-size: 10px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.05em;
flex: 1 1 0;
min-width: 0;
}
input {
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;
}
.row {
display: flex;
gap: 8px;
}
.hint {
color: var(--text-dim);
font-size: 12px;
font-style: italic;
padding: 8px 0;
}
.prop-group {
display: flex;
flex-direction: column;
border-top: 1px solid var(--border);
margin: 0;
padding: 0;
}
.group-header {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 8px 4px;
background: transparent;
border: none;
color: var(--text-dim);
font-size: 10px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.08em;
cursor: pointer;
text-align: left;
}
.group-header:hover {
color: var(--text);
}
.group-header svg {
flex-shrink: 0;
}
.group-body {
display: flex;
flex-direction: column;
gap: 8px;
padding: 0 2px 10px;
}
.toggle-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.toggle-label {
font-size: 12px;
color: var(--text);
text-transform: none;
letter-spacing: 0;
flex: 1 1 auto;
}
.segmented {
display: inline-flex;
gap: 2px;
background: var(--panel-alt);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 2px;
}
.icon-toggle {
width: 26px;
height: 26px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--panel-alt);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-dim);
cursor: pointer;
transition: background 140ms var(--ease), border-color 140ms var(--ease),
color 140ms var(--ease);
flex-shrink: 0;
}
.icon-toggle:hover {
border-color: var(--border-strong);
color: var(--text);
}
.icon-toggle.active {
background: var(--accent-soft);
border-color: var(--accent);
color: var(--accent);
}
.icon-toggle.active.danger {
background: color-mix(in srgb, #ff6b6b 16%, transparent);
border-color: #ff6b6b;
color: #ff6b6b;
}
.segmented .icon-toggle {
border: none;
background: transparent;
}
.segmented .icon-toggle.active {
background: var(--accent-soft);
color: var(--accent);
}
.segmented .icon-toggle.active.danger {
background: color-mix(in srgb, #ff6b6b 16%, transparent);
color: #ff6b6b;
}
.status-active,
.status-warn {
margin: 0;
font-size: 11px;
padding: 4px 8px;
border-radius: var(--radius-sm);
}
.status-active {
color: #6ea8ff;
background: color-mix(in srgb, #6ea8ff 14%, transparent);
}
.status-warn {
color: #ffb84d;
background: color-mix(in srgb, #ffb84d 16%, transparent);
}
.kf-row {
display: flex;
align-items: center;
gap: 8px;
padding: 0 10px;
height: 30px;
background: var(--panel-alt);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-size: 11px;
color: var(--text-dim);
transition: background 140ms var(--ease), border-color 140ms var(--ease);
}
.kf-row.on {
background: var(--accent-soft);
border-color: var(--accent);
color: var(--accent);
}
.kf-dot {
color: inherit;
flex-shrink: 0;
}
.kf-label {
flex: 1;
display: inline-flex;
gap: 4px;
align-items: baseline;
font-weight: 500;
}
.kf-time {
font-variant-numeric: tabular-nums;
color: var(--text-dim);
font-weight: 400;
}
.kf-row.on .kf-time {
color: var(--accent);
opacity: 0.8;
}
.kf-btn {
width: 20px;
height: 20px;
padding: 0;
justify-content: center;
background: transparent;
border: none;
color: inherit;
flex-shrink: 0;
}
.kf-btn:hover:not(:disabled) {
background: rgba(0, 0, 0, 0.2);
}
.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;
transition: border-color 140ms var(--ease);
}
.color-swatch:hover {
border-color: var(--border-strong);
}
.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;
}
.color-hex {
font-size: 12px;
font-variant-numeric: tabular-nums;
color: var(--text-dim-strong);
letter-spacing: 0.03em;
}
.alpha-toggle-btn {
margin-left: auto;
}
.color-swatch:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.danger-btn {
justify-content: center;
margin-top: 6px;
background: var(--danger-soft);
border-color: transparent;
color: var(--danger);
}
.danger-btn:hover:not(:disabled) {
background: rgba(255, 122, 122, 0.18);
border-color: var(--danger);
}
</style>