168 lines
4.9 KiB
Vue
168 lines
4.9 KiB
Vue
<template>
|
|
<!-- Root uses .app-root class, NOT id="app" — index.html owns #app.
|
|
Nested #app elements cause ambiguous CSS specificity. Gotcha #1. -->
|
|
<div class="app-root" :class="{ 'rich-motion': motion.rich.value, 'app-root--wizard': isWizard }">
|
|
<AppNav v-if="!isWizard" />
|
|
<main class="app-main" :class="{ 'app-main--wizard': isWizard }" id="main-content" tabindex="-1">
|
|
<!-- Skip to main content link (screen reader / keyboard nav) -->
|
|
<a href="#main-content" class="skip-link">Skip to main content</a>
|
|
|
|
<!-- Demo mode banner + welcome modal — rendered when isDemo -->
|
|
<DemoBanner v-if="config.isDemo" />
|
|
<WelcomeModal v-if="config.isDemo" />
|
|
|
|
<RouterView />
|
|
|
|
<!-- Global toast — rendered at App level so any component can trigger it -->
|
|
<Transition name="global-toast">
|
|
<div v-if="toast.message.value" class="global-toast" role="status" aria-live="polite">
|
|
{{ toast.message.value }}
|
|
</div>
|
|
</Transition>
|
|
</main>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, onMounted } from 'vue'
|
|
import { RouterView, useRoute } from 'vue-router'
|
|
import { useMotion } from './composables/useMotion'
|
|
import { useHackerMode, useKonamiCode } from './composables/useEasterEgg'
|
|
import { useTheme } from './composables/useTheme'
|
|
import { useToast } from './composables/useToast'
|
|
import AppNav from './components/AppNav.vue'
|
|
import DemoBanner from './components/DemoBanner.vue'
|
|
import WelcomeModal from './components/WelcomeModal.vue'
|
|
import { useAppConfigStore } from './stores/appConfig'
|
|
import { useDigestStore } from './stores/digest'
|
|
|
|
const motion = useMotion()
|
|
const route = useRoute()
|
|
const { toggle, restore } = useHackerMode()
|
|
const { initTheme } = useTheme()
|
|
const toast = useToast()
|
|
const config = useAppConfigStore()
|
|
const digestStore = useDigestStore()
|
|
|
|
const isWizard = computed(() => route.path.startsWith('/setup'))
|
|
|
|
useKonamiCode(toggle)
|
|
|
|
onMounted(() => {
|
|
initTheme() // apply persisted theme (hacker mode takes priority inside initTheme)
|
|
restore() // kept for hacker mode re-entry on hard reload (initTheme handles it, belt+suspenders)
|
|
digestStore.fetchAll() // populate badge immediately, before user visits Digest tab
|
|
})
|
|
</script>
|
|
|
|
<style>
|
|
/* Global resets — unscoped, applied once to document */
|
|
*, *::before, *::after {
|
|
box-sizing: border-box;
|
|
margin: 0;
|
|
padding: 0;
|
|
}
|
|
|
|
html {
|
|
font-family: var(--font-body, sans-serif);
|
|
color: var(--color-text, #1a2338);
|
|
background: var(--color-surface, #eaeff8);
|
|
overflow-x: clip; /* no BFC side effects. Gotcha #3. */
|
|
}
|
|
|
|
body {
|
|
/* Gotcha #14: do NOT set background on body. body paints on top of html —
|
|
a hardcoded body background will cover html's CSS-variable-driven color
|
|
even when html { background: var(--color-surface) } resolves correctly.
|
|
FOUT prevention lives in index.html on html only, not body. */
|
|
min-height: 100dvh; /* dynamic viewport — mobile chrome-aware. Gotcha #13. */
|
|
overflow-x: hidden;
|
|
}
|
|
|
|
#app { min-height: 100dvh; }
|
|
|
|
/* Layout root — sidebar pushes content right on desktop */
|
|
.app-root {
|
|
display: flex;
|
|
min-height: 100dvh;
|
|
}
|
|
|
|
/* Main content area */
|
|
.app-main {
|
|
flex: 1;
|
|
min-width: 0; /* prevents flex blowout */
|
|
/* Desktop: offset by sidebar width */
|
|
margin-left: var(--sidebar-width, 220px);
|
|
/* Mobile: no sidebar, leave room for bottom tab bar */
|
|
}
|
|
|
|
/* Skip-to-content link — visible only on keyboard focus */
|
|
.skip-link {
|
|
position: absolute;
|
|
top: -999px;
|
|
left: var(--space-4);
|
|
background: var(--app-primary);
|
|
color: white;
|
|
padding: var(--space-2) var(--space-4);
|
|
border-radius: var(--radius-md);
|
|
font-weight: 600;
|
|
z-index: 9999;
|
|
text-decoration: none;
|
|
transition: top 0ms;
|
|
}
|
|
|
|
.skip-link:focus {
|
|
top: var(--space-4);
|
|
}
|
|
|
|
/* Mobile: no sidebar margin, add bottom tab bar clearance */
|
|
@media (max-width: 1023px) {
|
|
.app-main {
|
|
margin-left: 0;
|
|
padding-bottom: calc(56px + env(safe-area-inset-bottom));
|
|
}
|
|
}
|
|
|
|
/* Wizard: full-bleed, no sidebar offset, no tab-bar clearance */
|
|
.app-root--wizard {
|
|
display: block;
|
|
}
|
|
|
|
.app-main--wizard {
|
|
margin-left: 0;
|
|
padding-bottom: 0;
|
|
}
|
|
|
|
/* Global toast — bottom-center, above tab bar */
|
|
.global-toast {
|
|
position: fixed;
|
|
bottom: calc(72px + env(safe-area-inset-bottom));
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
background: var(--color-surface-raised, #f5f7fc);
|
|
color: var(--color-text, #1a2338);
|
|
padding: 10px 20px;
|
|
border-radius: var(--radius-md, 8px);
|
|
font-size: 0.9rem;
|
|
font-weight: 500;
|
|
box-shadow: 0 4px 16px rgba(0,0,0,0.25);
|
|
white-space: nowrap;
|
|
z-index: 9000;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.global-toast-enter-active, .global-toast-leave-active {
|
|
transition: opacity 220ms ease, transform 220ms ease;
|
|
}
|
|
.global-toast-enter-from, .global-toast-leave-to {
|
|
opacity: 0;
|
|
transform: translateX(-50%) translateY(8px);
|
|
}
|
|
|
|
@media (min-width: 1024px) {
|
|
.global-toast {
|
|
bottom: calc(24px + env(safe-area-inset-bottom));
|
|
left: calc(50% + var(--sidebar-width, 220px) / 2);
|
|
}
|
|
}
|
|
</style>
|