Files
RUWIO/src/components/ExportDialog.vue
T
thandClaude Opus 4.7 92e9f1619f 1.1.0: .ruw project bundle + per-export target FPS
- New .ruw file format: a STORE-only zip wrapping project.json +
  an assets/ folder. packProject extracts every data-URL asset
  (images, audio, background video) into its own file and
  rewrites the in-JSON src fields to relative paths;
  unpackProject rehydrates back to data URLs on read. Zero
  dependencies — we ship a minimal in-house zip codec.
- ExportDialog surfaces the new format as a top-row
  "RUWIO project (.ruw)" option that round-trips through the
  welcome-dialog file picker. Legacy .json imports still work
  via byte-sniffing the PK header in readAnyProjectFile. Dead
  App.vue `exportProjectFile` helper removed.
- Frame-based exports (GIF, APNG, APNG preview) take an optional
  targetFps override so a 60fps project doesn't balloon the file
  — APNG / GIF size scales ~linearly with frame count. The
  ExportDialog grows a second input next to target size;
  defaults inherit the scene's fps and reset on each dialog
  open.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 20:50:21 +02:00

948 lines
24 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import {
PhCode,
PhDownloadSimple,
PhEngine,
PhFileArchive,
PhFileHtml,
PhFileImage,
PhFilmSlate,
PhGif,
} from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import { downloadExampleHtml, downloadRuntime, downloadSceneExport } from '@/lib/exportPlayer'
import { downloadSceneAsVideo, pickMimeType } from '@/lib/videoExport'
import { downloadSceneAsGif } from '@/lib/gifExport'
import { downloadSceneAsApng, downloadSceneAsApngPreview } from '@/lib/apngExport'
import { downloadFromServer } from '@/lib/serverExport'
import { downloadRuwFile } from '@/lib/ruwFile'
import { useProjectName } from '@/composables/useProjectName'
import type { ButtonAsset, Layer } from '@/types/scene'
import { Modal } from '@/components/ui'
import { t } from '@/i18n'
const props = defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
const store = useSceneStore()
const { projectName } = useProjectName()
interface ProgressState {
progress: number
controller: AbortController
label: string
error: string | null
}
const progress = ref<ProgressState | null>(null)
const videoSupported = pickMimeType() !== null
// Opt-in: strip layers that are hidden in the editor (`editorHidden`)
// from the exported scene. Default off — editor-hide is a workflow
// aid and shouldn't silently drop content on export unless the user
// explicitly asks for it. Applies to every format below.
const skipHidden = ref(false)
// Target output size for frame-based formats (GIF, APNG, APNG
// preview). The user enters a width; the height is derived from the
// scene's aspect ratio so the export never squashes the content.
const targetWidth = ref(Math.max(1, Math.round(store.scene.canvas.width)))
const targetHeight = computed(() => {
const sw = store.scene.canvas.width
const sh = store.scene.canvas.height
if (sw <= 0 || sh <= 0) return targetWidth.value
return Math.max(1, Math.round((targetWidth.value * sh) / sw))
})
const targetSizeChanged = computed(
() =>
targetWidth.value !== Math.round(store.scene.canvas.width) ||
targetHeight.value !== Math.round(store.scene.canvas.height),
)
function resetTargetSize() {
targetWidth.value = Math.max(1, Math.round(store.scene.canvas.width))
}
// Target capture frame rate. Scene default is often 60 for smooth
// playback in-editor, but 60fps APNG / GIF bloats the file for no
// perceptual gain — let the user dial it down per export without
// touching the project's own fps.
const targetFps = ref(Math.max(1, Math.round(store.scene.fps || 30)))
const targetFpsChanged = computed(
() => targetFps.value !== Math.max(1, Math.round(store.scene.fps || 30)),
)
function resetTargetFps() {
targetFps.value = Math.max(1, Math.round(store.scene.fps || 30))
}
// Every time the dialog re-opens, inherit the CURRENT document
// canvas size + fps. Without this, a user who resized the stage or
// switched project fps after first opening the dialog would keep
// seeing the stale values.
watch(
() => props.modelValue,
(open) => {
if (open) {
resetTargetSize()
resetTargetFps()
}
},
)
/** Shared render options for GIF / APNG / APNG-preview. Only sends
* the overrides that actually differ from the scene so same-size
* / same-fps exports keep the old no-override code paths. */
function frameExportOpts(controller: AbortController, onProg: (p: number) => void) {
const base: {
signal: AbortSignal
onProgress: (p: number) => void
targetWidth?: number
targetHeight?: number
targetFps?: number
} = { signal: controller.signal, onProgress: onProg }
if (targetSizeChanged.value) {
base.targetWidth = targetWidth.value
base.targetHeight = targetHeight.value
}
if (targetFpsChanged.value) {
base.targetFps = targetFps.value
}
return base
}
const hasHiddenLayers = computed(() => {
if (store.scene.layers.some((l) => l.editorHidden)) return true
if (store.scene.clipTracks?.some((t) => t.editorHidden)) return true
for (const clip of store.scene.clips ?? []) {
if (clip.layers.some((l) => l.editorHidden)) return true
}
for (const asset of store.scene.assets) {
if (asset.kind === 'button' && asset.layers.some((l) => l.editorHidden)) return true
}
return false
})
/** Strip every instance that belongs to an editor-hidden clip track,
* then drop the track itself. Shared between project + single-scene
* clones so both payload shapes stay in sync. */
function stripHiddenClipTracks(page: {
clipTracks?: { id: string; editorHidden?: boolean }[]
clipInstances?: { clipTrackId: string }[]
}) {
if (!page.clipTracks?.length) return
const hiddenIds = new Set(
page.clipTracks.filter((t) => t.editorHidden).map((t) => t.id),
)
if (!hiddenIds.size) return
if (page.clipInstances) {
page.clipInstances = page.clipInstances.filter(
(ci) => !hiddenIds.has(ci.clipTrackId),
)
}
page.clipTracks = page.clipTracks.filter((t) => !hiddenIds.has(t.id))
}
function projectClone() {
// Project-shaped payload for standalone static exports — the
// embedded runtime handles multi-scene playback natively.
const project = JSON.parse(JSON.stringify(store.project))
if (!skipHidden.value) return project
for (const page of project.scenes) {
page.layers = page.layers.filter((l: Layer) => !l.editorHidden)
stripHiddenClipTracks(page)
}
if (Array.isArray(project.clips)) {
for (const clip of project.clips) {
clip.layers = clip.layers.filter((l: Layer) => !l.editorHidden)
}
}
if (Array.isArray(project.assets)) {
for (const asset of project.assets as ButtonAsset[]) {
if (asset && asset.kind === 'button' && Array.isArray(asset.layers)) {
asset.layers = asset.layers.filter((l: Layer) => !l.editorHidden)
}
}
}
return project
}
function activeSceneClone() {
// Scene-shaped payload for linear render paths (webm/mp4 server).
// Video is a single timeline — we only render the currently active
// page. A user who wants a different scene rendered needs to switch
// it in the drawer first.
const scene = JSON.parse(JSON.stringify(store.scene))
if (!skipHidden.value) return scene
scene.layers = scene.layers.filter((l: Layer) => !l.editorHidden)
stripHiddenClipTracks(scene)
if (Array.isArray(scene.clips)) {
for (const clip of scene.clips) {
clip.layers = clip.layers.filter((l: Layer) => !l.editorHidden)
}
}
if (Array.isArray(scene.assets)) {
for (const asset of scene.assets as ButtonAsset[]) {
if (asset && asset.kind === 'button' && Array.isArray(asset.layers)) {
asset.layers = asset.layers.filter((l: Layer) => !l.editorHidden)
}
}
}
return scene
}
function exportScene() {
downloadSceneExport(projectClone())
emit('update:modelValue', false)
}
function exportRuntime() {
downloadRuntime()
emit('update:modelValue', false)
}
function exportRuwBundle() {
// Re-import target: .ruw is the round-trippable format the welcome
// dialog + file-picker accept. Pull the project via projectForSave
// so the editor-session UI state (zoom, toggles, …) rides along.
const project = JSON.parse(JSON.stringify(store.projectForSave))
const base = (projectName.value ?? 'ruwio').toString()
const safe = base.replace(/[^a-z0-9äöüß_-]+/gi, '-').replace(/^-+|-+$/g, '') || 'ruwio'
downloadRuwFile(project, `${safe}.ruw`)
emit('update:modelValue', false)
}
function exportHtml() {
downloadExampleHtml(projectClone())
emit('update:modelValue', false)
}
async function exportWebm() {
if (progress.value) return
const controller = new AbortController()
progress.value = {
progress: 0,
controller,
label: t('export.labelWebm'),
error: null,
}
store.pause()
try {
await downloadSceneAsVideo(activeSceneClone(), {
signal: controller.signal,
onProgress: (p) => {
if (progress.value) progress.value.progress = p
},
})
progress.value = null
emit('update:modelValue', false)
} catch (err) {
if ((err as Error).name === 'AbortError') {
progress.value = null
return
}
if (progress.value) progress.value.error = (err as Error).message || String(err)
}
}
async function exportGif() {
if (progress.value) return
const controller = new AbortController()
progress.value = {
progress: 0,
controller,
label: t('export.labelGif'),
error: null,
}
store.pause()
try {
await downloadSceneAsGif(
activeSceneClone(),
frameExportOpts(controller, (p) => {
if (progress.value) progress.value.progress = p
}),
)
progress.value = null
emit('update:modelValue', false)
} catch (err) {
if ((err as Error).name === 'AbortError') {
progress.value = null
return
}
if (progress.value) progress.value.error = (err as Error).message || String(err)
}
}
async function exportApng() {
if (progress.value) return
const controller = new AbortController()
progress.value = {
progress: 0,
controller,
label: t('export.labelApng'),
error: null,
}
store.pause()
try {
await downloadSceneAsApng(
activeSceneClone(),
frameExportOpts(controller, (p) => {
if (progress.value) progress.value.progress = p
}),
)
progress.value = null
emit('update:modelValue', false)
} catch (err) {
if ((err as Error).name === 'AbortError') {
progress.value = null
return
}
if (progress.value) progress.value.error = (err as Error).message || String(err)
}
}
async function exportApngPreview() {
if (progress.value) return
const controller = new AbortController()
progress.value = {
progress: 0,
controller,
label: t('export.labelApngPreview'),
error: null,
}
store.pause()
try {
await downloadSceneAsApngPreview(
activeSceneClone(),
frameExportOpts(controller, (p) => {
if (progress.value) progress.value.progress = p
}),
)
progress.value = null
emit('update:modelValue', false)
} catch (err) {
if ((err as Error).name === 'AbortError') {
progress.value = null
return
}
if (progress.value) progress.value.error = (err as Error).message || String(err)
}
}
async function exportMp4Server() {
if (progress.value) return
const controller = new AbortController()
progress.value = {
progress: 0,
controller,
label: t('export.labelMp4'),
error: null,
}
store.pause()
try {
await downloadFromServer(activeSceneClone(), {
signal: controller.signal,
onProgress: (p) => {
if (progress.value) progress.value.progress = p
},
})
progress.value = null
emit('update:modelValue', false)
} catch (err) {
if ((err as Error).name === 'AbortError') {
progress.value = null
return
}
if (progress.value) progress.value.error = (err as Error).message || String(err)
}
}
function cancel() {
progress.value?.controller.abort()
}
function dismissError() {
progress.value = null
}
const isLocked = computed(() => !!progress.value && !progress.value.error)
function onUpdate(v: boolean) {
if (!v && isLocked.value) return
emit('update:modelValue', v)
}
</script>
<template>
<Modal
:model-value="modelValue"
:title="t('export.title')"
size="md"
:dismissable="!isLocked"
:close-on-backdrop="!isLocked"
:close-on-esc="!isLocked"
@update:model-value="onUpdate"
>
<div v-if="!progress" class="options">
<label
class="export-toggle"
:class="{ disabled: !hasHiddenLayers }"
v-tooltip="hasHiddenLayers ? undefined : t('export.skipHiddenNone')"
>
<span class="switch" :class="{ on: skipHidden && hasHiddenLayers }">
<input
type="checkbox"
v-model="skipHidden"
:disabled="!hasHiddenLayers"
/>
</span>
<div class="export-toggle-text">
<div class="export-toggle-label">{{ t('export.skipHidden') }}</div>
<div class="export-toggle-desc">{{ t('export.skipHiddenDesc') }}</div>
</div>
</label>
<button class="opt" @click="exportRuwBundle">
<div class="opt-icon"><PhFileArchive :size="20" /></div>
<div class="opt-text">
<div class="opt-title">
{{ t('export.ruwTitle') }}
<span class="badge accent">{{ t('export.ruwBadge') }}</span>
</div>
<div class="opt-desc">{{ t('export.ruwDesc') }}</div>
</div>
<PhDownloadSimple :size="14" class="opt-arrow" />
</button>
<button class="opt" @click="exportRuntime">
<div class="opt-icon"><PhEngine :size="20" /></div>
<div class="opt-text">
<div class="opt-title">
ruwio-runtime.js
<span class="badge">{{ t('export.runtimeBadge') }}</span>
</div>
<div class="opt-desc">{{ t('export.runtimeDesc') }}</div>
</div>
<PhDownloadSimple :size="14" class="opt-arrow" />
</button>
<button class="opt" @click="exportScene">
<div class="opt-icon"><PhCode :size="20" /></div>
<div class="opt-text">
<div class="opt-title">
animation.js
<span class="badge">{{ t('export.sceneBadge') }}</span>
</div>
<div class="opt-desc">{{ t('export.sceneDesc') }}</div>
</div>
<PhDownloadSimple :size="14" class="opt-arrow" />
</button>
<button class="opt" @click="exportHtml">
<div class="opt-icon"><PhFileHtml :size="20" /></div>
<div class="opt-text">
<div class="opt-title">{{ t('export.exampleTitle') }}</div>
<div class="opt-desc">{{ t('export.exampleDesc') }}</div>
</div>
<PhDownloadSimple :size="14" class="opt-arrow" />
</button>
<button class="opt" @click="exportMp4Server">
<div class="opt-icon"><PhFilmSlate :size="20" /></div>
<div class="opt-text">
<div class="opt-title">
MP4 Video
<span class="badge accent">{{ t('export.mp4Badge') }}</span>
</div>
<div class="opt-desc">{{ t('export.mp4Desc') }}</div>
</div>
<PhDownloadSimple :size="14" class="opt-arrow" />
</button>
<button class="opt" :disabled="!videoSupported" @click="exportWebm">
<div class="opt-icon"><PhFilmSlate :size="20" /></div>
<div class="opt-text">
<div class="opt-title">
WebM Video
<span class="badge">{{ t('export.webmBadge') }}</span>
</div>
<div class="opt-desc">
{{ videoSupported ? t('export.webmDesc') : t('export.webmNotSupported') }}
</div>
</div>
<PhDownloadSimple :size="14" class="opt-arrow" />
</button>
<div class="target-size">
<div class="target-size-label">{{ t('export.targetSize') }}</div>
<label class="target-size-input">
<span class="target-size-tag">W</span>
<input
type="number"
min="1"
step="1"
:value="targetWidth"
@input="targetWidth = Math.max(1, Math.round(Number(($event.target as HTMLInputElement).value) || 1))"
/>
</label>
<span class="target-size-times">×</span>
<div class="target-size-derived">
{{ targetHeight }}
<span class="target-size-tag">H</span>
</div>
<button
v-if="targetSizeChanged"
class="target-size-reset"
v-tooltip="t('export.targetSizeReset')"
@click="resetTargetSize"
>
{{ t('export.targetSizeResetLabel') }}
</button>
</div>
<div class="target-size">
<div class="target-size-label">{{ t('export.targetFps') }}</div>
<label class="target-size-input">
<input
type="number"
min="1"
step="1"
:value="targetFps"
@input="targetFps = Math.max(1, Math.round(Number(($event.target as HTMLInputElement).value) || 1))"
/>
<span class="target-size-tag">fps</span>
</label>
<span class="target-size-hint">{{ t('export.targetFpsHint') }}</span>
<button
v-if="targetFpsChanged"
class="target-size-reset"
v-tooltip="t('export.targetFpsReset')"
@click="resetTargetFps"
>
{{ t('export.targetSizeResetLabel') }}
</button>
</div>
<button class="opt" @click="exportGif">
<div class="opt-icon"><PhGif :size="20" /></div>
<div class="opt-text">
<div class="opt-title">
Animated GIF
<span class="badge">{{ t('export.gifBadge') }}</span>
</div>
<div class="opt-desc">{{ t('export.gifDesc') }}</div>
</div>
<PhDownloadSimple :size="14" class="opt-arrow" />
</button>
<button class="opt" @click="exportApng">
<div class="opt-icon"><PhFileImage :size="20" /></div>
<div class="opt-text">
<div class="opt-title">
Animated PNG
<span class="badge">{{ t('export.apngBadge') }}</span>
</div>
<div class="opt-desc">{{ t('export.apngDesc') }}</div>
</div>
<PhDownloadSimple :size="14" class="opt-arrow" />
</button>
<button class="opt" @click="exportApngPreview">
<div class="opt-icon"><PhFileHtml :size="20" /></div>
<div class="opt-text">
<div class="opt-title">
{{ t('export.apngPreviewTitle') }}
<span class="badge">{{ t('export.apngPreviewBadge') }}</span>
</div>
<div class="opt-desc">{{ t('export.apngPreviewDesc') }}</div>
</div>
<PhDownloadSimple :size="14" class="opt-arrow" />
</button>
</div>
<div v-else-if="!progress.error" class="rendering">
<div class="r-label">
<PhFilmSlate :size="14" />
<span class="r-text">{{ progress.label }}</span>
<span class="r-percent">{{ Math.round(progress.progress * 100) }}%</span>
</div>
<div class="r-bar">
<div class="r-bar-fill" :style="{ width: progress.progress * 100 + '%' }"></div>
</div>
<div class="r-actions">
<button class="secondary" @click="cancel">{{ t('export.cancel') }}</button>
</div>
</div>
<div v-else class="error-view">
<div class="error-title">{{ t('export.failed') }}</div>
<div class="error-msg">{{ progress.error }}</div>
<div class="error-actions">
<button class="primary" @click="dismissError">{{ t('export.back') }}</button>
</div>
</div>
</Modal>
</template>
<style scoped>
.options {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px;
}
.export-toggle {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
margin-bottom: 6px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--panel-alt);
cursor: pointer;
user-select: none;
-webkit-user-select: none;
}
.export-toggle.disabled {
opacity: 0.5;
cursor: not-allowed;
}
.export-toggle-text {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.export-toggle-label {
font-size: 12px;
font-weight: 500;
color: var(--text);
}
.export-toggle-desc {
font-size: 11px;
color: var(--text-dim);
line-height: 1.35;
}
/* iOS-style switch. The <input> is a transparent overlay that still
handles keyboard focus and click targeting; the visual is drawn by
the span + ::after pill. */
.switch {
position: relative;
width: 32px;
height: 18px;
flex-shrink: 0;
background: var(--border);
border-radius: 999px;
transition: background 140ms var(--ease);
}
.switch.on {
background: var(--accent);
}
.switch input {
position: absolute;
inset: 0;
margin: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: inherit;
}
.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);
}
.switch.on::after {
transform: translateX(14px);
}
.target-size {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
background: var(--panel-alt);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-size: 12px;
}
.target-size-label {
flex-shrink: 0;
font-weight: 500;
color: var(--text-dim);
}
.target-size-input,
.target-size-derived {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 6px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
min-width: 82px;
}
.target-size-input input {
background: transparent;
border: 0;
color: var(--text);
font: inherit;
font-variant-numeric: tabular-nums;
width: 56px;
text-align: right;
padding: 0;
outline: none;
}
.target-size-input input:focus {
outline: none;
}
.target-size-tag {
font-size: 10px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.target-size-times {
color: var(--text-dim);
}
.target-size-derived {
color: var(--text-dim);
font-variant-numeric: tabular-nums;
justify-content: flex-end;
}
.target-size-hint {
margin-left: 4px;
color: var(--text-dim);
font-size: 11px;
}
.target-size-reset {
margin-left: auto;
padding: 4px 8px;
background: var(--panel);
border: 1px solid var(--border);
color: var(--text-dim);
font-size: 11px;
border-radius: var(--radius-sm);
cursor: pointer;
}
.target-size-reset:hover {
border-color: var(--border-strong);
color: var(--text);
}
.opt {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: var(--panel-alt);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
text-align: left;
transition: background 140ms var(--ease), border-color 140ms var(--ease),
transform 140ms var(--ease);
}
.opt:hover:not(:disabled) {
background: var(--panel-hover);
border-color: var(--border-strong);
}
.opt:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.opt-icon {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg);
border-radius: var(--radius-sm);
color: var(--accent);
flex-shrink: 0;
}
.opt-text {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.opt-title {
font-size: 13px;
font-weight: 500;
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.opt-desc {
font-size: 11px;
color: var(--text-dim);
line-height: 1.35;
}
.opt-arrow {
color: var(--text-dim);
flex-shrink: 0;
}
.opt:hover:not(:disabled) .opt-arrow {
color: var(--accent);
}
.badge {
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 2px 6px;
background: var(--bg);
color: var(--text-dim);
border-radius: 999px;
}
.badge.accent {
background: var(--accent-soft);
color: var(--accent);
}
.rendering {
padding: 20px;
display: flex;
flex-direction: column;
gap: 14px;
}
.r-label {
display: flex;
align-items: center;
gap: 8px;
color: var(--text-dim-strong);
font-size: 12px;
}
.r-text {
flex: 1;
}
.r-percent {
color: var(--accent);
font-variant-numeric: tabular-nums;
font-weight: 500;
}
.r-bar {
height: 6px;
background: var(--border);
border-radius: 999px;
overflow: hidden;
}
.r-bar-fill {
height: 100%;
background: var(--accent);
transition: width 140ms linear;
}
.r-actions {
display: flex;
justify-content: flex-end;
padding-top: 4px;
}
.r-actions button {
padding: 6px 14px;
border: 1px solid var(--border);
background: var(--panel-alt);
color: var(--text);
border-radius: var(--radius-sm);
}
.r-actions button:hover:not(:disabled) {
background: var(--panel-hover);
}
.error-view {
padding: 20px;
display: flex;
flex-direction: column;
gap: 10px;
}
.error-title {
color: var(--danger);
font-weight: 600;
font-size: 13px;
}
.error-msg {
font-size: 12px;
color: var(--text-dim-strong);
background: var(--danger-soft);
padding: 8px 10px;
border-radius: var(--radius-sm);
word-break: break-word;
user-select: text;
-webkit-user-select: text;
}
.error-actions {
display: flex;
justify-content: flex-end;
}
.error-actions .primary {
padding: 6px 14px;
background: var(--accent-soft);
border: 1px solid var(--accent);
color: var(--accent);
border-radius: var(--radius-sm);
}
.error-actions .primary:hover:not(:disabled) {
background: var(--accent-soft-strong);
}
</style>