feat(ui): v1 desktop responsive — top app bar + content max-widths
CI / test (push) Has been cancelled

The existing PWA layout was mobile-first only: BottomNav hides at ≥960px
with no replacement, leaving desktop users with zero navigation and views
that stretch to viewport width. Fixes both:

- New TopNav.vue mirrors BottomNav (Home / Library / Settings) but renders
  as a top horizontal app bar at ≥960px only. Includes the wordmark + mark.
- App.vue includes <TopNav v-if="!route.meta.hideNav" /> alongside BottomNav
  so upload-flow hideNav: true still hides both.
- HomeView, LibraryView, SettingsView get desktop max-widths (820 / 1100 /
  720 respectively) so content centers instead of stretching to 1440+.

Same cream/terracotta theme tokens, no aesthetic change — just gives v1
proper desktop chrome. Prep for v2 opt-in landing next.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-15 12:17:49 -04:00
parent 81effca22b
commit 5bb8289a54
20 changed files with 226 additions and 26 deletions
+2
View File
@@ -1,4 +1,5 @@
<template> <template>
<TopNav v-if="!route.meta.hideNav" />
<RouterView /> <RouterView />
<BottomNav v-if="!route.meta.hideNav" /> <BottomNav v-if="!route.meta.hideNav" />
<BaseToast /> <BaseToast />
@@ -7,6 +8,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted } from 'vue' import { onMounted } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import TopNav from '@/components/TopNav.vue'
import BottomNav from '@/components/BottomNav.vue' import BottomNav from '@/components/BottomNav.vue'
import BaseToast from '@/components/BaseToast.vue' import BaseToast from '@/components/BaseToast.vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
+164
View File
@@ -0,0 +1,164 @@
<template>
<header class="top-nav" aria-label="Main navigation">
<RouterLink to="/" class="top-nav__brand">
<span class="top-nav__mark"><img :src="markUrl" alt=""></span>
<span class="top-nav__wordmark">WeVisto</span>
</RouterLink>
<nav class="top-nav__tabs">
<RouterLink
v-for="tab in tabs"
:key="tab.name"
:to="tab.to"
:class="['top-nav__tab', { 'top-nav__tab--active': tab.isActive(route) }]"
:aria-current="tab.isActive(route) ? 'page' : undefined"
>
<span class="top-nav__icon" aria-hidden="true" v-html="tab.icon" />
<span class="top-nav__label">{{ tab.label }}</span>
<span
v-if="tab.name === 'library' && imagesStore.pendingCount > 0"
class="top-nav__badge"
>{{ imagesStore.pendingCount > 9 ? '9+' : imagesStore.pendingCount }}</span>
</RouterLink>
</nav>
</header>
</template>
<script setup lang="ts">
import { useRoute, type RouteLocationNormalizedLoaded } from 'vue-router'
import { useImagesStore } from '@/stores/images'
const route = useRoute()
const imagesStore = useImagesStore()
const markUrl = '/build/icons/apple-touch-icon.png'
interface Tab {
name: string
label: string
to: string
icon: string
isActive: (r: RouteLocationNormalizedLoaded) => boolean
}
const tabs: Tab[] = [
{
name: 'home', label: 'Home', to: '/',
icon: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9,22 9,12 15,12 15,22"/></svg>',
isActive: r => r.path === '/',
},
{
name: 'library', label: 'Library', to: '/library',
icon: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>',
isActive: r => r.path.startsWith('/library'),
},
{
name: 'settings', label: 'Settings', to: '/settings',
icon: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>',
isActive: r => r.path.startsWith('/settings'),
},
]
</script>
<style scoped lang="scss">
.top-nav {
display: none;
position: sticky;
top: 0;
z-index: 30;
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
padding-top: env(safe-area-inset-top);
@media (min-width: 960px) {
display: flex;
align-items: center;
padding-left: var(--space-6);
padding-right: var(--space-6);
height: 60px;
gap: var(--space-6);
}
&__brand {
display: flex;
align-items: center;
gap: var(--space-3);
text-decoration: none;
color: var(--color-text);
}
&__mark {
width: 36px;
height: 36px;
border-radius: 8px;
overflow: hidden;
background: var(--color-surface-2);
flex-shrink: 0;
display: block;
}
&__mark img { width: 100%; height: 100%; display: block; }
&__wordmark {
font-size: var(--text-lg);
font-weight: 800;
letter-spacing: -0.01em;
color: var(--color-text);
}
&__tabs {
display: flex;
gap: var(--space-1);
flex: 1;
justify-content: flex-start;
margin-left: var(--space-4);
}
&__tab {
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-4);
color: var(--color-text-muted);
text-decoration: none;
font-size: var(--text-base);
font-weight: 700;
border-radius: var(--radius-md);
position: relative;
transition: color var(--duration-fast), background var(--duration-fast);
&:hover {
background: var(--color-surface-2);
color: var(--color-text);
}
&--active {
color: var(--color-primary);
background: var(--color-surface-2);
}
}
&__icon {
display: flex;
align-items: center;
justify-content: center;
}
&__label { line-height: 1; }
&__badge {
position: absolute;
top: 6px;
right: 4px;
min-width: 16px;
height: 16px;
padding: 0 4px;
background: var(--color-primary);
color: var(--color-primary-fg);
border-radius: 999px;
font-size: 10px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}
}
</style>
+8
View File
@@ -774,6 +774,14 @@ async function saveSettings() {
flex-direction: column; flex-direction: column;
gap: var(--space-3); gap: var(--space-3);
// Desktop: cap the width and center; the top nav handles routing here.
@media (min-width: 960px) {
max-width: 820px;
margin: 0 auto;
width: 100%;
padding-bottom: var(--space-8);
}
&__loading { &__loading {
color: var(--color-text-muted); color: var(--color-text-muted);
font-size: var(--text-sm); font-size: var(--text-sm);
+19
View File
@@ -492,12 +492,31 @@ async function doDelete() {
.library { .library {
padding-bottom: calc(var(--bottom-nav-height) + var(--space-4)); padding-bottom: calc(var(--bottom-nav-height) + var(--space-4));
// Desktop: wider container for grid, but capped; bottom nav is hidden so
// the calc value above is harmless padding.
@media (min-width: 960px) {
max-width: 1100px;
margin: 0 auto;
width: 100%;
padding-bottom: var(--space-8);
}
&__header { &__header {
padding: var(--space-4) var(--space-4) var(--space-3); padding: var(--space-4) var(--space-4) var(--space-3);
@media (min-width: 960px) {
display: flex;
align-items: center;
gap: var(--space-4);
}
} }
&__add-btn { &__add-btn {
width: 100%; width: 100%;
@media (min-width: 960px) {
width: auto;
}
} }
&__tabs { &__tabs {
+7
View File
@@ -259,6 +259,13 @@ async function submitPasswordChange() {
max-width: 480px; max-width: 480px;
margin: 0 auto; margin: 0 auto;
// Desktop: roomier; bottom-nav hidden, so reduce the bottom padding.
@media (min-width: 960px) {
max-width: 720px;
padding-top: var(--space-8);
padding-bottom: var(--space-8);
}
&__title { &__title {
font-size: var(--text-xl); font-size: var(--text-xl);
font-weight: 700; font-weight: 700;
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -16,9 +16,9 @@
<meta name="mobile-web-app-capable" content="yes" /> <meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" /> <meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="WeVisto" /> <meta name="apple-mobile-web-app-title" content="WeVisto" />
<script type="module" crossorigin src="/build/assets/index-CH4aAMxd.js"></script> <script type="module" crossorigin src="/build/assets/index-B3RcyMgN.js"></script>
<link rel="modulepreload" crossorigin href="/build/assets/_plugin-vue_export-helper-BNDVmFr7.js"> <link rel="modulepreload" crossorigin href="/build/assets/_plugin-vue_export-helper-BNDVmFr7.js">
<link rel="stylesheet" crossorigin href="/build/assets/index-BlLBHR1q.css"> <link rel="stylesheet" crossorigin href="/build/assets/index-CraJX9-T.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>