From ec66ea5f83177113c206b95a9f6e91b747cd03bc Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 13 Jul 2026 22:28:47 -0400 Subject: [PATCH] refactor(ui): settings-card primitives + fix threshold clamp / card misgroup (#161) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tier-3 frontend DRY for the ML settings cards, plus the F-D2 clamp bug and the F-D3 card misgrouping. New primitives (components/common + composables): - — the accent-icon + .fc-section-h label + right-aligned switch row (HeadsCard x3, CropProposersCard). iconColor prop absorbs the on/off dim. - — compact numeric field that CLAMPS to [min,max] on commit. This fixes F-D2: HeadsCard/CropProposersCard previously sent Number(raw) straight to the API, so an out-of-range threshold bounced off the 400 validator (only TranslationCard clamped). density prop for the grid cards. - useSettingSave(patchFn) — the busy + patch + toast + revert-on-failure flow each card hand-rolled (HeadsCard x6 handlers, CropProposersCard, MLBackfillCard, VideoEmbeddingCard). Returns ok/false for the optimistic-switch revert. Adopted in HeadsCard, CropProposersCard, MLBackfillCard (handler only — its plain labelled switch is a different affordance), VideoEmbeddingCard. F-D3: MLThresholdSliders.vue actually rendered a "Video embedding" (frame- sampling) card but sat under "Tagging → Suggestion thresholds". Renamed it VideoEmbeddingCard.vue and moved it to the "GPU agent & embeddings" section. Left deliberately (over-DRY guard): TranslationCard uses an inline error ALERT (not a toast), already clamps its confidence with a NaN fallback, and lives on the ImportStore — a genuinely different save pattern, so forcing it onto useSettingSave would change its UX. Behaviour-preserving refactor; CI has no Vue type-check so this needs a live UI pass (toggles persist + revert on failure, thresholds clamp on blur, video card now under Embeddings). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01NsmJSQxnNxGgtM5Yz4GAqi --- .../components/common/SettingNumberField.vue | 56 ++++++ .../components/common/SettingToggleRow.vue | 42 +++++ .../components/settings/CropProposersCard.vue | 61 +++--- .../src/components/settings/HeadsCard.vue | 174 ++++++------------ .../components/settings/MLBackfillCard.vue | 24 +-- .../components/settings/MaintenancePanel.vue | 4 +- ...holdSliders.vue => VideoEmbeddingCard.vue} | 34 ++-- frontend/src/composables/useSettingSave.js | 33 ++++ 8 files changed, 244 insertions(+), 184 deletions(-) create mode 100644 frontend/src/components/common/SettingNumberField.vue create mode 100644 frontend/src/components/common/SettingToggleRow.vue rename frontend/src/components/settings/{MLThresholdSliders.vue => VideoEmbeddingCard.vue} (53%) create mode 100644 frontend/src/composables/useSettingSave.js diff --git a/frontend/src/components/common/SettingNumberField.vue b/frontend/src/components/common/SettingNumberField.vue new file mode 100644 index 0000000..3575f33 --- /dev/null +++ b/frontend/src/components/common/SettingNumberField.vue @@ -0,0 +1,56 @@ + + + + diff --git a/frontend/src/components/common/SettingToggleRow.vue b/frontend/src/components/common/SettingToggleRow.vue new file mode 100644 index 0000000..44c147b --- /dev/null +++ b/frontend/src/components/common/SettingToggleRow.vue @@ -0,0 +1,42 @@ + + + + diff --git a/frontend/src/components/settings/CropProposersCard.vue b/frontend/src/components/settings/CropProposersCard.vue index 64bb570..fb0ca81 100644 --- a/frontend/src/components/settings/CropProposersCard.vue +++ b/frontend/src/components/settings/CropProposersCard.vue @@ -15,28 +15,23 @@

-
- {{ p.icon }} - {{ p.label }} - -
+

{{ p.help }}

-
@@ -48,12 +43,12 @@ storage. Dedupe IoU drops near-duplicate crops before embedding.

-
@@ -61,14 +56,16 @@ diff --git a/frontend/src/components/settings/HeadsCard.vue b/frontend/src/components/settings/HeadsCard.vue index 6516281..4400bcd 100644 --- a/frontend/src/components/settings/HeadsCard.vue +++ b/frontend/src/components/settings/HeadsCard.vue @@ -95,14 +95,10 @@
-
- mdi-lightning-bolt - Auto-apply - -
+

Graduated heads (⚡, with ≥ {{ autoMinPosInput }} examples) apply their tag on their own where they clear {{ Math.round((autoPrecisionInput || 0) * 100) }}% @@ -111,17 +107,14 @@

- -
@@ -161,15 +154,11 @@
-
- mdi-image-off-outline - Hide presentation chrome - -
+

Auto-hide banner chrome from the gallery once a head has learned it (≥ {{ minPositives }} examples) and clears @@ -180,16 +169,14 @@ tag), it's flagged for review instead of buried. Every auto-hide is reversible.

- -
@@ -197,15 +184,11 @@
-
- mdi-progress-wrench - Auto-tag work-in-progress - -
+

