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>
<!-- Chip-card row: visible border + hover/focus state unifies the
name, score, and action buttons as one "object" (operator-asked
2026-06-01). The row itself is informational; the explicit
Accept button + 3-dot menu are the action affordances. -->
2026-06-01). The row itself is informational; the green / red
verdict pair + 3-dot alias menu are the action affordances. -->
<div class="fc-suggestion">
<span class="fc-suggestion__name">
{{ suggestion.display_name }}
@@ -12,18 +12,32 @@
:title="`Mapped from the tagger's “${suggestion.raw_name}” via an alias`">alias</span>
</span>
<span class="fc-suggestion__score">{{ scorePct }}</span>
<v-btn
class="fc-suggestion__accept"
size="small" variant="tonal" color="accent"
density="compact" rounded="pill"
:aria-label="`Accept ${suggestion.display_name}`"
@click="$emit('accept', suggestion)"
>
Accept
</v-btn>
<!-- Green / red pair (operator-asked 2026-06-28) mirrors the eval
card's verdict buttons: ✓ accepts the tag (positive), ✗ dismisses it
for this image (records a TagSuggestionRejection — a hard negative the
heads train on). Together they occupy ~the footprint of the old single
Accept pill, so rejecting is now a one-click peer of accepting rather
than buried in the kebab. -->
<div class="fc-suggestion__acts">
<button
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
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
v-if="hasMenu"
class="fc-suggestion__menu" size="small" variant="outlined"
:label="`More actions for ${suggestion.display_name}`"
>
@@ -42,9 +56,6 @@
>
<v-list-item-title>Remove alias</v-list-item-title>
</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>
</div>
</template>
@@ -57,6 +68,12 @@ const props = defineProps({ suggestion: { type: Object, required: true } })
defineEmits(['accept', 'alias', 'remove-alias', 'dismiss'])
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>
<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)));
font-family: 'JetBrains Mono', monospace;
}
/* Vuetify's compact density doesn't shrink the tonal button enough
for a tight row; clamp the min-width so Accept stays compact. */
.fc-suggestion__accept :deep(.v-btn__content) {
font-size: 12px; letter-spacing: 0.02em;
/* Green ✓ / red ✗ verdict pair — same circular language as the eval card
(TagEvalCard .fc-act) so accept/reject read identically across surfaces. */
.fc-suggestion__acts {
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 {
flex: 0 0 auto;
}