Compare commits

...

8 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
BOHA
06519d521f chore(release): v2.4.17 - Odin avatar polish
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 08:20:15 +02:00
BOHA
3c6d175857 fix(odin): brand mark as bubble avatar + aligned thinking indicator
Assistant bubbles use the animated OdinMark instead of the plain 'O'
circle; the 'Pracuji…' row drops its horizontal padding and matches the
28px mark size, so the thinking mark sits exactly in the avatar column.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 08:20:15 +02:00
5 changed files with 202 additions and 61 deletions

4
package-lock.json generated
View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import { useState, useRef, useEffect } from "react"; import { useState, useRef, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
@@ -109,6 +110,17 @@ export default function OdinChat() {
}); });
const [sidebarOpen, setSidebarOpen] = useState(false); 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 // 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 // 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 // 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 // browser-chrome height. svh sizes for the bar-visible viewport — the
// page never scrolls and the chat's message list scrolls internally. // page never scrolls and the chat's message list scrolls internally.
// Desktop: svh === vh. // 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", 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", borderColor: "divider",
borderRadius: 3, borderRadius: { xs: 0, md: 3 },
bgcolor: "background.paper", bgcolor: "background.paper",
overflow: "hidden", overflow: "hidden",
}} }}
@@ -454,15 +476,41 @@ export default function OdinChat() {
flexDirection: "column", flexDirection: "column",
gap: 1.5, gap: 1.5,
p: 2, 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 }}> <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 && ( {isMobile && (
<IconButton <IconButton
onClick={() => setSidebarOpen(true)} onClick={() => setSidebarOpen(true)}
aria-label="Konverzace" aria-label="Konverzace"
size="small" size="small"
sx={{ ml: -0.5, flexShrink: 0 }} sx={{ flexShrink: 0 }}
> >
<Box <Box
component="svg" component="svg"

View File

@@ -1,7 +1,12 @@
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import Chip from "@mui/material/Chip"; 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 type { ChatTurn } from "./types";
import OdinMark from "./OdinMark"; import OdinMark from "./OdinMark";
@@ -30,29 +35,6 @@ interface OdinThreadProps {
threadRef: React.RefObject<HTMLDivElement | null>; threadRef: React.RefObject<HTMLDivElement | null>;
} }
/** Small Odin avatar shown to the left of assistant bubbles. */
function OdinAvatar({ size = 28 }: { size?: number }) {
return (
<Box
sx={{
width: size,
height: size,
borderRadius: "50%",
bgcolor: "primary.main",
color: "common.white",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontWeight: 700,
fontSize: size * 0.5,
flexShrink: 0,
}}
>
O
</Box>
);
}
export default function OdinThread({ export default function OdinThread({
turns, turns,
busy, busy,
@@ -173,12 +155,17 @@ export default function OdinThread({
return ( return (
<MotionBox <MotionBox
key={i} key={i}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 10 }} initial={
animate={{ opacity: 1, y: 0 }} reduce
transition={{ ? { opacity: 0 }
duration: reduce ? 0.3 : 0.34, : { opacity: 0, y: 14, scale: 0.9, x: isUser ? 12 : -12 }
ease: [0.16, 1, 0.3, 1], }
}} animate={{ opacity: 1, y: 0, scale: 1, x: 0 }}
transition={
reduce
? { duration: 0.3 }
: { type: "spring", stiffness: 460, damping: 32, mass: 0.7 }
}
sx={{ sx={{
display: "flex", display: "flex",
flexDirection: "row", flexDirection: "row",
@@ -186,9 +173,12 @@ export default function OdinThread({
gap: 1, gap: 1,
alignSelf: isUser ? "flex-end" : "flex-start", alignSelf: isUser ? "flex-end" : "flex-start",
maxWidth: "80%", 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 && <OdinAvatar size={28} />} {!isUser && <OdinMark size={28} />}
<Box <Box
sx={{ sx={{
px: 1.5, px: 1.5,
@@ -241,25 +231,79 @@ export default function OdinThread({
); );
})} })}
{/* Busy indicator */} {/* Busy indicator — a typing bubble (three bouncing dots) anchored in
{busy && ( the avatar column, springing in/out where the reply will land. */}
<Box <AnimatePresence>
sx={{ {busy && (
alignSelf: "flex-start", <MotionBox
display: "flex", key="busy"
alignItems: "center", initial={
gap: 1, reduce ? { opacity: 0 } : { opacity: 0, y: 10, scale: 0.85 }
px: 1.5, }
py: 1, animate={{ opacity: 1, y: 0, scale: 1 }}
color: "text.secondary", exit={
}} reduce
> ? { opacity: 0, transition: { duration: 0.15 } }
<OdinMark size={22} state="thinking" /> : {
<Typography variant="caption" sx={{ color: "inherit" }}> opacity: 0,
Pracuji scale: 0.85,
</Typography> transition: { duration: 0.16, ease: "easeIn" },
</Box> }
)} }
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> </Box>
); );
} }

View File

@@ -23,6 +23,32 @@ export default function AppShell() {
undefined, 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(() => { const handleLogout = useCallback(() => {
setLoggingOut(true); setLoggingOut(true);
setMobileOpen(false); setMobileOpen(false);
@@ -74,13 +100,22 @@ export default function AppShell() {
duration: loggingOut ? 0.4 : 0.25, duration: loggingOut ? 0.4 : 0.25,
ease: [0.4, 0, 0.2, 1], ease: [0.4, 0, 0.2, 1],
}} }}
style={{ minHeight: "100dvh" }}
> >
<Box <Box
sx={{ sx={{
display: "flex", display: "flex",
minHeight: "100dvh",
bgcolor: "background.default", 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 <Drawer
@@ -123,6 +158,7 @@ export default function AppShell() {
sx={{ sx={{
flex: 1, flex: 1,
minWidth: 0, minWidth: 0,
minHeight: 0,
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
}} }}
@@ -130,7 +166,9 @@ export default function AppShell() {
<Box <Box
component="header" component="header"
sx={{ sx={{
display: "flex", display: immersiveOnMobile
? { xs: "none", md: "flex" }
: "flex",
alignItems: "center", alignItems: "center",
gap: 1, gap: 1,
px: 2, px: 2,
@@ -160,7 +198,18 @@ export default function AppShell() {
<Box sx={{ flex: 1 }} /> <Box sx={{ flex: 1 }} />
<ThemeToggle /> <ThemeToggle />
</Box> </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 /> <Outlet />
</Box> </Box>
</Box> </Box>