feat(modal): green ✓ / red ✗ verdict pair on suggestion rows
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m18s

Replace the single "Accept" pill in the modal Suggestions rail with the
eval card's green ✓ / red ✗ language: ✓ accepts the tag (positive), ✗
dismisses it for this image — which already persists a TagSuggestionRejection
(hard negative the heads train on). The pair occupies ~the footprint of the
old pill, so per-image rejection becomes a one-click peer of accepting
instead of being buried in the kebab.

Dismiss moves off the 3-dot menu, so the kebab now only carries alias
actions and is hidden when none apply (centroid hits with no alias option).

Toward #1134 (native per-image negatives in the rail). The bigger piece —
heads as a suggestion source feeding this panel — is still ahead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
This commit is contained in:
2026-06-28 09:38:30 -04:00
parent b69c70ab2b
commit 1d39afa3b6
@@ -1,8 +1,8 @@
<template> <template>
<!-- Chip-card row: visible border + hover/focus state unifies the <!-- Chip-card row: visible border + hover/focus state unifies the
name, score, and action buttons as one "object" (operator-asked name, score, and action buttons as one "object" (operator-asked
2026-06-01). The row itself is informational; the explicit 2026-06-01). The row itself is informational; the green / red
Accept button + 3-dot menu are the action affordances. --> verdict pair + 3-dot alias menu are the action affordances. -->
<div class="fc-suggestion"> <div class="fc-suggestion">
<span class="fc-suggestion__name"> <span class="fc-suggestion__name">
{{ suggestion.display_name }} {{ suggestion.display_name }}
@@ -12,18 +12,32 @@
:title="`Mapped from the tagger's “${suggestion.raw_name}” via an alias`">alias</span> :title="`Mapped from the tagger's “${suggestion.raw_name}” via an alias`">alias</span>
</span> </span>
<span class="fc-suggestion__score">{{ scorePct }}</span> <span class="fc-suggestion__score">{{ scorePct }}</span>
<v-btn <!-- Green / red pair (operator-asked 2026-06-28) mirrors the eval
class="fc-suggestion__accept" card's verdict buttons: ✓ accepts the tag (positive), ✗ dismisses it
size="small" variant="tonal" color="accent" for this image (records a TagSuggestionRejection — a hard negative the
density="compact" rounded="pill" heads train on). Together they occupy ~the footprint of the old single
:aria-label="`Accept ${suggestion.display_name}`" Accept pill, so rejecting is now a one-click peer of accepting rather
@click="$emit('accept', suggestion)" than buried in the kebab. -->
> <div class="fc-suggestion__acts">
Accept <button
</v-btn> class="fc-act fc-act--yes" type="button"
:aria-label="`Accept ${suggestion.display_name}`"
:title="`Yes — tag ${suggestion.display_name}`"
@click="$emit('accept', suggestion)"
><v-icon size="16">mdi-check</v-icon></button>
<button
class="fc-act fc-act--no" type="button"
:aria-label="`Reject ${suggestion.display_name}`"
:title="`No — not ${suggestion.display_name}`"
@click="$emit('dismiss', suggestion)"
><v-icon size="16">mdi-close</v-icon></button>
</div>
<!-- Modal-safe kebab is baked into KebabMenu (this row lives in the <!-- Modal-safe kebab is baked into KebabMenu (this row lives in the
teleported image modal #711). --> teleported image modal — #711). Only rendered when an alias action
applies — dismiss now lives on the red ✗, so a centroid hit with no
alias option has no menu. -->
<KebabMenu <KebabMenu
v-if="hasMenu"
class="fc-suggestion__menu" size="small" variant="outlined" class="fc-suggestion__menu" size="small" variant="outlined"
:label="`More actions for ${suggestion.display_name}`" :label="`More actions for ${suggestion.display_name}`"
> >
@@ -42,9 +56,6 @@
> >
<v-list-item-title>Remove alias</v-list-item-title> <v-list-item-title>Remove alias</v-list-item-title>
</v-list-item> </v-list-item>
<v-list-item @click="$emit('dismiss', suggestion)">
<v-list-item-title>Dismiss for this image</v-list-item-title>
</v-list-item>
</KebabMenu> </KebabMenu>
</div> </div>
</template> </template>
@@ -57,6 +68,12 @@ const props = defineProps({ suggestion: { type: Object, required: true } })
defineEmits(['accept', 'alias', 'remove-alias', 'dismiss']) defineEmits(['accept', 'alias', 'remove-alias', 'dismiss'])
const scorePct = computed(() => `${Math.round(props.suggestion.score * 100)}%`) const scorePct = computed(() => `${Math.round(props.suggestion.score * 100)}%`)
// Kebab now only carries alias actions: show it when this suggestion can be
// aliased (raw model key, not yet aliased) or is already aliased (so it can be
// un-aliased). Centroid hits (no raw_name, no alias) have an empty menu → hide.
const hasMenu = computed(() =>
Boolean(props.suggestion.raw_name) || Boolean(props.suggestion.via_alias)
)
</script> </script>
<style scoped> <style scoped>
@@ -104,11 +121,22 @@ const scorePct = computed(() => `${Math.round(props.suggestion.score * 100)}%`)
color: rgb(var(--v-theme-on-surface-variant, var(--v-theme-on-surface))); color: rgb(var(--v-theme-on-surface-variant, var(--v-theme-on-surface)));
font-family: 'JetBrains Mono', monospace; font-family: 'JetBrains Mono', monospace;
} }
/* Vuetify's compact density doesn't shrink the tonal button enough /* Green ✓ / red ✗ verdict pair — same circular language as the eval card
for a tight row; clamp the min-width so Accept stays compact. */ (TagEvalCard .fc-act) so accept/reject read identically across surfaces. */
.fc-suggestion__accept :deep(.v-btn__content) { .fc-suggestion__acts {
font-size: 12px; letter-spacing: 0.02em; flex: 0 0 auto; display: flex; gap: 4px;
} }
.fc-act {
width: 26px; height: 26px; border-radius: 50%; border: none; cursor: pointer;
display: flex; align-items: center; justify-content: center; color: #fff;
opacity: 0.9; transition: transform 0.1s, opacity 0.1s;
}
.fc-act:hover { opacity: 1; transform: scale(1.1); }
.fc-act:focus-visible {
outline: 2px solid rgb(var(--v-theme-accent)); outline-offset: 1px;
}
.fc-act--yes { background: rgb(var(--v-theme-success)); }
.fc-act--no { background: rgb(var(--v-theme-error)); }
.fc-suggestion__menu { .fc-suggestion__menu {
flex: 0 0 auto; flex: 0 0 auto;
} }