diff --git a/backend/.dockerignore b/backend/.dockerignore
new file mode 100644
index 00000000..ee6556b9
--- /dev/null
+++ b/backend/.dockerignore
@@ -0,0 +1,26 @@
+# Secrets & environment
+.env
+.env.*
+!.env.example
+
+# Build artifacts
+bin
+.cache
+uploads
+
+# IDE & editor
+.idea
+.vscode
+*.swp
+*.swo
+*~
+
+# Docker config
+Dockerfile
+.dockerignore
+
+# Docs & misc
+*.md
+*.log
+.DS_Store
+Makefile
diff --git a/frontend/.dockerignore b/frontend/.dockerignore
new file mode 100644
index 00000000..898014ec
--- /dev/null
+++ b/frontend/.dockerignore
@@ -0,0 +1,27 @@
+# Secrets & environment
+.env
+.env.*
+!.env.example
+
+# Dependencies & build output
+node_modules
+dist
+coverage
+*.tsbuildinfo
+.eslintcache
+
+# IDE & editor
+.idea
+.vscode
+*.swp
+*.swo
+*~
+
+# Docker config
+Dockerfile
+.dockerignore
+
+# Docs & misc
+*.md
+*.log
+.DS_Store
diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js
index 848fa7f8..d124fbb3 100644
--- a/frontend/eslint.config.js
+++ b/frontend/eslint.config.js
@@ -1,57 +1,57 @@
-import eslint from '@eslint/js'
-import tseslint from 'typescript-eslint'
-import pluginVue from 'eslint-plugin-vue'
+import eslint from "@eslint/js";
+import tseslint from "typescript-eslint";
+import pluginVue from "eslint-plugin-vue";
export default tseslint.config(
eslint.configs.recommended,
...tseslint.configs.recommended,
- ...pluginVue.configs['flat/recommended'],
+ ...pluginVue.configs["flat/recommended"],
{
- files: ['**/*.vue'],
+ files: ["**/*.vue"],
languageOptions: {
parserOptions: {
parser: tseslint.parser,
},
globals: {
- HTMLElement: 'readonly',
- HTMLStyleElement: 'readonly',
- HTMLCanvasElement: 'readonly',
- HTMLImageElement: 'readonly',
- document: 'readonly',
- window: 'readonly',
- requestAnimationFrame: 'readonly',
- cancelAnimationFrame: 'readonly',
- setTimeout: 'readonly',
- clearTimeout: 'readonly',
- setInterval: 'readonly',
- clearInterval: 'readonly',
- console: 'readonly',
- Image: 'readonly',
+ HTMLElement: "readonly",
+ HTMLStyleElement: "readonly",
+ HTMLCanvasElement: "readonly",
+ HTMLImageElement: "readonly",
+ document: "readonly",
+ window: "readonly",
+ requestAnimationFrame: "readonly",
+ cancelAnimationFrame: "readonly",
+ setTimeout: "readonly",
+ clearTimeout: "readonly",
+ setInterval: "readonly",
+ clearInterval: "readonly",
+ console: "readonly",
+ Image: "readonly",
},
},
},
{
- files: ['**/*.vue'],
+ files: ["**/*.vue"],
rules: {
- 'no-undef': 'off',
+ "no-undef": "off",
},
},
{
- files: ['env.d.ts'],
+ files: ["env.d.ts"],
rules: {
- '@typescript-eslint/no-empty-object-type': 'off',
- '@typescript-eslint/no-explicit-any': 'off',
+ "@typescript-eslint/no-empty-object-type": "off",
+ "@typescript-eslint/no-explicit-any": "off",
},
},
{
rules: {
- 'vue/max-attributes-per-line': 'off',
- 'vue/singleline-html-element-content-newline': 'off',
- 'vue/html-self-closing': 'off',
- 'vue/attributes-order': 'off',
+ "vue/max-attributes-per-line": "off",
+ "vue/singleline-html-element-content-newline": "off",
+ "vue/html-self-closing": "off",
+ "vue/attributes-order": "off",
},
},
{
- ignores: ['dist/', 'node_modules/'],
+ ignores: ["dist/", "node_modules/", "public/"],
},
-)
+);
diff --git a/frontend/index.html b/frontend/index.html
index c9d0907f..90a40b82 100755
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -2,8 +2,15 @@
-
- Sunset Beach
+
+
+
+
+
+
+
+
+ MomShell
diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg
new file mode 100644
index 00000000..2e49bee5
--- /dev/null
+++ b/frontend/public/favicon.svg
@@ -0,0 +1,5 @@
+
diff --git a/frontend/public/icons/icon-192.png b/frontend/public/icons/icon-192.png
new file mode 100644
index 00000000..3e24fc01
--- /dev/null
+++ b/frontend/public/icons/icon-192.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:eea6cc229d1ba3abe94d5cf9c463625a624a21dbaa604c159944c231031ebd6d
+size 594
diff --git a/frontend/public/icons/icon-512.png b/frontend/public/icons/icon-512.png
new file mode 100644
index 00000000..7ee17b73
--- /dev/null
+++ b/frontend/public/icons/icon-512.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:700d233f5cfac21820e5e46df1dd856f4082ec609cf2b8b86a51e37560e2a935
+size 2201
diff --git a/frontend/public/icons/icon-maskable-512.png b/frontend/public/icons/icon-maskable-512.png
new file mode 100644
index 00000000..7ee17b73
--- /dev/null
+++ b/frontend/public/icons/icon-maskable-512.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:700d233f5cfac21820e5e46df1dd856f4082ec609cf2b8b86a51e37560e2a935
+size 2201
diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json
new file mode 100644
index 00000000..bc75f10d
--- /dev/null
+++ b/frontend/public/manifest.json
@@ -0,0 +1,15 @@
+{
+ "name": "MomShell - 贝壳回响",
+ "short_name": "MomShell",
+ "description": "亲密关系成长平台",
+ "start_url": "/",
+ "display": "standalone",
+ "orientation": "any",
+ "background_color": "#f5e0c8",
+ "theme_color": "#f2b8c8",
+ "icons": [
+ { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
+ { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
+ { "src": "/icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
+ ]
+}
diff --git a/frontend/public/sw.js b/frontend/public/sw.js
new file mode 100644
index 00000000..00c26b57
--- /dev/null
+++ b/frontend/public/sw.js
@@ -0,0 +1,31 @@
+/* eslint-disable no-undef */
+const CACHE_NAME = "momshell-v1";
+const PRECACHE_URLS = ["/", "/index.html"];
+
+self.addEventListener("install", (event) => {
+ event.waitUntil(
+ caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE_URLS)),
+ );
+ self.skipWaiting();
+});
+
+self.addEventListener("activate", (event) => {
+ event.waitUntil(
+ caches
+ .keys()
+ .then((keys) =>
+ Promise.all(
+ keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)),
+ ),
+ ),
+ );
+ self.clients.claim();
+});
+
+self.addEventListener("fetch", (event) => {
+ if (event.request.mode === "navigate") {
+ event.respondWith(
+ fetch(event.request).catch(() => caches.match("/index.html")),
+ );
+ }
+});
diff --git a/frontend/src/components/overlay/AiMemoryPanel.vue b/frontend/src/components/overlay/AiMemoryPanel.vue
index 03465040..802edc3a 100644
--- a/frontend/src/components/overlay/AiMemoryPanel.vue
+++ b/frontend/src/components/overlay/AiMemoryPanel.vue
@@ -578,4 +578,33 @@ async function onClearHistory() {
background: rgba(255, 255, 255, 0.14);
color: var(--text-primary);
}
+
+/* ── Mobile ── */
+@media (max-width: 768px) {
+ .ai-memory-panel {
+ padding: 24px 16px 20px;
+ }
+
+ .panel-title {
+ font-size: 20px;
+ }
+
+ .facts-list {
+ max-height: 55dvh;
+ max-height: 55vh;
+ }
+
+ .fact-card {
+ padding: 12px;
+ }
+
+ .fact-delete {
+ min-width: 44px;
+ min-height: 44px;
+ }
+
+ .fact-text {
+ font-size: 13px;
+ }
+}
diff --git a/frontend/src/components/overlay/AuthPanel.vue b/frontend/src/components/overlay/AuthPanel.vue
index 9e8eecc7..36766847 100644
--- a/frontend/src/components/overlay/AuthPanel.vue
+++ b/frontend/src/components/overlay/AuthPanel.vue
@@ -271,4 +271,26 @@ async function onRegister() {
font-size: 13px;
line-height: 1.6;
}
+
+/* ── Mobile ── */
+@media (max-width: 768px) {
+ .auth-panel {
+ padding: 24px 16px 20px;
+ }
+
+ .auth-input {
+ font-size: 16px;
+ padding: 14px 16px;
+ }
+
+ .auth-tab {
+ font-size: 13px;
+ min-height: 44px;
+ }
+
+ .auth-submit {
+ min-height: 48px;
+ font-size: 16px;
+ }
+}
diff --git a/frontend/src/components/overlay/BarPage.vue b/frontend/src/components/overlay/BarPage.vue
index 1f3ecf1f..2449c7df 100644
--- a/frontend/src/components/overlay/BarPage.vue
+++ b/frontend/src/components/overlay/BarPage.vue
@@ -290,6 +290,7 @@
diff --git a/frontend/src/components/scene/NavBar.vue b/frontend/src/components/scene/NavBar.vue
index 9be6effd..b73abf3e 100644
--- a/frontend/src/components/scene/NavBar.vue
+++ b/frontend/src/components/scene/NavBar.vue
@@ -254,6 +254,28 @@ const showNav = computed(() => {
opacity: 0;
transform: translateY(-8px) scale(0.95);
}
+
+/* ── Mobile ── */
+@media (max-width: 768px) {
+ .nav-toggle {
+ width: 44px;
+ height: 44px;
+ }
+
+ .nav-item {
+ padding: 12px 16px;
+ min-height: 44px;
+ }
+
+ .nav-label {
+ font-size: 14px;
+ }
+
+ .nav-icon {
+ width: 22px;
+ height: 22px;
+ }
+}
diff --git a/frontend/src/components/scene/SpritesLayer.vue b/frontend/src/components/scene/SpritesLayer.vue
index 8c27b0cb..cc58257a 100644
--- a/frontend/src/components/scene/SpritesLayer.vue
+++ b/frontend/src/components/scene/SpritesLayer.vue
@@ -5,12 +5,7 @@
:key="s.id"
:id="`sprite-${s.id}`"
:class="['sprite-wrapper', { clickable: isSpriteClickable(s.id) }]"
- :style="{
- left: s.left,
- top: s.top,
- width: s.width,
- zIndex: s.zIndex ?? undefined,
- }"
+ :style="spriteStyle(s)"
>
- {{ s.label }}
+ {{ s.label }}
@@ -50,9 +42,28 @@ import { LAYERS } from '@/constants/layers'
import { SPRITES } from '@/constants/sprites'
import { useUiStore } from '@/stores/ui'
import { useAuthStore } from '@/stores/auth'
+import { useIsMobile } from '@/composables/useIsMobile'
import { createSeededRandom } from '@/utils/random'
const rand = createSeededRandom(400)
+const { isMobile, isLandscape } = useIsMobile()
+
+function spriteStyle(s: typeof SPRITES[number]) {
+ const m = isLandscape.value ? s.landscape : isMobile.value ? s.mobile : undefined
+ return {
+ left: m?.left ?? s.left,
+ top: m?.top ?? s.top,
+ width: m?.width ?? s.width,
+ zIndex: s.zIndex ?? undefined,
+ }
+}
+
+function labelStyle(s: typeof SPRITES[number]) {
+ return {
+ fontSize: (isMobile.value || isLandscape.value) ? undefined : (s.labelSize ?? undefined),
+ marginTop: s.labelOffsetY ?? undefined,
+ }
+}
const SHARED_HINTS: readonly string[] = [
'想聊聊心事,就点那块石头呀。',
@@ -78,7 +89,7 @@ const DAD_HINTS: readonly string[] = [
const layerEl = ref(null)
const bubbleLayerEl = ref(null)
-const crabSpriteEl = ref(null)
+const crabSpriteEl = ref(null)
const ctx = inject(PARALLAX_KEY)!
const uiStore = useUiStore()
const authStore = useAuthStore()
@@ -106,7 +117,7 @@ function setSpriteEl(id: string, element: Element | ComponentPublicInstance | nu
return
}
- crabSpriteEl.value = element instanceof HTMLImageElement ? element : null
+ crabSpriteEl.value = element instanceof HTMLElement ? element : null
}
function updateCrabBubblePosition() {
@@ -247,6 +258,17 @@ onUnmounted(() => {
user-select: none;
}
+@media (max-width: 768px) {
+ .sprite-wrapper.clickable {
+ min-width: 44px;
+ min-height: 44px;
+ }
+
+ .sprite-label {
+ font-size: 0.7rem;
+ }
+}
+
.speech-bubble {
position: absolute;
background: rgba(255, 252, 246, 0.96);
@@ -257,12 +279,22 @@ onUnmounted(() => {
border-radius: 1.1em;
white-space: nowrap;
width: max-content;
+ max-width: 80vw;
pointer-events: none;
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.16);
border: 1px solid rgba(255, 214, 170, 0.7);
z-index: 1;
}
+@media (max-width: 768px) {
+ .speech-bubble {
+ white-space: normal;
+ width: auto;
+ max-width: min(280px, 70vw);
+ font-size: 0.85rem;
+ }
+}
+
.crab-bubble {
--bubble-shift-x: -10%;
--bubble-shift-y: calc(-100% - 0.5rem);
diff --git a/frontend/src/composables/useIsMobile.ts b/frontend/src/composables/useIsMobile.ts
new file mode 100644
index 00000000..61dfedfa
--- /dev/null
+++ b/frontend/src/composables/useIsMobile.ts
@@ -0,0 +1,39 @@
+import { ref, onMounted, onUnmounted } from "vue";
+
+export function useIsMobile() {
+ const isMobile = ref(false);
+ const isSmall = ref(false);
+ const isLandscape = ref(false);
+
+ function update() {
+ isMobile.value = window.matchMedia("(max-width: 768px)").matches;
+ isSmall.value = window.matchMedia("(max-width: 480px)").matches;
+ isLandscape.value = window.matchMedia(
+ "(max-height: 500px) and (orientation: landscape)",
+ ).matches;
+ }
+
+ let mql768: MediaQueryList;
+ let mql480: MediaQueryList;
+ let mqlLandscape: MediaQueryList;
+
+ onMounted(() => {
+ mql768 = window.matchMedia("(max-width: 768px)");
+ mql480 = window.matchMedia("(max-width: 480px)");
+ mqlLandscape = window.matchMedia(
+ "(max-height: 500px) and (orientation: landscape)",
+ );
+ update();
+ mql768.addEventListener("change", update);
+ mql480.addEventListener("change", update);
+ mqlLandscape.addEventListener("change", update);
+ });
+
+ onUnmounted(() => {
+ mql768?.removeEventListener("change", update);
+ mql480?.removeEventListener("change", update);
+ mqlLandscape?.removeEventListener("change", update);
+ });
+
+ return { isMobile, isSmall, isLandscape };
+}
diff --git a/frontend/src/composables/useParallax.ts b/frontend/src/composables/useParallax.ts
index 0f1814ba..7a4947d7 100644
--- a/frontend/src/composables/useParallax.ts
+++ b/frontend/src/composables/useParallax.ts
@@ -41,7 +41,9 @@ export function useParallax() {
function recalcParallax() {
const vw = window.innerWidth;
- maxOffset.value = vw * 1.2;
+ const isPortraitMobile =
+ vw <= 768 && window.innerHeight > window.innerWidth;
+ maxOffset.value = vw * (isPortraitMobile ? 1.5 : 1.2);
layerMeta.forEach((m) => {
m.centerShift = -(m.el.offsetWidth - vw) / 2;
});
@@ -190,6 +192,21 @@ export function useParallax() {
}
let resizeRaf = 0;
+ let orientationTimer = 0;
+
+ function onResize() {
+ cancelAnimationFrame(resizeRaf);
+ resizeRaf = requestAnimationFrame(recalcParallax);
+ }
+
+ function onOrientationChange() {
+ clearTimeout(orientationTimer);
+ orientationTimer = window.setTimeout(() => {
+ recalcParallax();
+ applyParallax();
+ startLoop();
+ }, 150);
+ }
onMounted(() => {
recalcParallax();
@@ -205,10 +222,8 @@ export function useParallax() {
document.addEventListener("touchstart", onTouchStart, { passive: true });
document.addEventListener("touchmove", onTouchMove, { passive: true });
document.addEventListener("wheel", onWheel, { passive: false });
- window.addEventListener("resize", () => {
- cancelAnimationFrame(resizeRaf);
- resizeRaf = requestAnimationFrame(recalcParallax);
- });
+ window.addEventListener("resize", onResize);
+ window.addEventListener("orientationchange", onOrientationChange);
setTimeout(() => {
hintHidden.value = true;
@@ -225,6 +240,9 @@ export function useParallax() {
document.removeEventListener("touchstart", onTouchStart);
document.removeEventListener("touchmove", onTouchMove);
document.removeEventListener("wheel", onWheel);
+ window.removeEventListener("resize", onResize);
+ window.removeEventListener("orientationchange", onOrientationChange);
+ clearTimeout(orientationTimer);
});
function wasDrag(): boolean {
diff --git a/frontend/src/constants/sprites.ts b/frontend/src/constants/sprites.ts
index ad1ac9f2..ac8c8da8 100644
--- a/frontend/src/constants/sprites.ts
+++ b/frontend/src/constants/sprites.ts
@@ -1,40 +1,127 @@
export interface SpriteData {
- id: string
- src: string
- left: string
- top: string
- width: string
- rotate?: number
- scaleX?: number
- scaleY?: number
- zIndex?: number
- label?: string
- labelSize?: string // e.g. '1rem', '0.9rem'
- labelOffsetY?: string // e.g. '-20%', '4px'
+ id: string;
+ src: string;
+ left: string;
+ top: string;
+ width: string;
+ rotate?: number;
+ scaleX?: number;
+ scaleY?: number;
+ zIndex?: number;
+ label?: string;
+ labelSize?: string; // e.g. '1rem', '0.9rem'
+ labelOffsetY?: string; // e.g. '-20%', '4px'
+ /** Override position/size on mobile (max-width: 768px) */
+ mobile?: {
+ left?: string;
+ top?: string;
+ width?: string;
+ };
+ /** Override position/size on mobile landscape (max-height: 500px, orientation: landscape) */
+ landscape?: {
+ left?: string;
+ top?: string;
+ width?: string;
+ };
}
-import carImg from '@/assets/images/car.png'
-import barImg from '@/assets/images/bar.png'
-import stoneImg from '@/assets/images/stone.png'
-import crabImg from '@/assets/images/crab.png'
-import shellImg from '@/assets/images/shell.png'
-import chairImg from '@/assets/images/chairs.png'
-import mailboxImg from '@/assets/images/mailbox.png'
+import carImg from "@/assets/images/car.png";
+import barImg from "@/assets/images/bar.png";
+import stoneImg from "@/assets/images/stone.png";
+import crabImg from "@/assets/images/crab.png";
+import shellImg from "@/assets/images/shell.png";
+import chairImg from "@/assets/images/chairs.png";
+import mailboxImg from "@/assets/images/mailbox.png";
export const SPRITES: SpriteData[] = [
+ {
+ id: "car",
+ src: carImg,
+ left: "40%",
+ top: "-2.5%",
+ width: "50vw",
+ label: "个人中心",
+ labelSize: "1.2rem",
+ labelOffsetY: "-7%",
+ mobile: { width: "60vw", left: "38%", top: "26%" },
+ landscape: { width: "40vw", left: "38%", top: "-8%" },
+ },
- { id: 'car', src: carImg, left: '40%', top: '-2.5%', width: '50vw', label: '个人中心', labelSize: '1.2rem', labelOffsetY: '-7%' },
+ {
+ id: "bar",
+ src: barImg,
+ left: "24%",
+ top: "-15%",
+ width: "60vw",
+ label: "智育社区",
+ labelSize: "1.2rem",
+ labelOffsetY: "-9%",
+ mobile: { width: "70vw", left: "21%", top: "22%" },
+ landscape: { width: "45vw", left: "26%", top: "-15%" },
+ },
- { id: 'bar', src: barImg, left: '24%', top: '-15%', width: '60vw', label: '智育社区', labelSize: '1.2rem', labelOffsetY: '-9%' },
+ {
+ id: "stone",
+ src: stoneImg,
+ left: "68%",
+ top: "20%",
+ width: "30vw",
+ label: "智聊助手",
+ labelSize: "1.2rem",
+ labelOffsetY: "-18%",
+ mobile: { width: "45vw", top: "26%", left: "71%" },
+ landscape: { width: "25vw", left: "66%", top: "5%" },
+ },
- { id: 'stone', src: stoneImg, left: '68%', top: '20%', width: '30vw', label: '智聊助手', labelSize: '1.2rem', labelOffsetY: '-18%' },
+ {
+ id: "crab",
+ src: crabImg,
+ left: "55%",
+ top: "12%",
+ width: "6vw",
+ zIndex: 10,
+ mobile: { width: "8vw", left: "49%", top: "18%" },
+ landscape: { width: "6vw", left: "53%", top: "10%" },
+ },
- { id: 'crab', src: crabImg, left: '55%', top: '12%', width: '6vw', zIndex: 10 },
+ {
+ id: "shell",
+ src: shellImg,
+ left: "50.75%",
+ top: "60%",
+ width: "5vw",
+ rotate: 0,
+ zIndex: 10,
+ label: "生成相片",
+ labelSize: "1.2rem",
+ mobile: { width: "12vw", left: "52%", top: "43%" },
+ landscape: { width: "6vw", left: "48%", top: "53%" },
+ },
- { id: 'shell', src: shellImg, left: '50.75%', top: '60%', width: '5vw', rotate: 0, zIndex: 10, label: '生成相片', labelSize: '1.2rem' },
+ {
+ id: "chair",
+ src: chairImg,
+ left: "56%",
+ top: "2.5%",
+ width: "40vw",
+ rotate: 0,
+ label: "同频任务",
+ labelSize: "1.2rem",
+ labelOffsetY: "-6%",
+ mobile: { width: "40vw", left: "60%", top: "30%" },
+ landscape: { width: "30vw", left: "56%", top: "-3%" },
+ },
- { id: 'chair', src: chairImg, left: '56%', top: '2.5%', width: '40vw', rotate: 0, label: '同频任务', labelSize: '1.2rem', labelOffsetY: '-6%' },
-
- { id: 'mailbox', src: mailboxImg, left: '51.5%', top: '22%', width: '12vw', rotate: 0, label: '心愿签', labelSize: '1.2rem' },
-
-]
+ {
+ id: "mailbox",
+ src: mailboxImg,
+ left: "51.5%",
+ top: "22%",
+ width: "12vw",
+ rotate: 0,
+ label: "心愿签",
+ labelSize: "1.2rem",
+ mobile: { width: "15vw", left: "56%", top: "31%" },
+ landscape: { width: "8vw", left: "50%", top: "15%" },
+ },
+];
diff --git a/frontend/src/main.ts b/frontend/src/main.ts
index 725c0635..67cffa59 100644
--- a/frontend/src/main.ts
+++ b/frontend/src/main.ts
@@ -5,7 +5,12 @@ import App from "./App.vue";
import "./styles/reset.css";
import "./styles/variables.css";
import "./styles/animations.css";
+import "./styles/mobile.css";
const app = createApp(App);
app.use(createPinia());
app.mount("#app");
+
+if (import.meta.env.PROD && "serviceWorker" in navigator) {
+ navigator.serviceWorker.register("/sw.js").catch(() => {});
+}
diff --git a/frontend/src/styles/mobile.css b/frontend/src/styles/mobile.css
new file mode 100644
index 00000000..6bb819a3
--- /dev/null
+++ b/frontend/src/styles/mobile.css
@@ -0,0 +1,41 @@
+/* ── Global mobile overrides ── */
+
+@media (max-width: 768px) {
+ body {
+ overscroll-behavior: none;
+ -webkit-overflow-scrolling: touch;
+ }
+
+ .scene {
+ touch-action: pan-x;
+ }
+
+ /* Prevent any accidental overflow causing gray margins */
+ html, body, #app {
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
+ }
+}
+
+/* ── Portrait orientation on mobile ── */
+@media (max-width: 768px) and (orientation: portrait) {
+ .scene {
+ height: 100vh;
+ height: 100dvh;
+ }
+}
+
+/* ── Landscape orientation on mobile ── */
+@media (max-height: 500px) and (orientation: landscape) {
+ .scene {
+ height: 100vh;
+ height: 100dvh;
+ }
+
+ /* NavBar needs to be more compact in landscape */
+ .nav-wrapper {
+ top: 8px;
+ right: 8px;
+ }
+}
diff --git a/frontend/src/styles/reset.css b/frontend/src/styles/reset.css
index 06ccb246..e811f745 100644
--- a/frontend/src/styles/reset.css
+++ b/frontend/src/styles/reset.css
@@ -14,6 +14,13 @@ html, body {
background: #f5e0c8;
}
+#app {
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
+ position: relative;
+}
+
body.dragging {
cursor: grabbing;
}