Merge pull request 'fix(ui): Vuetify-4 review polish — active-tab indicator + one continuous chrome gradient' (#226) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 8s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 36s
CI / integration (push) Successful in 3m44s

This commit was merged in pull request #226.
This commit is contained in:
2026-07-13 14:47:59 -04:00
8 changed files with 93 additions and 34 deletions
+23 -1
View File
@@ -1,5 +1,5 @@
<template> <template>
<header class="fc-topnav"> <header class="fc-topnav" :class="{ 'fc-topnav--chrome': hasStickyChrome }">
<div class="fc-nav-left"> <div class="fc-nav-left">
<RouterLink :to="FRONT_DOOR" class="fc-brand" aria-label="FabledCurator home"> <RouterLink :to="FRONT_DOOR" class="fc-brand" aria-label="FabledCurator home">
<img src="/favicon.svg" alt="" class="fc-brand__glyph" width="22" height="22" /> <img src="/favicon.svg" alt="" class="fc-brand__glyph" width="22" height="22" />
@@ -65,6 +65,7 @@
<script setup> <script setup>
import { computed, onMounted } from 'vue' import { computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import router, { FRONT_DOOR } from '../router.js' import router, { FRONT_DOOR } from '../router.js'
import { useSystemStore } from '../stores/system.js' import { useSystemStore } from '../stores/system.js'
import PipelineStatusChip from './PipelineStatusChip.vue' import PipelineStatusChip from './PipelineStatusChip.vue'
@@ -72,6 +73,13 @@ import PipelineStatusChip from './PipelineStatusChip.vue'
const system = useSystemStore() const system = useSystemStore()
onMounted(() => system.refreshHealth()) onMounted(() => system.refreshHealth())
// Views that pin a sticky sub-header (filter bar / tabs) directly under the nav
// declare `meta.stickyChrome`. On those, the nav doesn't fade to transparent at
// its bottom — it hands off at the shared seam alpha so the sub-header can
// continue the SAME fade (see .fc-chrome-continues in app.css). One gradient.
const route = useRoute()
const hasStickyChrome = computed(() => !!route.meta?.stickyChrome)
// Every route with a meta.title is a nav entry. Order by meta.navOrder — // Every route with a meta.title is a nav entry. Order by meta.navOrder —
// router.getRoutes() does NOT guarantee declaration order, so explicit numbers // router.getRoutes() does NOT guarantee declaration order, so explicit numbers
// pin the sequence (e.g. Explore after Gallery). Routes without one fall to the // pin the sequence (e.g. Explore after Gallery). Routes without one fall to the
@@ -129,6 +137,20 @@ const health = computed(() => {
backdrop-filter: blur(2px); backdrop-filter: blur(2px);
-webkit-backdrop-filter: blur(2px); -webkit-backdrop-filter: blur(2px);
} }
/* On a view with a sticky sub-header pinned beneath (meta.stickyChrome), the nav
stops fading at the shared seam alpha instead of going fully transparent — the
sub-header (.fc-chrome-continues) picks the fade up from there, so the two read
as one continuous gradient. Compound selector out-specifies .fc-topnav so it
wins regardless of Vite's production CSS ordering. --fc-chrome-* come from the
global :root in app.css (custom props inherit into scoped styles). */
.fc-topnav.fc-topnav--chrome {
background: linear-gradient(
to bottom,
rgba(var(--fc-chrome-rgb), 0.92) 0%,
rgba(var(--fc-chrome-rgb), 0.72) 55%,
rgba(var(--fc-chrome-rgb), var(--fc-chrome-seam)) 100%
);
}
.fc-brand { .fc-brand {
display: flex; display: flex;
@@ -1,5 +1,5 @@
<template> <template>
<div class="fc-filterbar-wrap"> <div class="fc-filterbar-wrap fc-chrome-continues">
<div class="fc-filterbar"> <div class="fc-filterbar">
<v-autocomplete <v-autocomplete
v-model="selected" v-model="selected"
@@ -313,20 +313,10 @@ function pushFilter(mutate) {
and a gap shows through when scrolled to the top. */ and a gap shows through when scrolled to the top. */
margin-top: -8px; margin-top: -8px;
margin-bottom: 12px; margin-bottom: 12px;
/* EXACT same gradiated obsidian (#14171A = 20,23,26) frost as the TopNav so /* The frost itself (obsidian fade + blur) is the shared .fc-chrome-continues
the two read as one continuous piece of chrome — images scroll visibly primitive: it CONTINUES the TopNav's fade from the seam alpha to transparent
under both. The nav's gradient fades to transparent at ITS bottom; this rather than re-darkening, so the nav + bar read as one gradient (operator
bar re-darkens at its top, so a faint seam (the page/image showing through 2026-07-13). This block only owns the sticky positioning now. */
the nav's transparent edge) separates them when scrolled to the very top,
while under-scroll they frost as one. */
background: linear-gradient(
to bottom,
rgba(20, 23, 26, 0.92) 0%,
rgba(20, 23, 26, 0.65) 60%,
rgba(20, 23, 26, 0) 100%
);
backdrop-filter: blur(2px);
-webkit-backdrop-filter: blur(2px);
} }
.fc-filterbar { .fc-filterbar {
display: flex; display: flex;
+5 -5
View File
@@ -20,7 +20,7 @@ const routes = [
// FC-2: image backbone // FC-2: image backbone
{ path: '/showcase', name: 'showcase', component: ShowcaseView, meta: { title: 'Showcase', navOrder: 10 } }, { path: '/showcase', name: 'showcase', component: ShowcaseView, meta: { title: 'Showcase', navOrder: 10 } },
{ path: '/gallery', name: 'gallery', component: GalleryView, meta: { title: 'Gallery', navOrder: 20 } }, { path: '/gallery', name: 'gallery', component: GalleryView, meta: { title: 'Gallery', navOrder: 20, stickyChrome: true } },
// Explore: a 3-pane tagging workspace — walk an image's visual neighbours // Explore: a 3-pane tagging workspace — walk an image's visual neighbours
// (left) while tagging the focused image (center viewer + modal-parity tag // (left) while tagging the focused image (center viewer + modal-parity tag
// rail). Optional anchor param — the bare /explore nav entry SEEDS a random // rail). Optional anchor param — the bare /explore nav entry SEEDS a random
@@ -29,11 +29,11 @@ const routes = [
// Browse hub (operator-asked 2026-06-09): Posts / Artists / Tags as tabs — // Browse hub (operator-asked 2026-06-09): Posts / Artists / Tags as tabs —
// the three "browse the library by an axis" surfaces. One nav entry; the old // the three "browse the library by an axis" surfaces. One nav entry; the old
// standalone paths redirect into the matching tab (below). // standalone paths redirect into the matching tab (below).
{ path: '/browse', name: 'browse', component: BrowseView, meta: { title: 'Browse', navOrder: 30 } }, { path: '/browse', name: 'browse', component: BrowseView, meta: { title: 'Browse', navOrder: 30, stickyChrome: true } },
// Artist detail — no meta.title (reached by clicking an artist, not nav). // Artist detail — no meta.title (reached by clicking an artist, not nav).
{ path: '/artist/:slug', name: 'artist', component: ArtistView }, { path: '/artist/:slug', name: 'artist', component: ArtistView },
// Series browse — a nav entry (meta.title). // Series browse — a nav entry (meta.title).
{ path: '/series', name: 'series', component: SeriesView, meta: { title: 'Series', navOrder: 40 } }, { path: '/series', name: 'series', component: SeriesView, meta: { title: 'Series', navOrder: 40, stickyChrome: true } },
// Series management — no meta.title (reached from a series card/tag). // Series management — no meta.title (reached from a series card/tag).
{ path: '/series/:tagId', name: 'series-manage', component: SeriesManageView }, { path: '/series/:tagId', name: 'series-manage', component: SeriesManageView },
// Series reader — immersive (no top nav, no meta.title). // Series reader — immersive (no top nav, no meta.title).
@@ -41,10 +41,10 @@ const routes = [
// FC-3: subscription backbone — purely management (sources/downloads), // FC-3: subscription backbone — purely management (sources/downloads),
// distinct from the Browse hub. // distinct from the Browse hub.
{ path: '/subscriptions', name: 'subscriptions', component: SubscriptionsView, meta: { title: 'Subscriptions', navOrder: 50 } }, { path: '/subscriptions', name: 'subscriptions', component: SubscriptionsView, meta: { title: 'Subscriptions', navOrder: 50, stickyChrome: true } },
// Settings — config, pinned to the right of the nav (TopNav special-cases it). // Settings — config, pinned to the right of the nav (TopNav special-cases it).
{ path: '/settings', name: 'settings', component: SettingsView, meta: { title: 'Settings' } }, { path: '/settings', name: 'settings', component: SettingsView, meta: { title: 'Settings', stickyChrome: true } },
// The old standalone paths now redirect into the Browse hub, preserving any // The old standalone paths now redirect into the Browse hub, preserving any
// deep-link query (e.g. /posts?post_id=N → /browse?tab=posts&post_id=N). The // deep-link query (e.g. /posts?post_id=N → /browse?tab=posts&post_id=N). The
+44
View File
@@ -49,3 +49,47 @@
ul, ol, figure, details, summary { padding: 0; margin: 0; } ul, ol, figure, details, summary { padding: 0; margin: 0; }
h1, h2, h3, h4, h5, h6, p { margin: 0; } h1, h2, h3, h4, h5, h6, p { margin: 0; }
} }
/* Active-tab indicator (operator-flagged 2026-07-13 in the Vuetify-4 review): v4's
MD3 v-tab "slider" underline renders wider than the tab and floats below it. The
active tab's TEXT is already accent-coloured (color="accent"), so drop the slider
and mark the active tab with a subtle accent fill + rounded top — a clean,
unambiguous highlight app-wide (Subscriptions / Browse / Settings / Series). */
.v-tab__slider { display: none !important; }
.v-tab[aria-selected="true"],
.v-tab.v-tab--selected {
background: rgb(var(--v-theme-accent) / 0.12);
border-radius: 8px 8px 0 0;
}
/* --- Continuous chrome fade (operator-asked 2026-07-13: "group the sub-nav as
part of the nav and use a single gradient in them"). ------------------------
The TopNav and any sticky sub-header pinned directly beneath it (Gallery's
filter bar, the Browse/Series/Settings/Subscriptions tabs bars) used to each
paint their OWN dark-to-transparent gradient (or a solid band), so the fade
read as happening TWICE — dark, fade out, then dark again. Instead the two
share ONE obsidian fade: the nav paints the TOP half (opaque → the seam
alpha) and the sub-header paints the CONTINUATION (seam alpha → transparent)
over its own height. Both reference --fc-chrome-seam, so the alphas meet
exactly at the 64px boundary — no re-darkening, no doubling, one gradient.
--fc-chrome-seam is the single knob: raise it for a heavier sub-header (more
legible tabs/controls over scrolling content), lower it for a lighter fade. */
:root {
--fc-chrome-rgb: 20, 23, 26; /* obsidian #14171A — matches the TopNav */
--fc-chrome-seam: 0.46; /* alpha where the nav hands off to the sub-header */
}
/* Applied to a sticky sub-header so it continues the nav's fade instead of
restarting it. Percentage stops so the fade always spans the element's height
(survives the filter bar's expanding refine panel). The blur keeps tabs and
controls legible as the fill thins toward transparent — the solid-surface
bars it replaces had none, so it must live here. */
.fc-chrome-continues {
background: linear-gradient(
to bottom,
rgba(var(--fc-chrome-rgb), var(--fc-chrome-seam)) 0%,
rgba(var(--fc-chrome-rgb), 0) 100%
);
backdrop-filter: blur(2px);
-webkit-backdrop-filter: blur(2px);
}
+3 -2
View File
@@ -7,7 +7,7 @@
switcher and the search stay reachable no matter how far you scroll. switcher and the search stay reachable no matter how far you scroll.
Background uses the theme surface token so content scrolls cleanly Background uses the theme surface token so content scrolls cleanly
under it (matches SettingsView's sticky tabs). --> under it (matches SettingsView's sticky tabs). -->
<div class="fc-browse__head"> <div class="fc-browse__head fc-chrome-continues">
<v-container fluid class="py-0"> <v-container fluid class="py-0">
<!-- Tabs and search share one row: the axis switcher on the left, the <!-- Tabs and search share one row: the axis switcher on the left, the
search field + active-scope chips on the right (operator-asked search field + active-scope chips on the right (operator-asked
@@ -156,7 +156,8 @@ function clearFilter(key) {
position: sticky; position: sticky;
top: 64px; /* directly under AppShell's 64px sticky TopNav */ top: 64px; /* directly under AppShell's 64px sticky TopNav */
z-index: 4; z-index: 4;
background: rgb(var(--v-theme-surface)); /* Background is the shared .fc-chrome-continues fade — it continues the nav's
gradient instead of a solid surface band (operator 2026-07-13). */
} }
.fc-browse__bar { .fc-browse__bar {
display: flex; display: flex;
+4 -3
View File
@@ -4,7 +4,7 @@
the 64px TopNav (operator-asked 2026-06-12), so the axis switcher and the 64px TopNav (operator-asked 2026-06-12), so the axis switcher and
search/sort stay reachable on a long grid. The controls can't sit search/sort stay reachable on a long grid. The controls can't sit
inside v-window (it clips sticky children), so they're hoisted here. --> inside v-window (it clips sticky children), so they're hoisted here. -->
<div class="fc-series__head"> <div class="fc-series__head fc-chrome-continues">
<v-tabs v-model="tab" density="compact"> <v-tabs v-model="tab" density="compact">
<v-tab value="browse">Browse</v-tab> <v-tab value="browse">Browse</v-tab>
<v-tab value="suggestions"> <v-tab value="suggestions">
@@ -294,12 +294,13 @@ onMounted(() => {
<style scoped> <style scoped>
/* Sticky header (tabs + active-tab controls) pinned under the 64px TopNav, so /* Sticky header (tabs + active-tab controls) pinned under the 64px TopNav, so
content scrolls cleanly beneath it. Surface bg matches SettingsView. */ content scrolls cleanly beneath it. Background is the shared
.fc-chrome-continues fade — continues the nav's gradient rather than a solid
band (operator 2026-07-13). */
.fc-series__head { .fc-series__head {
position: sticky; position: sticky;
top: 64px; top: 64px;
z-index: 4; z-index: 4;
background: rgb(var(--v-theme-surface));
padding-bottom: 12px; padding-bottom: 12px;
} }
.fc-series-browse__controls { .fc-series-browse__controls {
+4 -6
View File
@@ -7,13 +7,11 @@
<!-- Sticky tabs: operator-flagged 2026-05-25 long Import / Maintenance <!-- Sticky tabs: operator-flagged 2026-05-25 long Import / Maintenance
panels pushed the tab strip out of the viewport, forcing a scroll- panels pushed the tab strip out of the viewport, forcing a scroll-
to-top just to change tab. AppShell's TopNav is 64px sticky, so the to-top just to change tab. AppShell's TopNav is 64px sticky, so the
tab strip lives directly under it. Background uses the theme surface tab strip lives directly under it. The .fc-chrome-continues fade
token so it visually merges with the page rather than the continues the nav's gradient across the strip (operator 2026-07-13). -->
translucent v-tabs default. -->
<v-tabs <v-tabs
v-model="tab" color="accent" class="mb-4" v-model="tab" color="accent" class="mb-4 fc-chrome-continues"
style="position: sticky; top: 64px; z-index: 4; style="position: sticky; top: 64px; z-index: 4;"
background: rgb(var(--v-theme-surface));"
> >
<v-tab value="overview">Overview</v-tab> <v-tab value="overview">Overview</v-tab>
<v-tab value="activity">Activity</v-tab> <v-tab value="activity">Activity</v-tab>
+5 -2
View File
@@ -5,7 +5,7 @@
align-tabs="start" align-tabs="start"
color="accent" color="accent"
density="compact" density="compact"
class="fc-subs-tabs" class="fc-subs-tabs fc-chrome-continues"
> >
<v-tab value="subscriptions"> <v-tab value="subscriptions">
<v-icon start>mdi-account-multiple-check</v-icon> <v-icon start>mdi-account-multiple-check</v-icon>
@@ -63,7 +63,10 @@ const { tab } = useTabQuery(VALID_TABS, 'subscriptions')
} }
.fc-subs-tabs { .fc-subs-tabs {
flex: 0 0 auto; flex: 0 0 auto;
border-bottom: 1px solid rgb(var(--v-theme-on-surface-variant) / 0.18); /* Cancel the shell's pt-2 so the tabs sit flush under the 64px nav, letting
the .fc-chrome-continues fade read as one gradient with it (operator
2026-07-13). The fade replaces the old border-bottom separator. */
margin-top: -8px;
} }
.fc-subs-window { .fc-subs-window {
flex: 1 1 auto; flex: 1 1 auto;