feat(tags): system-tag UI markers + full protection sweep (step 4 of #128)
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 32s
CI / integration (push) Failing after 3m33s

UI: shield marker + tooltip on TagChip and TagCard; system tags hide
rename/merge/delete affordances (chip kebab entirely — set-fandom never
applies to their general kind; remove stays, un-tagging is normal use).
Aliases stay available: mapping model outputs ONTO a system tag is
useful. Directory cards carry is_system.

Every destructive path that could take out a system row is now guarded,
found by sweeping run 1891s off-by-three failures — each one was a
surface that would have eaten the seeded tags:
- prune-unused: predicate exempts is_system (they ship with zero
  applications and matched every unused condition)
- reset-content: predicate exempts is_system AND keeps their
  applications — hygiene flags describe the file, not content tagging
- admin tag DELETE: refused with system_tag error
- normalize_existing_tags: scan excludes is_system — canonicalization
  would recase wip -> Wip behind TagService.rename's guard, breaking
  the name-keyed presentation lookup

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM
This commit is contained in:
2026-07-03 08:34:16 -04:00
parent 723f023e6a
commit f77e75147d
7 changed files with 121 additions and 8 deletions
@@ -13,9 +13,15 @@
<div class="fc-tagcard__name">
<template v-if="!editing">
{{ card.name }}
<v-icon
v-if="card.is_system" size="14" icon="mdi-shield-outline"
class="fc-tagcard__system"
title="System tag — tagged items are excluded from training other concepts"
/>
<span v-if="card.kind === 'character' && card.fandom_name"
class="fc-tagcard__fandom"> {{ card.fandom_name }}</span>
<v-icon
v-if="!card.is_system"
class="fc-tagcard__edit" size="14" icon="mdi-pencil"
@click.stop="startEdit"
/>
@@ -58,12 +64,18 @@
prepend-icon="mdi-tag-multiple"
@click="$emit('aliases', card)"
/>
<!-- System tags (#128): merge-away and delete are refused
server-side the hygiene machinery keys on the row so
don't offer them. Aliases stay (mapping model outputs ONTO
a system tag is useful). -->
<v-list-item
v-if="!card.is_system"
title="Merge with…"
prepend-icon="mdi-call-merge"
@click="$emit('merge-with', card)"
/>
<v-list-item
v-if="!card.is_system"
title="Delete tag"
prepend-icon="mdi-delete"
base-color="error"
@@ -133,6 +145,7 @@ function submit() {
.fc-tagcard__body { padding: 8px 12px; }
.fc-tagcard__name { font-weight: 600; }
.fc-tagcard__fandom { font-weight: 400; opacity: 0.7; }
.fc-tagcard__system { opacity: 0.6; margin-left: 2px; }
.fc-tagcard__meta {
display: flex; align-items: center; justify-content: space-between;
margin-top: 4px;
+13 -3
View File
@@ -10,14 +10,23 @@
@click:close="$emit('remove', tag.id)"
>
<v-icon start size="x-small">{{ iconFor(tag.kind) }}</v-icon>
{{ tag.name }}<span
{{ tag.name }}<v-icon
v-if="tag.is_system" end size="x-small" class="fc-tag-chip__system"
title="System tag — tagged items are excluded from training other concepts"
>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>
</v-chip>
<!-- Modal-safe kebab is baked into KebabMenu (this chip lives in the
teleported image modal #711). -->
<KebabMenu class="fc-tag-chip__kebab" :label="`More actions for ${tag.name}`">
teleported image modal #711). System tags hide it entirely: rename
is refused server-side (the hygiene machinery keys on the row) and
set-fandom never applies to their general kind. Remove () stays
un-tagging an image is normal use. -->
<KebabMenu
v-if="!tag.is_system"
class="fc-tag-chip__kebab" :label="`More actions for ${tag.name}`"
>
<v-list-item @click="$emit('rename', tag)">
<v-list-item-title>Rename</v-list-item-title>
</v-list-item>
@@ -64,6 +73,7 @@ function iconFor (k) { return KIND_ICONS[k] || 'mdi-tag' }
outline: 2px solid rgb(var(--v-theme-accent));
outline-offset: 1px;
}
.fc-tag-chip__system { opacity: 0.6; margin-left: 2px; }
.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; }