feat(ui): auto-tag accept/reject as an in-pill yes/no pair; confirm refocuses input
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 34s
CI / integration (push) Successful in 3m38s

Fold the auto-tag accept into the chip: a provisional auto-tag now shows a compact
green ✓ / red ✗ pair IN the pill (replacing the ✕), and the "auto" text label is
dropped — the yes/no is signal enough (operator-asked). ✓ confirms (trains +
shields from retraction), ✗ removes (records a negative). The name still
ellipsis-truncates so the pair stays reachable.

onConfirm now returns focus to the tag input like onRemove already does, so the
input is the cursor's resting position after any chip action in both the modal
and Explore views.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM
This commit is contained in:
2026-07-07 00:56:15 -04:00
parent 29f3a485b0
commit e7c3f4e9c9
2 changed files with 45 additions and 39 deletions
+41 -39
View File
@@ -1,7 +1,7 @@
<template> <template>
<span class="fc-tag-chip" @mouseenter="onEnter" @mouseleave="onLeave"> <span class="fc-tag-chip" @mouseenter="onEnter" @mouseleave="onLeave">
<v-chip <v-chip
size="small" closable size="small" :closable="!unconfirmedAuto"
:color="store.colorFor(tag.kind)" variant="tonal" :color="store.colorFor(tag.kind)" variant="tonal"
class="fc-tag-chip__nav" class="fc-tag-chip__nav"
role="link" role="link"
@@ -16,20 +16,26 @@
>mdi-shield-outline</v-icon><span >mdi-shield-outline</v-icon><span
v-if="tag.fandom_id" class="fc-tag-chip__fandom" v-if="tag.fandom_id" class="fc-tag-chip__fandom"
:title="tag.fandom_name ? `Fandom: ${tag.fandom_name}` : 'Has a fandom'" :title="tag.fandom_name ? `Fandom: ${tag.fandom_name}` : 'Has a fandom'"
><template v-if="fandomLabel">&nbsp;{{ fandomLabel }}</template></span><span ><template v-if="fandomLabel">&nbsp;{{ fandomLabel }}</template></span>
v-if="unconfirmedAuto" class="fc-tag-chip__auto" <!-- Provisional auto-tag: an in-pill yes/no pair REPLACES the (the pair
title="Auto-applied — provisional: it won't train the model and can be retracted until you confirm it." itself signals "auto" no separate label, operator 2026-07-07). Yes
>auto</span> confirms it (trains + shields from retraction); No removes it (records
a negative). Both hand focus back to the tag input via TagPanel. -->
<span v-if="unconfirmedAuto" class="fc-tag-chip__verdict">
<button
type="button" class="fc-tag-chip__yes"
:title="`Yes — keep “${tag.name}” (trains the model, won't be retracted)`"
:aria-label="`Confirm ${tag.name}`"
@click.stop="$emit('confirm', tag)"
><v-icon size="13">mdi-check</v-icon></button>
<button
type="button" class="fc-tag-chip__no"
:title="`No — remove “${tag.name}”`"
:aria-label="`Reject ${tag.name}`"
@click.stop="$emit('remove', tag.id)"
><v-icon size="13">mdi-close</v-icon></button>
</span>
</v-chip> </v-chip>
<!-- Keep/confirm an auto-applied tag: promotes it to a training positive and
shields it from the retraction sweep (milestone 139). Only shown for
provisional (unconfirmed) auto-tags. -->
<button
v-if="unconfirmedAuto" class="fc-tag-chip__confirm" type="button"
:title="`Keep “${tag.name}” — confirm this auto-tag so it trains the model and won't be retracted`"
:aria-label="`Confirm ${tag.name}`"
@click.stop="$emit('confirm', tag)"
><v-icon size="16">mdi-check</v-icon></button>
<!-- Modal-safe kebab is baked into KebabMenu (this chip lives in the <!-- Modal-safe kebab is baked into KebabMenu (this chip lives in the
teleported image modal #711). System tags hide it entirely: rename teleported image modal #711). System tags hide it entirely: rename
is refused server-side (the hygiene machinery keys on the row) and is refused server-side (the hygiene machinery keys on the row) and
@@ -71,8 +77,8 @@ const store = useTagStore()
const api = useApi() const api = useApi()
// An auto-applied tag the operator hasn't confirmed yet — provisional (milestone // An auto-applied tag the operator hasn't confirmed yet — provisional (milestone
// 139): it doesn't train the model and the retraction sweep can drop it. Shows // 139): it doesn't train the model and the retraction sweep can drop it. Shows an
// the "auto" badge + a Keep/confirm button. `source`/`confirmed` come from the // in-pill yes/no pair in place of the ✕. `source`/`confirmed` come from the
// applied-tags payload (list_for_image / get_image_with_tags). // applied-tags payload (list_for_image / get_image_with_tags).
const unconfirmedAuto = computed(() => const unconfirmedAuto = computed(() =>
AUTO_SOURCES.includes(props.tag.source) && !props.tag.confirmed AUTO_SOURCES.includes(props.tag.source) && !props.tag.confirmed
@@ -130,10 +136,11 @@ function iconFor (k) { return KIND_ICONS[k] || 'mdi-tag' }
</script> </script>
<style scoped> <style scoped>
/* A chip must never exceed the rail — a long character+fandom(+AUTO) chip used /* A chip must never exceed the rail — a long character+fandom chip used to
to overflow the right edge and clip its close ✕ (operator-flagged 2026-07-06). overflow the right edge and clip its trailing control (operator-flagged
The name is the elastic part: it ellipsis-truncates so the ✕, fandom, and AUTO 2026-07-06). The name is the elastic part: it ellipsis-truncates so the ✕ (or
badge stay reachable. Full name is still available via the chip's hover title. */ the auto-tag yes/no pair) and fandom stay reachable. Full name is on the
chip's hover title. */
.fc-tag-chip { display: inline-flex; align-items: center; gap: 1px; max-width: 100%; min-width: 0; } .fc-tag-chip { display: inline-flex; align-items: center; gap: 1px; max-width: 100%; min-width: 0; }
.fc-tag-chip__nav { max-width: 100%; min-width: 0; } .fc-tag-chip__nav { max-width: 100%; min-width: 0; }
.fc-tag-chip__nav :deep(.v-chip__content) { min-width: 0; overflow: hidden; } .fc-tag-chip__nav :deep(.v-chip__content) { min-width: 0; overflow: hidden; }
@@ -152,28 +159,23 @@ function iconFor (k) { return KIND_ICONS[k] || 'mdi-tag' }
.fc-tag-chip__kebab { opacity: 0.7; } .fc-tag-chip__kebab { opacity: 0.7; }
.fc-tag-chip:hover .fc-tag-chip__kebab { opacity: 1; } .fc-tag-chip:hover .fc-tag-chip__kebab { opacity: 1; }
.fc-tag-chip__fandom { opacity: 0.7; font-size: 0.85em; } .fc-tag-chip__fandom { opacity: 0.7; font-size: 0.85em; }
/* "auto" = provisional (auto-applied, unconfirmed). Quiet pill inside the chip. */ /* Provisional auto-tag: a compact green ✓ / red ✗ pair in place of the ✕. The
.fc-tag-chip__auto { yes/no is obvious enough on its own, so there's no "auto" label (operator-asked
display: inline-block; vertical-align: middle; margin-left: 4px; 2026-07-07). flex:0 0 auto keeps it visible while the name ellipsis-truncates. */
font-size: 9px; font-weight: 700; .fc-tag-chip__verdict {
text-transform: uppercase; letter-spacing: 0.04em; flex: 0 0 auto; display: inline-flex; align-items: center; gap: 1px;
color: rgb(var(--v-theme-on-surface-variant)); margin-left: 3px;
background: rgb(var(--v-theme-surface-light));
border: 1px solid rgb(var(--v-theme-on-surface-variant), 0.3);
padding: 0 4px; border-radius: 999px;
} }
/* Keep/confirm — the SAME filled green ✓ circle as the suggestion accept button .fc-tag-chip__yes, .fc-tag-chip__no {
(.fc-act--yes in SuggestionItem), so "accept this tag" reads identically
wherever it appears (operator-asked 2026-07-06). */
.fc-tag-chip__confirm {
flex: 0 0 auto;
width: 26px; height: 26px; border-radius: 50%; border: none; cursor: pointer;
display: inline-flex; align-items: center; justify-content: center; display: inline-flex; align-items: center; justify-content: center;
color: #fff; background: rgb(var(--v-theme-success)); width: 18px; height: 18px; padding: 0; border: none; border-radius: 50%;
opacity: 0.9; transition: transform 0.1s, opacity 0.1s; background: transparent; cursor: pointer; transition: background 0.1s;
} }
.fc-tag-chip__confirm:hover { opacity: 1; transform: scale(1.1); } .fc-tag-chip__yes { color: rgb(var(--v-theme-success)); }
.fc-tag-chip__confirm:focus-visible { .fc-tag-chip__no { color: rgb(var(--v-theme-error)); }
.fc-tag-chip__yes:hover { background: rgb(var(--v-theme-success), 0.16); }
.fc-tag-chip__no:hover { background: rgb(var(--v-theme-error), 0.16); }
.fc-tag-chip__yes:focus-visible, .fc-tag-chip__no:focus-visible {
outline: 2px solid rgb(var(--v-theme-accent)); outline-offset: 1px; outline: 2px solid rgb(var(--v-theme-accent)); outline-offset: 1px;
} }
</style> </style>
@@ -116,6 +116,10 @@ async function onConfirm(tag) {
try { try {
await api.post(`/api/images/${host.currentImageId}/tags/${tag.id}/confirm`) await api.post(`/api/images/${host.currentImageId}/tags/${tag.id}/confirm`)
await host.reloadTags() await host.reloadTags()
// Return the cursor to the tag input — its resting position in these views,
// matching every other chip action (remove/accept) so the keyboard flow
// never leaves the field (operator-asked 2026-07-07).
focusTagInput()
} }
catch (e) { errorMsg.value = e.message } catch (e) { errorMsg.value = e.message }
} }