Tagging-v2: heads are the suggestion source (learn-from-tags) + rail accept/reject #142

Merged
bvandeusen merged 7 commits from dev into main 2026-06-28 11:55:49 -04:00
Showing only changes of commit 1d39afa3b6 - Show all commits
@@ -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;
} }