diff --git a/backend/app/services/tag_directory_service.py b/backend/app/services/tag_directory_service.py
index 9d3bd58..49c55cc 100644
--- a/backend/app/services/tag_directory_service.py
+++ b/backend/app/services/tag_directory_service.py
@@ -55,21 +55,20 @@ class TagDirectoryService:
fandom = aliased(Tag)
member = aliased(Tag)
# image_count aggregates the tag's images INCLUDING, for a fandom, every
- # image carrying one of its characters (member.fandom_id == Tag.id) — the
- # member subquery is empty for non-fandom tags, so they stay direct-only.
- # DISTINCT so an image with both the fandom and ≥1 of its characters
- # counts once; a correlated scalar subquery keeps it 1 row per tag with
- # no group_by (the fandom self-join below is 1:1).
+ # image carrying one of its characters. `member` is the tag actually on
+ # the image; an image counts for the outer Tag if that applied tag IS the
+ # Tag (direct) OR is a character whose fandom_id is the Tag (the fandom
+ # leg). Both legs correlate the OUTER Tag.id at a SINGLE level — a nested
+ # `tag_id IN (SELECT ... WHERE member.fandom_id == Tag.id)` does NOT
+ # correlate and silently counts every fandom-character globally (~every
+ # tag collapses to the same inflated number). DISTINCT so an image with
+ # the fandom AND ≥1 of its characters counts once; a correlated scalar
+ # subquery keeps it 1 row per tag with no group_by.
count_col = (
select(func.count(image_tag.c.image_record_id.distinct()))
- .where(
- or_(
- image_tag.c.tag_id == Tag.id,
- image_tag.c.tag_id.in_(
- select(member.id).where(member.fandom_id == Tag.id)
- ),
- )
- )
+ .select_from(image_tag)
+ .join(member, member.id == image_tag.c.tag_id)
+ .where(or_(image_tag.c.tag_id == Tag.id, member.fandom_id == Tag.id))
.correlate(Tag)
.scalar_subquery()
.label("image_count")
diff --git a/frontend/src/components/TopNav.vue b/frontend/src/components/TopNav.vue
index b37514e..4c0028f 100644
--- a/frontend/src/components/TopNav.vue
+++ b/frontend/src/components/TopNav.vue
@@ -72,10 +72,14 @@ import PipelineStatusChip from './PipelineStatusChip.vue'
const system = useSystemStore()
onMounted(() => system.refreshHealth())
-// Same mechanism the old sidebar used: every route with a meta.title is a
-// nav entry, in router declaration order. Auto-tracks future routes.
+// Every route with a meta.title is a nav entry. Order by meta.navOrder —
+// router.getRoutes() does NOT guarantee declaration order, so explicit numbers
+// pin the sequence (e.g. Explore after Gallery). Routes without one fall to the
+// end. Auto-tracks future routes.
const navRoutes = computed(() =>
- router.getRoutes().filter(r => r.meta?.title)
+ router.getRoutes()
+ .filter(r => r.meta?.title)
+ .sort((a, b) => (a.meta.navOrder ?? 999) - (b.meta.navOrder ?? 999))
)
// Content links for the centered desktop row — everything EXCEPT Settings,
// which is config and gets pinned to the right edge instead.
diff --git a/frontend/src/components/explore/TagGapPanel.vue b/frontend/src/components/explore/TagGapPanel.vue
deleted file mode 100644
index f44546c..0000000
--- a/frontend/src/components/explore/TagGapPanel.vue
+++ /dev/null
@@ -1,159 +0,0 @@
-
-
-
Tag-coverage gaps
-
- Tags most of this cluster shares but some images lack. Apply to close the
- gap across the stragglers.
-
-
-
-
-
-
-
-
-
-
-
- {{ error }}
-
-
- Anchor on an image with visual neighbours to see gaps.
-
-
- No gaps at this threshold — this cluster is consistently tagged.
-