frontend: own the viewport contract in a global stylesheet

Element Plus ships no html/body reset, so the browser default
`body { margin: 8px }` applied, while the app shell was declared
`min-height: 100vh`. The document therefore measured one viewport plus
8px: a vertical scrollbar was permanently visible on the right edge even
for short pages, every page was pushed 8px off the intended 20px gutter,
and the window and .el-main both scrolled.

Add src/styles/base.css, imported once from main.ts after Element Plus,
which now owns the reset and the shell contract:

- html/body/#app/.app-shell are exactly one viewport tall with no margin;
  body never scrolls, so the window has no scrollbar
- .app-main is the single scroll container (min-height: 0, overflow-y
  auto, overflow-x hidden) with a slim low-contrast scrollbar that only
  shows when content truly exceeds the viewport
- global guards against content escaping the gutter: media max-width,
  wrapping for long code strings
- shared .page helper for a consistent per-view vertical rhythm

Also let the header menu track its content box, so its active underline
stays flush with the header separator instead of spilling past it, and
drop the now-unused RouterLink import.
This commit is contained in:
2026-09-18 14:34:34 +08:00
parent ff024ed927
commit cad96e585f
4 changed files with 169 additions and 13 deletions
+15 -6
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { RouterLink, RouterView } from 'vue-router'
import { RouterView } from 'vue-router'
import { useAppStore } from '@/stores/app'
const appStore = useAppStore()
@@ -17,17 +17,18 @@ const appStore = useAppStore()
</el-button>
</el-header>
<el-main>
<el-main class="app-main">
<RouterView />
</el-main>
</el-container>
</template>
<!--
Sizing and scrolling belong to the global shell contract in
src/styles/base.css (.app-shell, .app-header, .app-main). Only the chrome
drawn inside the header is styled here.
-->
<style scoped>
.app-shell {
min-height: 100vh;
}
.app-header {
display: flex;
align-items: center;
@@ -42,6 +43,14 @@ const appStore = useAppStore()
.app-nav {
flex: 1;
/* min-width: 0 keeps the horizontal menu from forcing the header — and with
it the page — wider than the viewport. */
min-width: 0;
border-bottom: none;
/* Element Plus sizes a horizontal menu at a fixed 60px, but the header's own
1px bottom border leaves only 59px of content box, so the menu would spill
0.5px past the separator and drag its active underline below it. Tracking
the content box keeps the underline flush with the border. */
--el-menu-horizontal-height: 100%;
}
</style>
+4
View File
@@ -6,6 +6,10 @@ import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import 'element-plus/dist/index.css'
// Global reset and application-shell layout contract. Imported after Element
// Plus so it can override its component defaults (e.g. .el-main overflow).
import './styles/base.css'
import App from './App.vue'
import router from './router'
+145
View File
@@ -0,0 +1,145 @@
/* ==========================================================================
* Global base styles — imported once from src/main.ts, after Element Plus.
*
* This file owns exactly two things: a minimal reset, and the application
* shell's viewport contract. Every view inherits both, so no page has to
* re-declare margins, heights or scroll behaviour.
*
* Why it exists
* -------------
* Element Plus ships component styles only: dist/index.css contains no
* `html` / `body` reset. The browser default `body { margin: 8px }` therefore
* applied on top of a shell declared as `min-height: 100vh`. The document
* measured one viewport PLUS 8px, which produced:
*
* - a permanently visible vertical scrollbar on the right edge, even on a
* page whose content is far shorter than the viewport;
* - every page shifted 8px away from the intended 20px gutter;
* - two competing scroll containers (the window and .el-main).
*
* The contract now
* ----------------
* html / body / #app / .app-shell exactly one viewport tall, zero margin
* body never scrolls -> the window has no bar
* .app-main the single scroll container, showing a
* slim bar only when content really does
* exceed the viewport
* page gutter el-header / el-main padding (20px),
* which all content stays inside
* ========================================================================== */
/* --- reset ----------------------------------------------------------------- */
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body {
height: 100%;
margin: 0;
padding: 0;
}
body {
/* Element Plus declares --el-font-family but never applies it to the
document, so page-level text would otherwise fall back to the browser
default. */
font-family: var(--el-font-family);
background-color: var(--el-bg-color-page);
color: var(--el-text-color-primary);
/* The shell below is exactly one viewport tall and owns all scrolling.
The window itself must never scroll, so no bar appears on the right. */
overflow: hidden;
}
#app {
height: 100%;
min-width: 0;
}
/* Media can never be the reason a page overflows its padding. */
img,
svg,
video,
canvas {
max-width: 100%;
}
/* Long unbroken strings — DSNs, URLs, hashes — wrap instead of pushing the
content box wider than the page gutter. */
code,
kbd,
samp {
overflow-wrap: anywhere;
}
/* --- application shell ------------------------------------------------------
.app-shell and .app-main are applied by src/App.vue. They live here because
the viewport contract spans html -> body -> #app -> shell -> main and only
holds together when it is declared in one place.
--------------------------------------------------------------------------- */
.app-shell {
/* Fill the viewport exactly. Not `min-height: 100vh`: that grows with
content and re-introduces the window scrollbar. */
height: 100%;
min-height: 0;
/* el-container defaults to a row; the shell stacks header over content. */
flex-direction: column;
}
/* The header keeps its height; only the content area scrolls. */
.app-shell > .app-header {
flex: 0 0 auto;
}
.app-main {
/* min-height: 0 lets this flex child actually shrink, so overflow is
contained here instead of escaping to the window. */
flex: 1 1 auto;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
/* Slim, low-contrast scrollbar: invisible while content fits, unobtrusive
when it does not. Firefox. */
scrollbar-width: thin;
scrollbar-color: var(--el-border-color-darker) transparent;
}
/* WebKit / Blink. */
.app-main::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.app-main::-webkit-scrollbar-track {
background: transparent;
}
.app-main::-webkit-scrollbar-thumb {
background-color: var(--el-border-color-darker);
border-radius: 4px;
}
.app-main::-webkit-scrollbar-thumb:hover {
background-color: var(--el-text-color-placeholder);
}
/* --- page-level helpers -----------------------------------------------------
A view's outermost element uses .page to get a consistent vertical rhythm
inside the main gutter. */
.page {
display: flex;
flex-direction: column;
gap: 16px;
min-width: 0;
}
+5 -7
View File
@@ -31,7 +31,7 @@ onMounted(loadHealth)
</script>
<template>
<div class="home">
<div class="page">
<el-card shadow="never">
<template #header>
<div class="card-header">
@@ -79,13 +79,11 @@ onMounted(loadHealth)
</div>
</template>
<!--
The outer .page class (stacking + gap) comes from src/styles/base.css, so
every view shares one vertical rhythm inside the main gutter.
-->
<style scoped>
.home {
display: flex;
flex-direction: column;
gap: 16px;
}
.card-header {
display: flex;
align-items: center;