Merge pull request 'Auto-tag accept/reject as an in-pill yes/no pair; confirm refocuses the tag input' (#200) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-agent (push) Successful in 8s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m44s

This commit was merged in pull request #200.
This commit is contained in:
2026-07-07 01:13:02 -04:00
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 }
} }