Auto-tag wip and editor screenshot process art once a head has learned them (≥ {{ minPositives }} examples) and clears @@ -217,16 +200,14 @@ manual tags, never its own guesses — so it can't run away. Every tag reversible.

- -
@@ -272,6 +253,9 @@ import { toast } from '../../utils/toast.js' import { computed, onMounted, onUnmounted, ref } from 'vue' import MaintenanceTile from '../common/MaintenanceTile.vue' +import SettingNumberField from '../common/SettingNumberField.vue' +import SettingToggleRow from '../common/SettingToggleRow.vue' +import { useSettingSave } from '../../composables/useSettingSave.js' import { useHeadsStore } from '../../stores/heads.js' import { useMLStore } from '../../stores/ml.js' @@ -285,7 +269,9 @@ let pollTimer = null const autoEnabled = ref(false) const autoPrecisionInput = ref(0.97) const autoMinPosInput = ref(30) -const settingBusy = ref(false) +// Shared settings-save flow (busy + toast + revert); `settingBusy` gates the +// toggles/fields, `save` returns ok/false for the optimistic-switch revert. +const { busy: settingBusy, save } = useSettingSave(mlSettings.patchSettings) const autoBusy = ref(false) const autoStatus = ref(null) const metricsData = ref(null) @@ -395,81 +381,39 @@ function startAutoPoll() { function stopAutoPoll() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null } } async function onToggleAuto(val) { - settingBusy.value = true - try { - await mlSettings.patchSettings({ head_auto_apply_enabled: !!val }) - toast({ text: val ? 'Auto-apply on' : 'Auto-apply off', type: 'success' }) - } catch (e) { - autoEnabled.value = !val // revert the switch - toast({ text: `Could not update: ${e.message}`, type: 'error' }) - } finally { - settingBusy.value = false - } + const ok = await save({ head_auto_apply_enabled: !!val }, + { successMessage: val ? 'Auto-apply on' : 'Auto-apply off', errorPrefix: 'Could not update' }) + if (!ok) autoEnabled.value = !val // revert the switch } async function onSaveSettings() { - settingBusy.value = true - try { - await mlSettings.patchSettings({ - head_auto_apply_precision: Number(autoPrecisionInput.value), - head_auto_apply_min_positives: Number(autoMinPosInput.value), - }) - } catch (e) { - toast({ text: `Could not save: ${e.message}`, type: 'error' }) - } finally { - settingBusy.value = false - } + await save({ + head_auto_apply_precision: Number(autoPrecisionInput.value), + head_auto_apply_min_positives: Number(autoMinPosInput.value), + }) } async function onTogglePresentation(val) { - settingBusy.value = true - try { - await mlSettings.patchSettings({ presentation_auto_apply_enabled: !!val }) - toast({ text: val ? 'Chrome auto-hide on' : 'Chrome auto-hide off', type: 'success' }) - } catch (e) { - presentationEnabled.value = !val // revert the switch - toast({ text: `Could not update: ${e.message}`, type: 'error' }) - } finally { - settingBusy.value = false - } + const ok = await save({ presentation_auto_apply_enabled: !!val }, + { successMessage: val ? 'Chrome auto-hide on' : 'Chrome auto-hide off', errorPrefix: 'Could not update' }) + if (!ok) presentationEnabled.value = !val // revert the switch } async function onSavePresentation() { - settingBusy.value = true - try { - await mlSettings.patchSettings({ - presentation_auto_apply_threshold: Number(presentationThresholdInput.value), - presentation_conflict_threshold: Number(presentationConflictInput.value), - }) - } catch (e) { - toast({ text: `Could not save: ${e.message}`, type: 'error' }) - } finally { - settingBusy.value = false - } + await save({ + presentation_auto_apply_threshold: Number(presentationThresholdInput.value), + presentation_conflict_threshold: Number(presentationConflictInput.value), + }) } async function onToggleProcess(val) { - settingBusy.value = true - try { - await mlSettings.patchSettings({ process_auto_apply_enabled: !!val }) - toast({ text: val ? 'WIP auto-tag on' : 'WIP auto-tag off', type: 'success' }) - } catch (e) { - processEnabled.value = !val // revert the switch - toast({ text: `Could not update: ${e.message}`, type: 'error' }) - } finally { - settingBusy.value = false - } + const ok = await save({ process_auto_apply_enabled: !!val }, + { successMessage: val ? 'WIP auto-tag on' : 'WIP auto-tag off', errorPrefix: 'Could not update' }) + if (!ok) processEnabled.value = !val // revert the switch } async function onSaveProcess() { - settingBusy.value = true - try { - await mlSettings.patchSettings({ - process_auto_apply_threshold: Number(processThresholdInput.value), - process_conflict_threshold: Number(processConflictInput.value), - }) - } catch (e) { - toast({ text: `Could not save: ${e.message}`, type: 'error' }) - } finally { - settingBusy.value = false - } + await save({ + process_auto_apply_threshold: Number(processThresholdInput.value), + process_conflict_threshold: Number(processConflictInput.value), + }) } function onPreview() { startSweep(true) } function onApplyNow() { startSweep(false) } diff --git a/frontend/src/components/settings/MLBackfillCard.vue b/frontend/src/components/settings/MLBackfillCard.vue index aca226a..dafae6f 100644 --- a/frontend/src/components/settings/MLBackfillCard.vue +++ b/frontend/src/components/settings/MLBackfillCard.vue @@ -39,13 +39,14 @@ import { toast } from '../../utils/toast.js' import { onMounted, ref } from 'vue' import { useMLStore } from '../../stores/ml.js' +import { useSettingSave } from '../../composables/useSettingSave.js' import MaintenanceTile from '../common/MaintenanceTile.vue' import QueueStatusBar from './QueueStatusBar.vue' const store = useMLStore() +const { busy: saving, save } = useSettingSave(store.patchSettings) const busy = ref(false) const done = ref(false) const enabled = ref(true) -const saving = ref(false) onMounted(async () => { try { await store.loadSettings() @@ -55,21 +56,12 @@ onMounted(async () => { } catch { /* non-fatal */ } }) async function onToggle() { - saving.value = true - try { - await store.patchSettings({ cpu_embed_enabled: enabled.value }) - toast({ - text: enabled.value - ? 'CPU embedding on — imports queue embeds for the ml-worker' - : 'CPU embedding off — the GPU embed backfill owns whole-image embeds', - type: 'success', - }) - } catch (e) { - toast({ text: `Could not save: ${e.message}`, type: 'error' }) - enabled.value = !enabled.value - } finally { - saving.value = false - } + const ok = await save({ cpu_embed_enabled: enabled.value }, { + successMessage: enabled.value + ? 'CPU embedding on — imports queue embeds for the ml-worker' + : 'CPU embedding off — the GPU embed backfill owns whole-image embeds', + }) + if (!ok) enabled.value = !enabled.value } async function run() { busy.value = true diff --git a/frontend/src/components/settings/MaintenancePanel.vue b/frontend/src/components/settings/MaintenancePanel.vue index b379015..372f920 100644 --- a/frontend/src/components/settings/MaintenancePanel.vue +++ b/frontend/src/components/settings/MaintenancePanel.vue @@ -24,6 +24,7 @@ the CPU fallback.

