Compare commits

..

6 Commits

Author SHA1 Message Date
BOHA
5b380863cf chore(release): v2.4.20 - standalone PWA viewport fix
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 08:48:57 +02:00
BOHA
1293da7f25 fix(odin): measured --app-height for standalone-PWA viewports
Installed PWAs (MIUI especially) compute svh/dvh against a viewport that
includes system UI the layout viewport doesn't have, so the immersive page
still scrolled in standalone mode. AppShell now measures window.innerHeight
into --app-height (refreshed on resize/visualViewport, covering the
minimize-restore stale-viewport Chrome bug and the keyboard), and the
immersive shell + chat size from var(--app-height, 100svh).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 08:48:57 +02:00
BOHA
d9cf8f53e8 chore(release): v2.4.19 - immersive mobile scroll lock
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 08:42:04 +02:00
BOHA
f509e24942 fix(odin): kill body scroll on immersive mobile (MIUI dvh quirk)
Xiaomi Chrome keeps 100dvh at the large-viewport size while the URL bar
overlays the page, so the shell's 100dvh min-height left the document one
bar-height taller than the screen (scrollable + a strip under the
composer). The immersive route now sizes the document to exactly 100svh
with hidden overflow down the chain — body scroll is impossible regardless
of the browser's dvh interpretation. Other routes unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 08:42:04 +02:00
BOHA
4159ae57a4 chore(release): v2.4.18 - Odin animations + mobile fullscreen
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 08:31:28 +02:00
BOHA
4c8ed39d85 feat(odin): chat animations + immersive fullscreen mobile view
- bubbles spring in anchored at their avatar corner; thinking state is a
  typing bubble (three bouncing dots) that springs in/out where the reply
  lands; reduced-motion falls back to opacity fades
- mobile /odin is immersive: AppShell hides its header and main padding
  below md, the chat owns 100svh full-bleed with safe-area insets and a
  back arrow (history back, home on deep links)
- desktop card border kept divider-colored via longhand border props (a
  responsive border shorthand reset the color to black)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 08:31:28 +02:00
