Feat(mobile): scroll-hide nav, iOS PWA safe-area painter, theme refactor

- Nav slides up on scroll-down / down on scroll-up on mobile, with
  threshold + top-of-page protection
- iOS PWA: apple-mobile-web-app-status-bar-style=black-translucent +
  fixed-position safe-area painter in Nav, driven by --page-bg CSS var.
  Works around WebKit's refusal to honor JS theme-color updates and
  media-variant theme-color tags in standalone PWA mode.
- Sheet + Drawer primitives now respect safe-area-inset via inline
  padding (top/left/right get inset-top; bottom gets inset-bottom)
- Extract theme orchestration from Preferences.vue into use-theme.js
  composable mounted at App root — Preferences is now pure UI
- Align page-bg color literals across style.css (--page-bg),
  index.html (html bg + meta theme-color), and manifest.webmanifest

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
jason5ng32andClaude Opus 4.7 committed 2026-05-27 01:10:35 +08:00
1 parent 405b1ef04a
commit 1752576374
9 files changed
+153 -73

No files matched your search

+4
View File
@@ -60,6 +60,7 @@ import { useInfoMask } from '@/composables/use-info-mask.js';
import { useRefreshOrchestrator } from '@/composables/use-refresh-orchestrator.js';
import { useShortcuts } from '@/composables/use-shortcuts.js';
import { useSectionTracking } from '@/composables/use-section-tracking.js';
import { useTheme } from '@/composables/use-theme.js';
const { t } = useI18n();
const store = useMainStore();
@@ -137,6 +138,9 @@ const { loadShortcuts } = useShortcuts({
// Scroll monitoring + section tracking (logic from widgets/Patch.vue)
useSectionTracking();
// Theme orchestration: initial apply, OS flip listener, preference watcher.
useTheme();
onMounted(() => {
loadingControl();
loadShortcuts();
+68 -10
View File
@@ -1,7 +1,18 @@
<template>
<!-- iOS PWA safe-area painter. Pairs with apple-mobile-web-app-status-bar-style=black-translucent
in index.html — the only way to get a live status-bar tint on iOS PWA, since WebKit
ignores JS theme-color writes and media-variant theme-color tags in standalone mode.
Color tracks --page-bg (style.css), which follows .dark class. -->
<div
class="fixed top-0 left-0 right-0 z-50 pointer-events-none transition-colors duration-300"
style="height: env(safe-area-inset-top); background: var(--page-bg);"
aria-hidden="true"
></div>
<!-- Nav -->
<header
class="sticky top-0 z-40 w-full border-b bg-background/80 supports-[backdrop-filter:blur(0px)]:bg-background/60 backdrop-blur">
class="sticky top-[env(safe-area-inset-top)] z-40 w-full border-b bg-background/80 supports-[backdrop-filter:blur(0px)]:bg-background/60 backdrop-blur transition-transform duration-300 ease-out will-change-transform"
:class="{ '-translate-y-full': isNavHidden }">
<nav id="navbar-top"
class="mx-auto flex w-full max-w-[1600px] items-center gap-2 px-3 sm:px-4 h-14">
@@ -170,7 +181,7 @@
</template>
<script setup>
import { ref, computed, watch } from 'vue';
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue';
import { useMainStore } from '@/store';
import { useI18n } from 'vue-i18n';
import { trackEvent } from '@/utils/use-analytics';
@@ -196,13 +207,11 @@ import { SECTION_IDS } from '@/data/sections';
const { t } = useI18n();
const store = useMainStore();
// Basic state
const isDarkMode = computed(() => store.isDarkMode);
const isMobile = computed(() => store.isMobile);
const currentSection = computed(() => store.currentSection);
const loaded = ref(false);
// Navigation items (desktop + mobile share the same list)
const navItems = SECTION_IDS;
// nav link style — current section highlight use bg-accent instead of only bold
@@ -247,17 +256,14 @@ const onNavMenuChange = (val) => {
store.setOpenSheet(val ? 'navMenu' : null);
};
// Open preferences — consumed by `p` key in use-shortcuts.js defineExpose
// Consumed by `p` shortcut in use-shortcuts.js via defineExpose.
const OpenPreferences = () => {
store.toggleSheet('preferences');
trackEvent('Nav', 'NavClick', 'Preferences');
};
// Logo click:
// - Page middle → smooth scroll to top
// - Already at top → trigger full refresh
// Note: native behavior of <a href="#"> is to jump to top instantly, not smooth scrolling.
// Here we prevent the default behavior and use window.scrollTo + smooth to ensure the animation effect.
// At top → full refresh; mid-page → smooth scroll up. preventDefault
// avoids the native instant-jump of <a href="#">.
const handleLogoClick = (e) => {
if (window.scrollY === 0) {
store.setRefreshEveryThing(true);
@@ -278,6 +284,58 @@ const scrollToSection = (el, offset = 70) => {
watch(() => store.allHasLoaded, (newValue) => { loaded.value = newValue; });
// Mobile: hide nav on scroll-down, show on scroll-up.
// SCROLL_DELTA filters out micro-jitter; SHOW_AT_TOP forces the nav
// visible near the top of the page regardless of direction.
const isNavHidden = ref(false);
let lastScrollY = 0;
let scrollTicking = false;
const SCROLL_DELTA = 5;
const SHOW_AT_TOP = 48;
const onScroll = () => {
if (scrollTicking) return;
scrollTicking = true;
requestAnimationFrame(() => {
const y = window.scrollY;
const dy = y - lastScrollY;
if (y <= SHOW_AT_TOP) {
isNavHidden.value = false;
} else if (Math.abs(dy) > SCROLL_DELTA) {
// Keep nav visible while the menu drawer is open so its close
// affordance stays in place.
if (dy > 0 && !isNavMenuOpen.value) {
isNavHidden.value = true;
} else if (dy < 0) {
isNavHidden.value = false;
}
}
lastScrollY = y;
scrollTicking = false;
});
};
watch(isMobile, (mobile) => {
if (!mobile) {
isNavHidden.value = false;
window.removeEventListener('scroll', onScroll);
} else {
lastScrollY = window.scrollY;
window.addEventListener('scroll', onScroll, { passive: true });
}
}, { immediate: false });
onMounted(() => {
if (isMobile.value) {
lastScrollY = window.scrollY;
window.addEventListener('scroll', onScroll, { passive: true });
}
});
onBeforeUnmount(() => {
window.removeEventListener('scroll', onScroll);
});
defineExpose({ OpenPreferences });
</script>
@@ -25,6 +25,7 @@ defineEmits(['close', 'escapeKeyDown']);
'fixed inset-x-0 bottom-0 z-[10001] mt-24 flex h-auto flex-col rounded-t-[14px] border bg-background shadow-lg outline-none',
props.class,
)"
:style="{ paddingBottom: 'env(safe-area-inset-bottom)' }"
@escape-key-down="$emit('escapeKeyDown', $event)"
>
<DrawerHandle
@@ -1,6 +1,7 @@
<script setup>
// shadcn-vue Sheet 面板:四向滑入,包含 overlay
// 见 refactor/01 阶段 B
import { computed } from 'vue';
import { DialogContent, DialogOverlay, DialogPortal, DialogTitle, VisuallyHidden } from 'reka-ui';
import { cva } from 'class-variance-authority';
import { cn } from '@/lib/utils';
@@ -30,6 +31,25 @@ const props = defineProps({
onInteractOutside: { type: Function, default: undefined },
});
defineEmits(['close', 'escapeKeyDown']);
// Push content past the iOS safe-area insets so it doesn't render
// behind the transparent status bar / home indicator. Inline style
// so consumers' `p-0` doesn't override it.
const safeAreaStyle = computed(() => {
switch (props.side) {
case 'top':
return { paddingTop: 'env(safe-area-inset-top)' };
case 'bottom':
return { paddingBottom: 'env(safe-area-inset-bottom)' };
case 'left':
case 'right':
default:
return {
paddingTop: 'env(safe-area-inset-top)',
paddingBottom: 'env(safe-area-inset-bottom)',
};
}
});
</script>
<template>
@@ -39,6 +59,7 @@ defineEmits(['close', 'escapeKeyDown']);
/>
<DialogContent
:class="cn(sheetVariants({ side }), props.class)"
:style="safeAreaStyle"
:on-pointer-down-outside="onPointerDownOutside"
:on-interact-outside="onInteractOutside"
@escape-key-down="$emit('escapeKeyDown', $event)"
+2 -55
View File
@@ -123,7 +123,7 @@
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, watch, h } from 'vue';
import { computed, onMounted, h } from 'vue';
import { useMainStore } from '@/store';
import { useI18n } from 'vue-i18n';
import { trackEvent } from '@/utils/use-analytics';
@@ -148,7 +148,6 @@ import {
const { t } = useI18n();
const store = useMainStore();
const isDarkMode = computed(() => store.isDarkMode);
const configs = computed(() => store.configs);
const userPreferences = computed(() => store.userPreferences);
const ipDBs = computed(() => store.ipDBs);
@@ -183,45 +182,6 @@ const currentIpDB = computed(() =>
ipDBs.value.find(db => db.id === userPreferences.value.ipGeoSource)
);
// Theme coordination — applies the user's preference ("light" / "dark" / "auto")
// to the store, <html> `.dark` class, body class, and PWA meta colors.
//
// Single source of truth: applyTheme() reads the *current* userPreferences.theme
// and the *current* OS color-scheme together, decides the effective dark state,
// and pushes it everywhere. Called on mount, on OS flip, and on user preference
// change — so all three triggers path through the same logic.
//
// The earlier version read userPreferences.theme at callback-definition time
// and also called handleThemeChange inside prefTheme *before* persisting the
// new theme value, so "auto" could miss the OS flip and "click auto" could
// get stuck on the previous manual state.
const mediaQueryList = window.matchMedia('(prefers-color-scheme: dark)');
const applyTheme = () => {
const theme = userPreferences.value.theme;
const isDark =
theme === 'dark' ||
(theme === 'auto' && mediaQueryList.matches);
store.setDarkMode(isDark);
updateBodyClass();
PWAColor();
};
const handleMediaChange = () => applyTheme();
const updateBodyClass = () => {
document.body.classList.toggle('body-dark-mode', isDarkMode.value);
};
const PWAColor = () => {
const themeColor = document.querySelector('meta[name="theme-color"]');
const backgroundColor = document.querySelector('meta[name="background-color"]');
const color = isDarkMode.value ? '#171a1d' : '#f8f9fa';
const bgColor = isDarkMode.value ? '#212529' : '#ffffff';
themeColor.setAttribute('content', color);
backgroundColor.setAttribute('content', bgColor);
};
const updateIPDBs = () => {
if (configs.value && Object.keys(configs.value).length > 0) {
store.updateIPDBs({ id: 0, enabled: configs.value.ipChecking });
@@ -232,9 +192,8 @@ const updateIPDBs = () => {
};
const prefTheme = (value) => {
// Persist first so applyTheme() reads the new value, then apply.
// Application is handled by use-theme.js, which watches this preference.
store.updatePreference('theme', value);
applyTheme();
trackEvent('Nav', 'PreferenceClick', 'Theme');
};
@@ -281,21 +240,9 @@ const prefipGeoSource = (value) => {
};
onMounted(() => {
mediaQueryList.addEventListener('change', handleMediaChange);
applyTheme();
setTimeout(updateIPDBs, 4000);
});
// Clean up the OS listener if this component is ever torn down (it normally
// lives for the life of the app, but be hygienic).
onUnmounted(() => {
mediaQueryList.removeEventListener('change', handleMediaChange);
});
// Also react when the user switches theme via any path — the watcher keeps
// applyTheme() firing even if a future code path mutates store.theme directly.
watch(() => userPreferences.value.theme, applyTheme);
// Section title: lucide icon + text, unified rhythm
const SectionTitle = (props, { slots }) =>
+35
View File
@@ -0,0 +1,35 @@
// Theme orchestrator. Reads userPreferences.theme + OS color-scheme,
// resolves the effective dark state, and pushes it to the store and
// body class. Reacts to OS theme flips and preference changes. Mount
// once at the app root (App.vue) — multiple instances would attach
// duplicate listeners.
import { onMounted, onUnmounted, watch } from 'vue';
import { useMainStore } from '@/store';
export function useTheme() {
const store = useMainStore();
const mediaQueryList = window.matchMedia('(prefers-color-scheme: dark)');
const applyTheme = () => {
const theme = store.userPreferences.theme;
const isDark =
theme === 'dark' ||
(theme === 'auto' && mediaQueryList.matches);
store.setDarkMode(isDark);
document.body.classList.toggle('body-dark-mode', isDark);
};
const handleMediaChange = () => applyTheme();
onMounted(() => {
mediaQueryList.addEventListener('change', handleMediaChange);
applyTheme();
});
onUnmounted(() => {
mediaQueryList.removeEventListener('change', handleMediaChange);
});
watch(() => store.userPreferences.theme, applyTheme);
}
+6
View File
@@ -67,6 +67,11 @@
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--radius: 0.5rem;
/* Canonical page background. Single source of truth for the iOS PWA
safe-area painter (Nav.vue). Must stay in sync with the literal
hex values in: index.html inline `html { background }`, index.html
<meta theme-color>, public/manifest.webmanifest. */
--page-bg: #ffffff;
}
.dark {
@@ -98,6 +103,7 @@
--border: oklch(1 0 0 / 0.1);
--input: oklch(1 0 0 / 0.15);
--ring: oklch(0.556 0 0);
--page-bg: #0e0e0e;
}
@theme inline {
+15 -7
View File
@@ -10,7 +10,9 @@
<meta name="robots" content="index, follow" />
<meta name="author" content="Jason Ng" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="theme-color" content="#f8f9fa" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#0e0e0e" media="(prefers-color-scheme: dark)" />
<meta name="background-color" content="#ffffff" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<!-- Avoid auto-linking phone numbers -->
@@ -98,6 +100,12 @@
padding: 0;
}
/* Keep page content below the iOS safe-area (transparent status
bar). env() resolves to 0 on non-notch platforms — no effect. */
body {
padding-top: env(safe-area-inset-top);
}
html {
line-height: 1.5;
-webkit-text-size-adjust: 100%;
@@ -108,7 +116,7 @@
"Apple Color Emoji", "Segoe UI Emoji";
scrollbar-gutter: stable;
background: #ffffff;
color: #0a0a0a;
color: #0e0e0e;
}
/* Dark mode is gated strictly on the `.dark` class — *never* on
@@ -118,7 +126,7 @@
before first paint, so boot-screen rendering already reflects the
right theme without a flash. */
html.dark {
background: #0a0a0a;
background: #0e0e0e;
color: #fafafa;
}
@@ -143,13 +151,13 @@
}
html.dark .jn-loading {
background: #0a0a0a;
background: #0e0e0e;
}
/* Hand-off, driven from App.vue:
stage-1 — non-logo children fade out, logo stays put.
stage-2 — logo scales down to 0 and fades.
stage-3 — overlay removed, `#app` runs `.jn-app-enter` below. */
stage-1 — non-logo children fade out, logo stays put.
stage-2 — logo scales down to 0 and fades.
stage-3 — overlay removed, `#app` runs `.jn-app-enter` below. */
.jn-loading>svg,
.jn-loading>div,
.jn-loading>.jn-spinner,
+1 -1
View File
@@ -6,7 +6,7 @@
"start_url": "/",
"scope": "/",
"display": "standalone",
"theme_color": "#f8f9fa",
"theme_color": "#ffffff",
"background_color": "#ffffff",
"orientation": "portrait",
"icons": [