+ @@ -36,7 +37,6 @@ Suggestion thresholds, trained heads and tag aliases.

- @@ -77,7 +77,7 @@ import ArchiveReextractCard from './ArchiveReextractCard.vue' import MissingFileRepairCard from './MissingFileRepairCard.vue' import GpuTriageCard from './GpuTriageCard.vue' import DbMaintenanceCard from './DbMaintenanceCard.vue' -import MLThresholdSliders from './MLThresholdSliders.vue' +import VideoEmbeddingCard from './VideoEmbeddingCard.vue' import CropProposersCard from './CropProposersCard.vue' import HeadsCard from './HeadsCard.vue' import GpuAgentCard from './GpuAgentCard.vue' diff --git a/frontend/src/components/settings/MLThresholdSliders.vue b/frontend/src/components/settings/VideoEmbeddingCard.vue similarity index 53% rename from frontend/src/components/settings/MLThresholdSliders.vue rename to frontend/src/components/settings/VideoEmbeddingCard.vue index 49c4a59..dcd34a9 100644 --- a/frontend/src/components/settings/MLThresholdSliders.vue +++ b/frontend/src/components/settings/VideoEmbeddingCard.vue @@ -12,17 +12,17 @@
- - @@ -32,21 +32,23 @@ diff --git a/frontend/src/composables/useSettingSave.js b/frontend/src/composables/useSettingSave.js new file mode 100644 index 0000000..dced75d --- /dev/null +++ b/frontend/src/composables/useSettingSave.js @@ -0,0 +1,33 @@ +import { ref } from 'vue' +import { toast } from '../utils/toast.js' + +// The shared "persist a settings patch" flow for the ML settings cards. Flips a +// busy flag, calls the store's patch (which rethrows on failure), toasts +// success/error, and returns true/false so a toggle handler can revert its +// optimistic switch on failure. Centralises the try/catch/toast the cards each +// hand-rolled (HeadsCard x6, CropProposersCard, MLBackfillCard) — and where the +// threshold-clamp drifted; the clamp now lives in . +// +// Pass the store's patch fn, e.g. useSettingSave(ml.patchSettings). +export function useSettingSave(patchFn) { + const busy = ref(false) + + // opts.successMessage — toast on success (toggles announce their new state; + // silent field-saves omit it). opts.errorPrefix — the failure toast prefix + // ("Could not save" default; toggles used "Could not update"). + async function save(patch, { successMessage = '', errorPrefix = 'Could not save' } = {}) { + busy.value = true + try { + await patchFn(patch) + if (successMessage) toast({ text: successMessage, type: 'success' }) + return true + } catch (e) { + toast({ text: `${errorPrefix}: ${e.message}`, type: 'error' }) + return false + } finally { + busy.value = false + } + } + + return { busy, save } +}