5 changed files with 201 additions and 37 deletions

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "app-ts",
"version": "2.4.17",
"version": "2.4.20",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "app-ts",
"version": "2.4.17",
"version": "2.4.20",
"license": "ISC",
"dependencies": {
"@anthropic-ai/sdk": "^0.102.0",

View File

@@ -1,6 +1,6 @@
{
"name": "app-ts",
"version": "2.4.17",
"version": "2.4.20",
"description": "",
"main": "dist/server.js",
"scripts": {

View File

@@ -1,4 +1,5 @@
import { useState, useRef, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
@@ -109,6 +110,17 @@ export default function OdinChat() {
});
const [sidebarOpen, setSidebarOpen] = useState(false);
// Mobile is immersive (no AppShell header on /odin) — this is the only way
// back. A deep link straight to /odin has no in-app history → go home.
const navigate = useNavigate();
const goBack = () => {
if (((window.history.state as { idx?: number } | null)?.idx ?? 0) > 0) {
navigate(-1);
} else {
navigate("/");
}
};
// No auto-select: like claude.ai, we land on a fresh "new chat" (activeId
// null) and the sidebar lists existing conversations to open. A conversation
// row in the DB is created lazily on the first message (see submit), so
@@ -424,11 +436,21 @@ export default function OdinChat() {
// browser-chrome height. svh sizes for the bar-visible viewport — the
// page never scrolls and the chat's message list scrolls internally.
// Desktop: svh === vh.
height: "calc(100svh - 100px)",
// Mobile is immersive (AppShell hides its header and main padding on
// /odin): the chat owns the whole viewport, full-bleed. --app-height
// is AppShell's live window.innerHeight measurement — standalone-PWA
// viewports misreport svh/dvh (MIUI), only the measured value fits.
height: {
xs: "var(--app-height, 100svh)",
md: "calc(100svh - 100px)",
},
display: "flex",
border: 1,
// Longhand on purpose: a responsive `border` shorthand lands in a
// media query AFTER borderColor and resets the color to black.
borderStyle: "solid",
borderWidth: { xs: 0, md: 1 },
borderColor: "divider",
borderRadius: 3,
borderRadius: { xs: 0, md: 3 },
bgcolor: "background.paper",
overflow: "hidden",
}}
@@ -454,15 +476,41 @@ export default function OdinChat() {
flexDirection: "column",
gap: 1.5,
p: 2,
// Immersive mobile: respect the notch / home-indicator insets
// (env() is 0 outside standalone mode, so max() keeps the 16px).
pt: { xs: "max(16px, env(safe-area-inset-top))", md: 2 },
pb: { xs: "max(16px, env(safe-area-inset-bottom))", md: 2 },
}}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
{isMobile && (
<IconButton
onClick={goBack}
aria-label="Zpět"
size="small"
sx={{ ml: -0.5, flexShrink: 0 }}
>
<Box
component="svg"
viewBox="0 0 24 24"
sx={{ width: 22, height: 22 }}
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="19" y1="12" x2="5" y2="12" />
<polyline points="12 19 5 12 12 5" />
</Box>
</IconButton>
)}
{isMobile && (
<IconButton
onClick={() => setSidebarOpen(true)}
aria-label="Konverzace"
size="small"
sx={{ ml: -0.5, flexShrink: 0 }}
sx={{ flexShrink: 0 }}
>
<Box
component="svg"

View File

@@ -1,7 +1,12 @@
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import Chip from "@mui/material/Chip";
import { motion, useReducedMotion, type Variants } from "framer-motion";
import {
motion,
AnimatePresence,
useReducedMotion,
type Variants,
} from "framer-motion";
import type { ChatTurn } from "./types";
import OdinMark from "./OdinMark";
@@ -150,12 +155,17 @@ export default function OdinThread({
return (
<MotionBox
key={i}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: reduce ? 0.3 : 0.34,
ease: [0.16, 1, 0.3, 1],
}}
initial={
reduce
? { opacity: 0 }
: { opacity: 0, y: 14, scale: 0.9, x: isUser ? 12 : -12 }
}
animate={{ opacity: 1, y: 0, scale: 1, x: 0 }}
transition={
reduce
? { duration: 0.3 }
: { type: "spring", stiffness: 460, damping: 32, mass: 0.7 }
}
sx={{
display: "flex",
flexDirection: "row",
@@ -163,6 +173,9 @@ export default function OdinThread({
gap: 1,
alignSelf: isUser ? "flex-end" : "flex-start",
maxWidth: "80%",
// The pop grows out of the corner where the bubble is anchored
// (next to the avatar / the composer side), not from its centre.
transformOrigin: isUser ? "bottom right" : "bottom left",
}}
>
{!isUser && <OdinMark size={28} />}
@@ -218,25 +231,79 @@ export default function OdinThread({
);
})}
{/* Busy indicator — same 28px mark and zero row padding as the bubble
rows, so the thinking mark sits exactly in the avatar column. */}
{busy && (
<Box
sx={{
alignSelf: "flex-start",
display: "flex",
alignItems: "center",
gap: 1,
py: 1,
color: "text.secondary",
}}
>
<OdinMark size={28} state="thinking" />
<Typography variant="caption" sx={{ color: "inherit" }}>
Pracuji
</Typography>
</Box>
)}
{/* Busy indicator — a typing bubble (three bouncing dots) anchored in
the avatar column, springing in/out where the reply will land. */}
<AnimatePresence>
{busy && (
<MotionBox
key="busy"
initial={
reduce ? { opacity: 0 } : { opacity: 0, y: 10, scale: 0.85 }
}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={
reduce
? { opacity: 0, transition: { duration: 0.15 } }
: {
opacity: 0,
scale: 0.85,
transition: { duration: 0.16, ease: "easeIn" },
}
}
transition={
reduce
? { duration: 0.3 }
: { type: "spring", stiffness: 460, damping: 32, mass: 0.7 }
}
sx={{
alignSelf: "flex-start",
display: "flex",
alignItems: "flex-end",
gap: 1,
transformOrigin: "bottom left",
}}
>
<OdinMark size={28} state="thinking" />
<Box
role="status"
aria-label="Odin pracuje"
sx={{
px: 1.5,
py: 1.5,
borderRadius: 2,
boxShadow: 1,
bgcolor: "background.paper",
display: "flex",
alignItems: "center",
gap: 0.6,
}}
>
{[0, 1, 2].map((i) => (
<MotionBox
key={i}
animate={
reduce
? { opacity: [0.35, 1, 0.35] }
: { y: [0, -4, 0], opacity: [0.35, 1, 0.35] }
}
transition={{
duration: 0.9,
repeat: Infinity,
delay: i * 0.15,
ease: "easeInOut",
}}
sx={{
width: 6,
height: 6,
borderRadius: "50%",
bgcolor: "text.secondary",
}}
/>
))}
</Box>
</MotionBox>
)}
</AnimatePresence>
</Box>
);
}

View File

@@ -23,6 +23,32 @@ export default function AppShell() {
undefined,
);
// Chat-style pages own the full mobile viewport: under md the shell header
// disappears and main loses its padding — the page brings its own back
// button. Desktop keeps the normal shell.
const immersiveOnMobile = location.pathname === "/odin";
// Installed-PWA (standalone) viewports lie to CSS units: on MIUI/Android
// svh/dvh are computed against a viewport that includes system UI the real
// layout viewport doesn't have, leaving scroll room no unit can remove
// (and Chrome can serve a stale viewport after minimize/restore). Measure
// the truth — window.innerHeight — into --app-height and keep it fresh;
// immersive layouts size from it with an svh fallback.
useEffect(() => {
if (!immersiveOnMobile) return;
const root = document.documentElement;
const set = () =>
root.style.setProperty("--app-height", `${window.innerHeight}px`);
set();
window.addEventListener("resize", set);
window.visualViewport?.addEventListener("resize", set);
return () => {
window.removeEventListener("resize", set);
window.visualViewport?.removeEventListener("resize", set);
root.style.removeProperty("--app-height");
};
}, [immersiveOnMobile]);
const handleLogout = useCallback(() => {
setLoggingOut(true);
setMobileOpen(false);
@@ -74,13 +100,22 @@ export default function AppShell() {
duration: loggingOut ? 0.4 : 0.25,
ease: [0.4, 0, 0.2, 1],
}}
style={{ minHeight: "100dvh" }}
>
<Box
sx={{
display: "flex",
minHeight: "100dvh",
bgcolor: "background.default",
// Immersive mobile pages must make the DOCUMENT exactly the
// small-viewport height and clip it: MIUI/Xiaomi Chrome keeps
// 100dvh at the large-viewport size while the URL bar overlays,
// so a 100dvh min-height leaves the page scrollable by the bar
// height (invisible in desktop emulation). svh + hidden overflow
// makes body scroll impossible regardless of dvh interpretation.
minHeight: immersiveOnMobile ? { xs: 0, md: "100dvh" } : "100dvh",
...(immersiveOnMobile && {
height: { xs: "var(--app-height, 100svh)", md: "auto" },
overflow: { xs: "hidden", md: "visible" },
}),
}}
>
<Drawer
@@ -123,6 +158,7 @@ export default function AppShell() {
sx={{
flex: 1,
minWidth: 0,
minHeight: 0,
display: "flex",
flexDirection: "column",
}}
@@ -130,7 +166,9 @@ export default function AppShell() {
<Box
component="header"
sx={{
display: "flex",
display: immersiveOnMobile
? { xs: "none", md: "flex" }
: "flex",
alignItems: "center",
gap: 1,
px: 2,
@@ -160,7 +198,18 @@ export default function AppShell() {
<Box sx={{ flex: 1 }} />
<ThemeToggle />
</Box>
<Box component="main" sx={{ flex: 1, px: { xs: 2, md: 3 }, pb: 4 }}>
<Box
component="main"
sx={{
flex: 1,
px: immersiveOnMobile ? { xs: 0, md: 3 } : { xs: 2, md: 3 },
pb: immersiveOnMobile ? { xs: 0, md: 4 } : 4,
...(immersiveOnMobile && {
minHeight: 0,
overflow: { xs: "hidden", md: "visible" },
}),
}}
>
<Outlet />
</Box>
</Box>