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>
<span class="fc-tag-chip" @mouseenter="onEnter" @mouseleave="onLeave">
<v-chip
size="small" closable
size="small" :closable="!unconfirmedAuto"
:color="store.colorFor(tag.kind)" variant="tonal"
class="fc-tag-chip__nav"
role="link"
@@ -16,20 +16,26 @@
>mdi-shield-outline</v-icon><span
v-if="tag.fandom_id" class="fc-tag-chip__fandom"
:title="tag.fandom_name ? `Fandom: ${tag.fandom_name}` : 'Has a fandom'"
><template v-if="fandomLabel">&nbsp;{{ fandomLabel }}</template></span><span
v-if="unconfirmedAuto" class="fc-tag-chip__auto"
title="Auto-applied — provisional: it won't train the model and can be retracted until you confirm it."
>auto</span>
><template v-if="fandomLabel">&nbsp;{{ fandomLabel }}</template></span>
<!-- Provisional auto-tag: an in-pill yes/no pair REPLACES the (the pair
itself signals "auto" no separate label, operator 2026-07-07). Yes
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>
<!-- 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
teleported image modal #711). System tags hide it entirely: rename
is refused server-side (the hygiene machinery keys on the row) and
@@ -71,8 +77,8 @@ const store = useTagStore()
const api = useApi()
// 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
// the "auto" badge + a Keep/confirm button. `source`/`confirmed` come from the
// 139): it doesn't train the model and the retraction sweep can drop it. Shows an
// in-pill yes/no pair in place of the ✕. `source`/`confirmed` come from the
// applied-tags payload (list_for_image / get_image_with_tags).
const unconfirmedAuto = computed(() =>
AUTO_SOURCES.includes(props.tag.source) && !props.tag.confirmed
@@ -130,10 +136,11 @@ function iconFor (k) { return KIND_ICONS[k] || 'mdi-tag' }
</script>
<style scoped>
/* A chip must never exceed the rail — a long character+fandom(+AUTO) chip used
to overflow the right edge and clip its close ✕ (operator-flagged 2026-07-06).
The name is the elastic part: it ellipsis-truncates so the ✕, fandom, and AUTO
badge stay reachable. Full name is still available via the chip's hover title. */
/* A chip must never exceed the rail — a long character+fandom chip used to
overflow the right edge and clip its trailing control (operator-flagged
2026-07-06). The name is the elastic part: it ellipsis-truncates so the ✕ (or
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__nav { max-width: 100%; min-width: 0; }
.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:hover .fc-tag-chip__kebab { opacity: 1; }
.fc-tag-chip__fandom { opacity: 0.7; font-size: 0.85em; }
/* "auto" = provisional (auto-applied, unconfirmed). Quiet pill inside the chip. */
.fc-tag-chip__auto {
display: inline-block; vertical-align: middle; margin-left: 4px;
font-size: 9px; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.04em;
color: rgb(var(--v-theme-on-surface-variant));
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;
/* Provisional auto-tag: a compact green ✓ / red ✗ pair in place of the ✕. The
yes/no is obvious enough on its own, so there's no "auto" label (operator-asked
2026-07-07). flex:0 0 auto keeps it visible while the name ellipsis-truncates. */
.fc-tag-chip__verdict {
flex: 0 0 auto; display: inline-flex; align-items: center; gap: 1px;
margin-left: 3px;
}
/* Keep/confirm — the SAME filled green ✓ circle as the suggestion accept button
(.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;
.fc-tag-chip__yes, .fc-tag-chip__no {
display: inline-flex; align-items: center; justify-content: center;
color: #fff; background: rgb(var(--v-theme-success));
opacity: 0.9; transition: transform 0.1s, opacity 0.1s;
width: 18px; height: 18px; padding: 0; border: none; border-radius: 50%;
background: transparent; cursor: pointer; transition: background 0.1s;
}
.fc-tag-chip__confirm:hover { opacity: 1; transform: scale(1.1); }
.fc-tag-chip__confirm:focus-visible {
.fc-tag-chip__yes { color: rgb(var(--v-theme-success)); }
.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;
}
</style>
@@ -116,6 +116,10 @@ async function onConfirm(tag) {
try {
await api.post(`/api/images/${host.currentImageId}/tags/${tag.id}/confirm`)
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 }
}