feat: multimodel improvements #9

Merged
vincent merged 10 commits from multimodel-improvements into main 2026-08-10 09:15:58 +02:00
7 changed files with 358 additions and 98 deletions
Showing only changes of commit 77af339df4 - Show all commits
+10
View File
@@ -294,6 +294,16 @@
"compare_selection_pending": "Auswahl geändert", "compare_selection_pending": "Auswahl geändert",
"compare_apply_selection": "Anwenden & Diagramme neu laden", "compare_apply_selection": "Anwenden & Diagramme neu laden",
"compare_customize_models": "Modelle anpassen", "compare_customize_models": "Modelle anpassen",
"compare_edit_models": "Modelle bearbeiten",
"compare_edit_variables": "Variablen bearbeiten",
"compare_edit_models_description": "Modelle suchen oder ganze Anbietergruppen auswählen.",
"compare_edit_variables_description": "Variablen suchen oder ganze Themengruppen auswählen.",
"compare_search_models": "Modelle suchen",
"compare_search_variables": "Variablen suchen",
"compare_only_selected": "Nur ausgewählte",
"compare_select_group": "Alle auswählen",
"compare_clear_group": "Alle abwählen",
"compare_no_matching_options": "Keine passenden Optionen.",
"compare_no_models_selected": "Keine Modelle ausgewählt.", "compare_no_models_selected": "Keine Modelle ausgewählt.",
"compare_remove_model": "{model} entfernen", "compare_remove_model": "{model} entfernen",
"compare_timeline_title": "Zeitleiste des Modellvergleichs", "compare_timeline_title": "Zeitleiste des Modellvergleichs",
+10
View File
@@ -294,6 +294,16 @@
"compare_selection_pending": "Selection changed", "compare_selection_pending": "Selection changed",
"compare_apply_selection": "Apply & reload charts", "compare_apply_selection": "Apply & reload charts",
"compare_customize_models": "Customize models", "compare_customize_models": "Customize models",
"compare_edit_models": "Edit models",
"compare_edit_variables": "Edit variables",
"compare_edit_models_description": "Search models or select complete provider families.",
"compare_edit_variables_description": "Search variables or select complete thematic groups.",
"compare_search_models": "Search models",
"compare_search_variables": "Search variables",
"compare_only_selected": "Only selected",
"compare_select_group": "Select all",
"compare_clear_group": "Clear all",
"compare_no_matching_options": "No matching options.",
"compare_no_models_selected": "No models selected.", "compare_no_models_selected": "No models selected.",
"compare_remove_model": "Remove {model}", "compare_remove_model": "Remove {model}",
"compare_timeline_title": "Model Comparison Timeline", "compare_timeline_title": "Model Comparison Timeline",
+10
View File
@@ -294,6 +294,16 @@
"compare_selection_pending": "Selección modificada", "compare_selection_pending": "Selección modificada",
"compare_apply_selection": "Aplicar y recargar gráficos", "compare_apply_selection": "Aplicar y recargar gráficos",
"compare_customize_models": "Personalizar modelos", "compare_customize_models": "Personalizar modelos",
"compare_edit_models": "Editar modelos",
"compare_edit_variables": "Editar variables",
"compare_edit_models_description": "Busca modelos o selecciona familias completas de proveedores.",
"compare_edit_variables_description": "Busca variables o selecciona grupos temáticos completos.",
"compare_search_models": "Buscar modelos",
"compare_search_variables": "Buscar variables",
"compare_only_selected": "Solo seleccionados",
"compare_select_group": "Seleccionar todo",
"compare_clear_group": "Deseleccionar todo",
"compare_no_matching_options": "No hay opciones coincidentes.",
"compare_no_models_selected": "No hay modelos seleccionados.", "compare_no_models_selected": "No hay modelos seleccionados.",
"compare_remove_model": "Quitar {model}", "compare_remove_model": "Quitar {model}",
"compare_timeline_title": "Cronología de comparación de modelos", "compare_timeline_title": "Cronología de comparación de modelos",
+10
View File
@@ -294,6 +294,16 @@
"compare_selection_pending": "Sélection modifiée", "compare_selection_pending": "Sélection modifiée",
"compare_apply_selection": "Appliquer et recharger les graphiques", "compare_apply_selection": "Appliquer et recharger les graphiques",
"compare_customize_models": "Personnaliser les modèles", "compare_customize_models": "Personnaliser les modèles",
"compare_edit_models": "Modifier les modèles",
"compare_edit_variables": "Modifier les variables",
"compare_edit_models_description": "Recherchez des modèles ou sélectionnez des fournisseurs entiers.",
"compare_edit_variables_description": "Recherchez des variables ou sélectionnez des groupes thématiques entiers.",
"compare_search_models": "Rechercher des modèles",
"compare_search_variables": "Rechercher des variables",
"compare_only_selected": "Sélection uniquement",
"compare_select_group": "Tout sélectionner",
"compare_clear_group": "Tout désélectionner",
"compare_no_matching_options": "Aucune option correspondante.",
"compare_no_models_selected": "Aucun modèle sélectionné.", "compare_no_models_selected": "Aucun modèle sélectionné.",
"compare_remove_model": "Supprimer {model}", "compare_remove_model": "Supprimer {model}",
"compare_timeline_title": "Chronologie de comparaison des modèles", "compare_timeline_title": "Chronologie de comparaison des modèles",
+10
View File
@@ -294,6 +294,16 @@
"compare_selection_pending": "Selezione modificata", "compare_selection_pending": "Selezione modificata",
"compare_apply_selection": "Applica e ricarica i grafici", "compare_apply_selection": "Applica e ricarica i grafici",
"compare_customize_models": "Personalizza modelli", "compare_customize_models": "Personalizza modelli",
"compare_edit_models": "Modifica modelli",
"compare_edit_variables": "Modifica variabili",
"compare_edit_models_description": "Cerca modelli o seleziona intere famiglie di fornitori.",
"compare_edit_variables_description": "Cerca variabili o seleziona interi gruppi tematici.",
"compare_search_models": "Cerca modelli",
"compare_search_variables": "Cerca variabili",
"compare_only_selected": "Solo selezionati",
"compare_select_group": "Seleziona tutto",
"compare_clear_group": "Deseleziona tutto",
"compare_no_matching_options": "Nessuna opzione corrispondente.",
"compare_no_models_selected": "Nessun modello selezionato.", "compare_no_models_selected": "Nessun modello selezionato.",
"compare_remove_model": "Rimuovi {model}", "compare_remove_model": "Rimuovi {model}",
"compare_timeline_title": "Cronologia del confronto tra modelli", "compare_timeline_title": "Cronologia del confronto tra modelli",
@@ -18,7 +18,6 @@
type ExportableChart, type ExportableChart,
type ExportLegendItem type ExportLegendItem
} from '$lib/components/charts'; } from '$lib/components/charts';
import { Checkbox } from '$lib/components/ui/checkbox';
import { Label } from '$lib/components/ui/label'; import { Label } from '$lib/components/ui/label';
import { Switch } from '$lib/components/ui/switch'; import { Switch } from '$lib/components/ui/switch';
@@ -41,6 +40,7 @@
import { useHeroActions } from '../../hero.svelte'; import { useHeroActions } from '../../hero.svelte';
import { defaultParameters, hourly, modelGroups, models as modelOptions } from '../../options'; import { defaultParameters, hourly, modelGroups, models as modelOptions } from '../../options';
import ComparisonSelectionDrawer from './ComparisonSelectionDrawer.svelte';
import ModelPictogramTimeline from './ModelPictogramTimeline.svelte'; import ModelPictogramTimeline from './ModelPictogramTimeline.svelte';
import { import {
cardinalDirection, cardinalDirection,
@@ -67,6 +67,9 @@
group.filter((variable) => variable.value !== 'cloud_cover') group.filter((variable) => variable.value !== 'cloud_cover')
); );
const VARIABLE_IDS = new Set(COMPARISON_HOURLY.flat().map((variable) => variable.value)); const VARIABLE_IDS = new Set(COMPARISON_HOURLY.flat().map((variable) => variable.value));
const VARIABLE_ORDER = new Map(
COMPARISON_HOURLY.flat().map((variable, index) => [variable.value, index])
);
const SECONDS_PER_HOUR = 3600; const SECONDS_PER_HOUR = 3600;
const FETCH_DEBOUNCE_MS = 300; const FETCH_DEBOUNCE_MS = 300;
const STANDARD_COMPARE_VARIABLES = [ const STANDARD_COMPARE_VARIABLES = [
@@ -95,8 +98,7 @@
let isMobile = $state(false); let isMobile = $state(false);
let comparisonPlotInsetLeft = $derived(isMobile ? 92 : 116); let comparisonPlotInsetLeft = $derived(isMobile ? 92 : 116);
let comparisonPlotInsetRight = $derived(isMobile ? 8 : 20); let comparisonPlotInsetRight = $derived(isMobile ? 8 : 20);
let modelsOpen = $state(false); let selectionDrawerMode = $state<'models' | 'variables' | null>(null);
let customVariablesOpen = $state(false);
let params = $state({ let params = $state({
...defaultParameters, ...defaultParameters,
@@ -138,6 +140,14 @@
); );
} }
function orderVariables(values: string[]): string[] {
return [...values].sort(
(left, right) =>
(VARIABLE_ORDER.get(left) ?? Number.MAX_SAFE_INTEGER) -
(VARIABLE_ORDER.get(right) ?? Number.MAX_SAFE_INTEGER)
);
}
let variablesDirty = $derived(!hasSameVariables(params.hourly, appliedHourly)); let variablesDirty = $derived(!hasSameVariables(params.hourly, appliedHourly));
let modelsDirty = $derived(!hasSameOrder(params.models, appliedModels)); let modelsDirty = $derived(!hasSameOrder(params.models, appliedModels));
let displayedVariablesStale = $derived( let displayedVariablesStale = $derived(
@@ -501,7 +511,19 @@
const nextValues = values.includes(value) const nextValues = values.includes(value)
? values.filter((item) => item !== value) ? values.filter((item) => item !== value)
: [...values, value]; : [...values, value];
params[list] = list === 'models' ? orderModels(nextValues) : nextValues; params[list] = list === 'models' ? orderModels(nextValues) : orderVariables(nextValues);
}
function toggleGroupSelection(
list: 'models' | 'hourly',
values: string[],
select: boolean
): void {
const groupValues = new Set(values);
const nextValues = select
? [...new Set([...params[list], ...values])]
: params[list].filter((value) => !groupValues.has(value));
params[list] = list === 'models' ? orderModels(nextValues) : orderVariables(nextValues);
} }
function restoreStandardVariables(): void { function restoreStandardVariables(): void {
@@ -535,10 +557,15 @@
return model ? [model] : []; return model ? [model] : [];
}) })
); );
let modelSelectionGroups = $derived( let modelDrawerGroups = $derived(
modelGroups.map((group) => ({ modelGroups.map((group) => ({
...group, value: group.value,
selectedCount: group.models.filter((model) => params.models.includes(model.value)).length label: group.label,
options: group.models.map((model) => ({
value: model.value,
label: model.label,
metadata: [model.resolution, model.update].filter(Boolean).join(' · ')
}))
})) }))
); );
let selectedVariables = $derived( let selectedVariables = $derived(
@@ -551,11 +578,14 @@
params.hourly.length === STANDARD_COMPARE_VARIABLES.length && params.hourly.length === STANDARD_COMPARE_VARIABLES.length &&
STANDARD_COMPARE_VARIABLES.every((variable) => params.hourly.includes(variable)) STANDARD_COMPARE_VARIABLES.every((variable) => params.hourly.includes(variable))
); );
let variableSelectionGroups = $derived( let variableDrawerGroups = $derived(
COMPARISON_HOURLY.map((variables, index) => ({ COMPARISON_HOURLY.map((variables, index) => ({
label: variableGroupLabels[index], value: `variables_${index}`,
variables, label: variableGroupLabels[index](),
selectedCount: variables.filter((variable) => params.hourly.includes(variable.value)).length options: variables.map((variable) => ({
value: variable.value,
label: variableLabel(variable.value)
}))
})) }))
); );
</script> </script>
@@ -812,7 +842,7 @@
</div> </div>
{/if} {/if}
<!-- Compact ordered selection summaries with expandable model/variable pickers. --> <!-- Compact summaries keep the page scannable; full editing happens in the shared drawer. -->
<section class="mt-6 border-t border-border/60 pt-5 md:mt-10" aria-labelledby="models"> <section class="mt-6 border-t border-border/60 pt-5 md:mt-10" aria-labelledby="models">
<div class="flex items-baseline justify-between gap-3"> <div class="flex items-baseline justify-between gap-3">
<h2 id="models" class="text-2xl md:text-3xl">{m.compare_models_heading()}</h2> <h2 id="models" class="text-2xl md:text-3xl">{m.compare_models_heading()}</h2>
@@ -870,11 +900,10 @@
<button <button
type="button" type="button"
class="min-h-9 rounded-lg border border-border px-3 py-1.5 text-xs font-semibold hover:bg-muted" class="min-h-9 rounded-lg border border-border px-3 py-1.5 text-xs font-semibold hover:bg-muted"
onclick={() => (modelsOpen = !modelsOpen)} onclick={() => (selectionDrawerMode = 'models')}
aria-expanded={modelsOpen} aria-haspopup="dialog"
aria-controls="model-options"
> >
{modelsOpen ? m.action_close() : m.compare_customize_models()} {m.compare_customize_models()}
</button> </button>
{#if modelsDirty} {#if modelsDirty}
<button <button
@@ -887,47 +916,6 @@
{/if} {/if}
</div> </div>
</div> </div>
{#if modelsOpen}
<div id="model-options" class="mt-3 border-t border-border/60 pt-3">
<div class="grid items-start gap-2 sm:grid-cols-2 lg:grid-cols-3">
{#each modelSelectionGroups as group (group.value)}
<details class="rounded-lg border border-border/60 bg-background/40">
<summary
class="flex min-h-10 cursor-pointer list-none items-center justify-between gap-2 px-3 py-2 text-sm font-bold marker:content-none"
>
<span>{group.label}</span>
<span class="flex items-center gap-2 text-xs font-normal text-muted-foreground">
<span>{group.selectedCount}/{group.models.length}</span>
<span aria-hidden="true"></span>
</span>
</summary>
<div class="border-t border-border/50 p-2">
{#each group.models as model (model.value)}
<div class="flex min-h-9 items-center rounded-md px-1 hover:bg-muted/60">
<Checkbox
id="{model.value}_model"
value={model.value}
checked={params.models.includes(model.value)}
onCheckedChange={() => toggleSelection('models', model.value)}
/>
<Label
for="{model.value}_model"
class="min-w-0 flex-1 cursor-pointer py-1.5 pl-2 text-xs"
>
<span class="font-medium">{model.label}</span>
<span class="text-muted-foreground">
· {model.resolution} · {model.update}
</span>
</Label>
</div>
{/each}
</div>
</details>
{/each}
</div>
</div>
{/if}
</div> </div>
</section> </section>
@@ -999,11 +987,10 @@
<button <button
type="button" type="button"
class="min-h-9 rounded-lg border border-border px-3 py-1.5 text-xs font-semibold hover:bg-muted" class="min-h-9 rounded-lg border border-border px-3 py-1.5 text-xs font-semibold hover:bg-muted"
onclick={() => (customVariablesOpen = !customVariablesOpen)} onclick={() => (selectionDrawerMode = 'variables')}
aria-expanded={customVariablesOpen} aria-haspopup="dialog"
aria-controls="variable-options"
> >
{customVariablesOpen ? m.action_close() : m.compare_customize_variables()} {m.compare_customize_variables()}
</button> </button>
{#if variablesDirty} {#if variablesDirty}
<button <button
@@ -1016,42 +1003,32 @@
{/if} {/if}
</div> </div>
</div> </div>
{#if customVariablesOpen}
<div id="variable-options" class="border-t border-border/60 p-3 sm:p-4">
<div class="grid items-start gap-2 sm:grid-cols-2 lg:grid-cols-3">
{#each variableSelectionGroups as variableGroup (variableGroup.label())}
<details class="rounded-lg border border-border/60 bg-background/40">
<summary
class="flex min-h-10 cursor-pointer list-none items-center justify-between gap-2 px-3 py-2 text-sm font-bold marker:content-none"
>
<span>{variableGroup.label()}</span>
<span class="flex items-center gap-2 text-xs font-normal text-muted-foreground">
<span>{variableGroup.selectedCount}/{variableGroup.variables.length}</span>
<span aria-hidden="true"></span>
</span>
</summary>
<div class="border-t border-border/50 p-2">
{#each variableGroup.variables as variable (variable.value)}
<div class="flex min-h-10 items-center rounded-md px-1 hover:bg-muted/60">
<Checkbox
id="{variable.value}_hourly"
checked={params.hourly.includes(variable.value)}
onCheckedChange={() => toggleSelection('hourly', variable.value)}
/>
<Label
for="{variable.value}_hourly"
class="min-w-0 flex-1 cursor-pointer py-2 pl-2 text-sm"
>
{variableLabel(variable.value)}
</Label>
</div>
{/each}
</div>
</details>
{/each}
</div>
</div>
{/if}
</div> </div>
</section> </section>
{#if selectionDrawerMode === 'models'}
<ComparisonSelectionDrawer
open
mode="models"
groups={modelDrawerGroups}
selected={params.models}
dirty={comparisonSelectionDirty}
onToggle={(value) => toggleSelection('models', value)}
onToggleGroup={(values, select) => toggleGroupSelection('models', values, select)}
onApply={applyComparisonSelection}
onClose={() => (selectionDrawerMode = null)}
/>
{:else if selectionDrawerMode === 'variables'}
<ComparisonSelectionDrawer
open
mode="variables"
groups={variableDrawerGroups}
selected={params.hourly}
dirty={comparisonSelectionDirty}
onToggle={(value) => toggleSelection('hourly', value)}
onToggleGroup={(values, select) => toggleGroupSelection('hourly', values, select)}
onApply={applyComparisonSelection}
onClose={() => (selectionDrawerMode = null)}
onRestoreDefaults={restoreStandardVariables}
/>
{/if}
@@ -0,0 +1,233 @@
<script lang="ts">
import { Checkbox } from '$lib/components/ui/checkbox';
import * as Dialog from '$lib/components/ui/dialog';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import * as m from '$lib/paraglide/messages';
interface Option {
value: string;
label: string;
metadata?: string;
}
interface OptionGroup {
value: string;
label: string;
options: Option[];
}
interface Props {
open: boolean;
mode: 'models' | 'variables';
groups: OptionGroup[];
selected: string[];
dirty: boolean;
onToggle: (value: string) => void;
onToggleGroup: (values: string[], select: boolean) => void;
onApply: () => void;
onClose: () => void;
onRestoreDefaults?: () => void;
}
let {
open,
mode,
groups,
selected,
dirty,
onToggle,
onToggleGroup,
onApply,
onClose,
onRestoreDefaults
}: Props = $props();
let search = $state('');
let onlySelected = $state(false);
let normalizedSearch = $derived(search.trim().toLocaleLowerCase());
let visibleGroups = $derived.by(() =>
groups.flatMap((group) => {
const options = group.options.filter((option) => {
if (onlySelected && !selected.includes(option.value)) return false;
if (!normalizedSearch) return true;
return `${group.label} ${option.label} ${option.value} ${option.metadata ?? ''}`
.toLocaleLowerCase()
.includes(normalizedSearch);
});
return options.length > 0 ? [{ ...group, options }] : [];
})
);
$effect(() => {
if (open) return;
search = '';
onlySelected = false;
});
function groupSelection(group: OptionGroup): { count: number; all: boolean; partial: boolean } {
const count = group.options.filter((option) => selected.includes(option.value)).length;
return { count, all: count === group.options.length, partial: count > 0 && count < group.options.length };
}
function applyAndClose(): void {
onApply();
onClose();
}
</script>
<Dialog.Dialog {open} onOpenChange={(nextOpen) => !nextOpen && onClose()}>
<Dialog.DialogContent
showCloseButton={false}
class="top-0 right-0 bottom-0 left-auto flex h-dvh w-full max-w-none translate-x-0 translate-y-0 flex-col gap-0 rounded-none border-0 p-0 sm:w-[640px] sm:max-w-[90vw] sm:border-l"
>
<header class="shrink-0 border-b border-border bg-background px-4 py-4 sm:px-5">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0">
<Dialog.DialogTitle class="text-lg font-bold">
{mode === 'models' ? m.compare_edit_models() : m.compare_edit_variables()}
</Dialog.DialogTitle>
<Dialog.DialogDescription class="mt-0.5 text-xs">
{mode === 'models'
? m.compare_edit_models_description()
: m.compare_edit_variables_description()}
</Dialog.DialogDescription>
</div>
<button
type="button"
class="flex size-9 shrink-0 items-center justify-center rounded-lg text-xl text-muted-foreground hover:bg-muted hover:text-foreground"
onclick={onClose}
aria-label={m.action_close()}
>
<span aria-hidden="true">×</span>
</button>
</div>
<div class="mt-4 flex flex-col gap-2 sm:flex-row">
<Input
type="search"
bind:value={search}
placeholder={mode === 'models'
? m.compare_search_models()
: m.compare_search_variables()}
aria-label={mode === 'models'
? m.compare_search_models()
: m.compare_search_variables()}
/>
<div
class="flex min-h-9 shrink-0 cursor-pointer items-center gap-2 rounded-lg border border-border px-3 text-xs font-semibold hover:bg-muted"
>
<Checkbox id="drawer_{mode}_only_selected" bind:checked={onlySelected} />
<Label for="drawer_{mode}_only_selected" class="cursor-pointer text-xs">
{m.compare_only_selected()}
</Label>
</div>
</div>
</header>
<div class="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-4 sm:px-5">
{#if visibleGroups.length > 0}
<div class="grid items-start gap-3 sm:grid-cols-2">
{#each visibleGroups as visibleGroup (visibleGroup.value)}
{@const fullGroup = groups.find((group) => group.value === visibleGroup.value) ?? visibleGroup}
{@const selection = groupSelection(fullGroup)}
<section class="overflow-hidden rounded-xl border border-border/70 bg-card">
<div class="flex min-h-11 items-center gap-2 border-b border-border/60 bg-muted/30 px-3 py-2">
<Checkbox
id="drawer_{mode}_group_{fullGroup.value}"
checked={selection.all}
indeterminate={selection.partial}
onCheckedChange={() =>
onToggleGroup(
fullGroup.options.map((option) => option.value),
!selection.all
)}
/>
<Label
for="drawer_{mode}_group_{fullGroup.value}"
class="min-w-0 flex-1 cursor-pointer text-sm font-bold"
>
{fullGroup.label}
</Label>
<span class="shrink-0 text-[11px] text-muted-foreground tabular-nums">
{selection.count}/{fullGroup.options.length}
</span>
<button
type="button"
class="shrink-0 rounded-md px-2 py-1 text-[11px] font-semibold text-primary hover:bg-primary/10"
onclick={() =>
onToggleGroup(
fullGroup.options.map((option) => option.value),
!selection.all
)}
>
{selection.all ? m.compare_clear_group() : m.compare_select_group()}
</button>
</div>
<div class="p-2">
{#each visibleGroup.options as option (option.value)}
<div class="flex min-h-10 items-center rounded-lg px-1.5 hover:bg-muted/60">
<Checkbox
id="drawer_{mode}_{option.value}"
checked={selected.includes(option.value)}
onCheckedChange={() => onToggle(option.value)}
/>
<Label
for="drawer_{mode}_{option.value}"
class="min-w-0 flex-1 cursor-pointer py-2 pl-2"
>
<span class="block text-sm font-medium">{option.label}</span>
{#if option.metadata}
<span class="block text-[10px] text-muted-foreground">
{option.metadata}
</span>
{/if}
</Label>
</div>
{/each}
</div>
</section>
{/each}
</div>
{:else}
<div class="rounded-xl border border-dashed border-border px-4 py-10 text-center text-sm text-muted-foreground">
{m.compare_no_matching_options()}
</div>
{/if}
</div>
<footer class="shrink-0 border-t border-border bg-background px-4 py-3 sm:px-5">
<div class="flex flex-wrap items-center justify-between gap-2">
<div>
{#if mode === 'variables' && onRestoreDefaults}
<button
type="button"
class="min-h-9 rounded-lg border border-border px-3 py-1.5 text-xs font-semibold hover:bg-muted"
onclick={onRestoreDefaults}
>
{m.compare_restore_defaults()}
</button>
{/if}
</div>
<div class="flex gap-2">
<button
type="button"
class="min-h-10 rounded-lg border border-border px-4 py-2 text-sm font-semibold hover:bg-muted"
onclick={onClose}
>
{m.action_close()}
</button>
<button
type="button"
class="min-h-10 rounded-lg bg-primary px-4 py-2 text-sm font-bold text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
disabled={!dirty}
onclick={applyAndClose}
>
{m.compare_apply_selection()}
</button>
</div>
</div>
</footer>
</Dialog.DialogContent>
</Dialog.Dialog>