This commit is contained in:
2026-05-02 00:20:44 +07:00
parent 36b8ee9ec2
commit dcbce863de
14 changed files with 448 additions and 81 deletions

View File

@@ -1,12 +1,15 @@
import { Link, useRouterState } from '@tanstack/react-router'
import { Home, ClipboardList, Layers, Trophy, User } from 'lucide-react'
import { cn } from '@/lib/utils'
const NAV_ITEMS = [
{ to: '/', label: 'Home', icon: 'home', matchPrefix: '/', exact: true },
{ to: '/archivement', label: 'Thành tích', icon: 'emoji_events', matchPrefix: '/archivement', exact: false },
{ to: '/toeic', label: 'Luyện đề', icon: 'assignment', matchPrefix: '/toeic', exact: false },
{ to: '/writing', label: 'Writing', icon: 'edit_note', matchPrefix: '/writing', exact: false },
{ to: '/settings', label: 'Cài đặt', icon: 'settings', matchPrefix: '/settings', exact: false },
// Atelier Mobile tab bar — 5 tabs matching the mobile design.
// Labels keep the mobile design's playful brevity: Hôm nay / Luyện / Thẻ / Thành tích / Tôi
const TABS = [
{ to: '/', label: 'Hôm nay', icon: Home, matchPrefix: '/', exact: true },
{ to: '/toeic', label: 'Luyện', icon: ClipboardList, matchPrefix: '/toeic', exact: false },
{ to: '/flash-card', label: 'Thẻ', icon: Layers, matchPrefix: '/flash-card', exact: false },
{ to: '/archivement', label: 'Thành tích', icon: Trophy, matchPrefix: '/archivement', exact: false },
{ to: '/settings', label: 'Tôi', icon: User, matchPrefix: '/settings', exact: false },
]
function isActive(pathname: string, prefix: string, exact: boolean) {
@@ -18,22 +21,19 @@ export function MobileNav() {
const pathname = location.pathname
return (
<nav className="fixed bottom-0 inset-x-0 lg:hidden bg-white border-t border-slate-200 z-50 flex safe-area-inset-bottom">
{NAV_ITEMS.map((item) => {
const active = isActive(pathname, item.matchPrefix, item.exact)
<nav className="m-tabbar lg:hidden" aria-label="Chuyển màn hình">
{TABS.map((tab) => {
const active = isActive(pathname, tab.matchPrefix, tab.exact)
const Icon = tab.icon
return (
<Link
key={item.to}
to={item.to}
className={cn(
'flex-1 flex flex-col items-center justify-center gap-0.5 py-2 min-h-[56px] text-[11px] font-medium transition-colors',
active ? 'text-blue-600' : 'text-slate-400',
)}
key={tab.to}
to={tab.to}
className={cn('m-tab', active && 'is-active')}
aria-current={active ? 'page' : undefined}
>
<span className="material-symbols-outlined" style={{ fontSize: 22 }}>
{item.icon}
</span>
{item.label}
<Icon width={22} height={22} strokeWidth={active ? 2.25 : 1.75} />
<span>{tab.label}</span>
</Link>
)
})}

View File

@@ -64,7 +64,7 @@ export function Dashboard() {
if (!user) {
return (
<div className="px-4 lg:px-6 py-20 max-w-6xl mx-auto flex flex-col items-center text-center gap-4">
<div className="px-4 lg:px-6 py-20 flex flex-col items-center text-center gap-4">
<div className="at-serif italic text-5xl" style={{ color: 'var(--at-mute-2)' }}>Thành tích</div>
<p className="max-w-sm" style={{ color: 'var(--at-mute)' }}>
Đăng nhập đ xem streak, XP, Xu bảng xếp hạng của bạn.
@@ -114,7 +114,7 @@ export function Dashboard() {
}))
return (
<div className="px-6 lg:px-10 py-10 max-w-6xl mx-auto page-enter">
<div className="px-6 lg:px-10 py-10 page-enter">
{/* Editorial head */}
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-6 mb-10">
<div>

View File

@@ -3,6 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useNavigate } from '@tanstack/react-router'
import { cn } from '@/lib/utils'
import { useAuthStore } from '@/store/auth-store'
import { useAuthModalStore } from '@/store/auth-modal-store'
import {
fetchFlashcardTerms,
fetchUserProgress,
@@ -34,7 +35,9 @@ function speak(word: string) {
export function FlashCardLearnPage({ listId }: Props) {
const navigate = useNavigate()
const user = useAuthStore(s => s.user)
const openAuthModal = useAuthModalStore(s => s.open)
const queryClient = useQueryClient()
const isGuest = !user
const [isFlipped, setIsFlipped] = useState(false)
const [currentIdx, setCurrentIdx] = useState(0)
@@ -213,6 +216,12 @@ export function FlashCardLearnPage({ listId }: Props) {
setTimeout(advance, 450)
}, [currentIdx, sessionTerms, user, saveAnswer, progressMap, advance])
// Jump to a specific card in the deck (no progress write — just navigate)
const jumpTo = useCallback((idx: number) => {
setCurrentIdx(idx)
setIsFlipped(false)
}, [])
// Keyboard shortcuts
useEffect(() => {
function onKey(e: KeyboardEvent) {
@@ -222,14 +231,26 @@ export function FlashCardLearnPage({ listId }: Props) {
setIsFlipped(v => !v)
return
}
if (!isFlipped) return
// Arrow nav — works for everyone, no progress write
if (e.key === 'ArrowLeft') {
e.preventDefault()
if (currentIdx > 0) jumpTo(currentIdx - 1)
return
}
if (e.key === 'ArrowRight') {
e.preventDefault()
if (currentIdx < sessionTerms.length - 1) jumpTo(currentIdx + 1)
return
}
// SRS keys — auth users only
if (isGuest || !isFlipped) return
if (e.key.toLowerCase() === 'j') handleAnswer('known')
else if (e.key.toLowerCase() === 'k') handleAnswer('hard')
else if (e.key.toLowerCase() === 'i') handleAnswer('ignored')
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [isDone, isFlipped, currentIdx, sessionTerms, handleAnswer])
}, [isDone, isFlipped, currentIdx, sessionTerms, handleAnswer, isGuest, jumpTo])
const total = sessionTerms.length
const progressPct = total > 0 ? Math.round((currentIdx / total) * 100) : 0
@@ -309,12 +330,6 @@ export function FlashCardLearnPage({ listId }: Props) {
)
}
// Jump to a specific card in the deck (no progress write — just navigate)
const jumpTo = (idx: number) => {
setCurrentIdx(idx)
setIsFlipped(false)
}
return (
<div
className="atelier fixed top-16 right-0 left-0 lg:left-60 bottom-20 lg:bottom-0 flex flex-col px-4 lg:px-6 py-3 overflow-hidden"
@@ -468,28 +483,60 @@ export function FlashCardLearnPage({ listId }: Props) {
</div>
)}
{/* Actions */}
{/* Actions — auth users get SRS buttons, guests get prev/next + login CTA */}
<div className="mt-4 w-full" style={{ maxWidth: 420 }}>
<div className={cn('flex items-stretch gap-2.5 w-full transition-opacity duration-300', !isFlipped && 'opacity-40 pointer-events-none')}>
<button onClick={() => handleAnswer('ignored')} disabled={!isFlipped} className="at-action" style={{ padding: '11px 14px', fontSize: 13 }}>
Bỏ qua <span className="at-kbd">I</span>
</button>
<button onClick={() => handleAnswer('hard')} disabled={!isFlipped} className="at-action at-action-review" style={{ padding: '11px 14px', fontSize: 13 }}>
Cần ôn <span className="at-kbd">K</span>
</button>
<button onClick={() => handleAnswer('known')} disabled={!isFlipped} className="at-action at-action-known" style={{ padding: '11px 14px', fontSize: 13 }}>
Đã thuộc
<span className="at-kbd" style={{ background: 'rgba(255,255,255,0.16)', color: 'rgba(255,255,255,0.9)', border: 'none' }}>J</span>
</button>
</div>
{isGuest ? (
<div className="flex flex-col gap-2.5">
<div className="flex items-stretch gap-2.5 w-full">
<button
onClick={() => currentIdx > 0 && jumpTo(currentIdx - 1)}
disabled={currentIdx === 0}
className="at-action"
style={{ padding: '11px 14px', fontSize: 13 }}
>
<span className="at-kbd"></span> Trước
</button>
<button
onClick={() => currentIdx < sessionTerms.length - 1 && jumpTo(currentIdx + 1)}
disabled={currentIdx >= sessionTerms.length - 1}
className="at-action"
style={{ padding: '11px 14px', fontSize: 13 }}
>
Sau <span className="at-kbd"></span>
</button>
</div>
<button
onClick={() => openAuthModal('register')}
className="at-action at-action-known"
style={{ padding: '11px 14px', fontSize: 13 }}
>
Đăng nhập đ theo dõi tiến đ
</button>
</div>
) : (
<div className={cn('flex items-stretch gap-2.5 w-full transition-opacity duration-300', !isFlipped && 'opacity-40 pointer-events-none')}>
<button onClick={() => handleAnswer('ignored')} disabled={!isFlipped} className="at-action" style={{ padding: '11px 14px', fontSize: 13 }}>
Bỏ qua <span className="at-kbd">I</span>
</button>
<button onClick={() => handleAnswer('hard')} disabled={!isFlipped} className="at-action at-action-review" style={{ padding: '11px 14px', fontSize: 13 }}>
Cần ôn <span className="at-kbd">K</span>
</button>
<button onClick={() => handleAnswer('known')} disabled={!isFlipped} className="at-action at-action-known" style={{ padding: '11px 14px', fontSize: 13 }}>
Đã thuộc
<span className="at-kbd" style={{ background: 'rgba(255,255,255,0.16)', color: 'rgba(255,255,255,0.9)', border: 'none' }}>J</span>
</button>
</div>
)}
</div>
{/* Progress */}
<div className="mt-3 w-full" style={{ maxWidth: 420 }}>
<div className="flex items-baseline justify-between mb-1.5 text-[12px] text-[var(--at-mute)]">
<span>
<b className="text-[var(--at-ink)] tabular-nums">{currentIdx + 1}</b> / {total} ·{' '}
{sessionStats.known} biết · {sessionStats.learning} học · {sessionStats.ignored} bỏ
<b className="text-[var(--at-ink)] tabular-nums">{currentIdx + 1}</b> / {total}
{!isGuest && (
<> · {sessionStats.known} biết · {sessionStats.learning} học · {sessionStats.ignored} bỏ</>
)}
</span>
<span className="at-pct" style={{ fontSize: 18 }}>{progressPct}%</span>
</div>
@@ -501,37 +548,59 @@ export function FlashCardLearnPage({ listId }: Props) {
{/* Right sidebar */}
<aside className="hidden lg:flex flex-col gap-3 min-h-0">
{/* Today stats */}
<div
className="rounded-2xl p-4 flex-shrink-0"
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}
>
<div className="at-eyebrow mb-2" style={{ fontSize: 11 }}>Hôm nay</div>
<div className="grid grid-cols-2 gap-3 mt-1">
<div>
<div style={{ fontSize: 10, color: 'var(--at-mute)', textTransform: 'uppercase', fontWeight: 600, letterSpacing: '0.12em' }}>
Đã học
</div>
<div
className="at-serif"
style={{ fontSize: 26, fontWeight: 400, letterSpacing: '-0.02em', lineHeight: 1.1, color: 'var(--at-ink)' }}
>
{sessionStats.known + sessionStats.learning + sessionStats.ignored}
</div>
{/* Today stats — auth users; guests see a login nudge */}
{isGuest ? (
<div
className="rounded-2xl p-4 flex-shrink-0"
style={{ background: 'var(--at-brand-soft)', border: '1px solid var(--at-line)' }}
>
<div className="at-eyebrow mb-1" style={{ fontSize: 11, color: 'var(--at-brand-ink)' }}>Chế đ khách</div>
<div
className="at-serif"
style={{ fontSize: 18, fontWeight: 400, letterSpacing: '-0.02em', lineHeight: 1.2, color: 'var(--at-brand-ink)' }}
>
Đăng nhập đ <i>ghi nhớ</i> tiến đ
</div>
<div>
<div style={{ fontSize: 10, color: 'var(--at-mute)', textTransform: 'uppercase', fontWeight: 600, letterSpacing: '0.12em' }}>
Đúng
<button
onClick={() => openAuthModal('register')}
className="mt-3 w-full text-[12px] font-semibold py-2 rounded-lg transition-opacity hover:opacity-90"
style={{ background: 'var(--at-brand)', color: '#fff' }}
>
Đăng nhập / Đăng
</button>
</div>
) : (
<div
className="rounded-2xl p-4 flex-shrink-0"
style={{ background: 'var(--at-surface)', border: '1px solid var(--at-line)' }}
>
<div className="at-eyebrow mb-2" style={{ fontSize: 11 }}>Hôm nay</div>
<div className="grid grid-cols-2 gap-3 mt-1">
<div>
<div style={{ fontSize: 10, color: 'var(--at-mute)', textTransform: 'uppercase', fontWeight: 600, letterSpacing: '0.12em' }}>
Đã học
</div>
<div
className="at-serif"
style={{ fontSize: 26, fontWeight: 400, letterSpacing: '-0.02em', lineHeight: 1.1, color: 'var(--at-ink)' }}
>
{sessionStats.known + sessionStats.learning + sessionStats.ignored}
</div>
</div>
<div
className="at-serif"
style={{ fontSize: 26, fontWeight: 400, letterSpacing: '-0.02em', lineHeight: 1.1, color: 'var(--at-good)' }}
>
{sessionStats.known}
<div>
<div style={{ fontSize: 10, color: 'var(--at-mute)', textTransform: 'uppercase', fontWeight: 600, letterSpacing: '0.12em' }}>
Đúng
</div>
<div
className="at-serif"
style={{ fontSize: 26, fontWeight: 400, letterSpacing: '-0.02em', lineHeight: 1.1, color: 'var(--at-good)' }}
>
{sessionStats.known}
</div>
</div>
</div>
</div>
</div>
)}
{/* Cards in deck — compact rows (word only) */}
<div

View File

@@ -125,7 +125,7 @@ export function FlashCardListPage() {
})
return (
<div className="px-6 lg:px-10 py-10 max-w-6xl mx-auto page-enter">
<div className="px-6 lg:px-10 py-10 page-enter">
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-6 mb-10">
<div>
<div className="at-eyebrow mb-3">Từ vựng TOEIC</div>

View File

@@ -68,7 +68,7 @@ export function FlashCardTermsPage({ listId }: Props) {
})
return (
<div className="px-6 lg:px-10 py-10 max-w-6xl mx-auto page-enter">
<div className="px-6 lg:px-10 py-10 page-enter">
{/* Editorial head */}
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-6 mb-10">
<div className="flex items-start gap-4 min-w-0">

View File

@@ -71,7 +71,7 @@ export function Vocabulary() {
.reverse()
return (
<div className="px-4 lg:px-6 py-6 max-w-6xl mx-auto page-enter">
<div className="px-4 lg:px-6 py-6 page-enter">
{/* Mobile topic chips */}
<div className="lg:hidden mb-4 overflow-x-auto pb-1">
<div className="flex gap-2 w-max">

View File

@@ -35,7 +35,7 @@ export function Home() {
const firstName = user?.name ?? 'bạn'
return (
<div className="px-6 lg:px-10 py-10 max-w-6xl mx-auto page-enter">
<div className="px-6 lg:px-10 py-10 page-enter">
{/* Page head — editorial */}
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-6 mb-10">
<div>

View File

@@ -13,7 +13,7 @@ export function Settings() {
if (!user) {
return (
<div className="px-4 lg:px-6 py-12 max-w-6xl mx-auto flex flex-col items-center justify-center gap-4 text-center">
<div className="px-4 lg:px-6 py-12 flex flex-col items-center justify-center gap-4 text-center">
<span className="material-symbols-outlined text-slate-300" style={{ fontSize: 64 }}>settings</span>
<h1 className="text-xl font-bold text-slate-700">Cài đt</h1>
<p className="text-slate-400 text-sm max-w-xs">
@@ -30,7 +30,7 @@ export function Settings() {
}
return (
<div className="px-4 lg:px-6 py-6 max-w-5xl mx-auto">
<div className="px-4 lg:px-6 py-6">
<div className="mb-6">
<h1 className="text-2xl font-extrabold text-slate-800 mb-1">Cài đt</h1>
<p className="text-slate-400 text-sm">Quản hồ , mục tiêu học tập thông báo.</p>

View File

@@ -28,7 +28,7 @@ export function ToeicPractice() {
}
return (
<div className="px-6 py-8 max-w-6xl mx-auto page-enter">
<div className="px-6 py-8 page-enter">
<div className="mb-8">
<h1 className="text-3xl font-extrabold text-slate-800 mb-2">Chọn Part TOEIC</h1>
<p className="text-slate-500">

View File

@@ -99,7 +99,7 @@ export function WritingChecker() {
const wordCount = text.split(/\s+/).filter(Boolean).length
return (
<div className="px-6 lg:px-10 py-10 max-w-6xl mx-auto page-enter">
<div className="px-6 lg:px-10 py-10 page-enter">
{/* Editorial page head */}
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-6 mb-10">
<div className="min-w-0">

View File

@@ -107,7 +107,7 @@ export function WritingHistory() {
if (!user) return null
return (
<section className="px-4 lg:px-6 pb-10 max-w-6xl mx-auto">
<section className="px-4 lg:px-6 pb-10">
<h2 className="text-lg font-bold text-slate-800 mb-4">Lịch sử chấm bài</h2>
{isLoading && (

View File

@@ -1,6 +1,7 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "./styles/mobile.css";
@custom-variant dark (&:is(.dark *));

View File

@@ -32,10 +32,12 @@ function RootLayout() {
}
return (
<div className="min-h-screen bg-slate-50">
<div className="min-h-screen" style={{ background: 'var(--at-paper)' }}>
<Sidebar />
<AppHeader />
<main className="lg:ml-60 pt-16 pb-20 lg:pb-0 min-h-screen">
{/* Extra bottom padding on mobile to clear the floating tab bar
(68px pill + 10px margin + safe-area). */}
<main className="lg:ml-60 pt-16 pb-28 lg:pb-0 min-h-screen">
<Outlet />
</main>
<MobileNav />

295
src/styles/mobile.css Normal file
View File

@@ -0,0 +1,295 @@
/* Mobile UI primitives — Atelier Mobile design.
Only kicks in on narrow viewports via `@media (max-width: 880px)`.
Uses the existing `--at-*` design tokens; does NOT touch desktop layouts. */
@media (max-width: 880px) {
/* Mobile page background gradient */
body {
background:
radial-gradient(60% 60% at 50% 0%, color-mix(in oklab, var(--at-brand) 6%, transparent) 0%, transparent 70%),
var(--at-paper);
}
}
/* -------- Mobile primitives (usable on any viewport) -------- */
/* Card — soft surface with hairline border and small shadow */
.m-card {
background: var(--at-surface);
border: 1px solid var(--at-line);
border-radius: 20px;
padding: 18px;
box-shadow: 0 1px 2px rgba(15, 17, 20, 0.04);
}
.m-card + .m-card {
margin-top: 12px;
}
.m-row {
display: flex;
align-items: center;
gap: 12px;
}
/* Big serif display numbers */
.m-num {
font-family: var(--at-serif);
font-weight: 400;
letter-spacing: -0.03em;
line-height: 0.95;
}
.m-num.xl {
font-size: 72px;
}
.m-num.lg {
font-size: 48px;
}
.m-num.md {
font-size: 32px;
}
/* Eyebrow */
.m-eyebrow {
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--at-mute);
}
/* Chips */
.m-chip {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 9px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
background: var(--at-paper-2);
color: var(--at-ink-2);
border: 1px solid var(--at-line);
}
.m-chip.brand {
background: var(--at-brand-soft);
color: var(--at-brand);
border-color: color-mix(in oklab, var(--at-brand) 18%, transparent);
}
.m-chip.good {
background: var(--at-good-soft);
color: var(--at-good);
border-color: color-mix(in oklab, var(--at-good) 20%, transparent);
}
.m-chip.streak {
background: var(--at-streak-soft);
color: var(--at-streak);
border-color: color-mix(in oklab, var(--at-streak) 22%, transparent);
}
/* Progress bar */
.m-bar {
width: 100%;
height: 8px;
border-radius: 99px;
background: var(--at-line-2);
overflow: hidden;
}
.m-bar > span {
display: block;
height: 100%;
background: var(--at-brand);
border-radius: 99px;
transition: width 0.6s cubic-bezier(0.2, 0.7, 0.2, 1);
}
/* Buttons */
.m-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
height: 44px;
padding: 0 18px;
border-radius: 14px;
font-size: 14px;
font-weight: 600;
background: var(--at-surface);
color: var(--at-ink);
border: 1px solid var(--at-line);
transition: transform 0.08s ease;
}
.m-btn:active {
transform: scale(0.97);
}
.m-btn.primary {
background: var(--at-ink);
color: var(--at-paper);
border-color: var(--at-ink);
}
.m-btn.brand {
background: var(--at-brand);
color: white;
border-color: var(--at-brand);
box-shadow: 0 4px 14px -4px color-mix(in oklab, var(--at-brand) 60%, transparent);
}
.m-btn.block {
width: 100%;
}
.m-btn.sm {
height: 34px;
padding: 0 12px;
font-size: 12.5px;
}
/* Section header */
.m-section-head {
display: flex;
align-items: baseline;
justify-content: space-between;
padding: 20px 4px 12px;
}
.m-section-title {
font-family: var(--at-serif);
font-weight: 400;
font-size: 22px;
letter-spacing: -0.02em;
line-height: 1.1;
}
.m-section-title i {
font-style: italic;
color: var(--at-brand);
}
.m-section-link {
font-size: 12.5px;
color: var(--at-brand);
font-weight: 600;
}
/* Topic tile */
.m-topic-tile {
border-radius: 18px;
padding: 14px;
background: var(--at-surface);
border: 1px solid var(--at-line);
text-align: left;
position: relative;
overflow: hidden;
min-height: 100px;
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 8px;
transition: transform 0.1s;
}
.m-topic-tile:active {
transform: scale(0.98);
}
/* Grids */
.m-grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.m-grid-3 {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 10px;
}
/* Screen transition */
.m-fade {
animation: mFade 0.35s cubic-bezier(0.2, 0.7, 0.2, 1);
}
@keyframes mFade {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* -------- Mobile-only responsive tweaks (only < 880px) -------- */
@media (max-width: 880px) {
/* Tab bar — floating glass pill */
.m-tabbar {
position: fixed;
left: 10px;
right: 10px;
bottom: calc(10px + env(safe-area-inset-bottom));
height: 68px;
border-radius: 28px;
background: color-mix(in oklab, var(--at-surface) 88%, transparent);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--at-line);
box-shadow:
0 -2px 14px rgba(15, 17, 20, 0.04),
0 14px 32px -12px rgba(15, 17, 20, 0.16);
display: grid;
grid-template-columns: repeat(5, 1fr);
align-items: stretch;
z-index: 30;
padding: 6px;
gap: 2px;
}
.m-tab {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 3px;
border-radius: 20px;
color: var(--at-mute);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.02em;
padding: 6px 4px;
transition: color 0.15s, background 0.15s;
}
.m-tab.is-active {
color: var(--at-brand);
background: var(--at-brand-softer);
}
.m-tab > svg {
transition: transform 0.15s;
}
.m-tab.is-active > svg {
transform: translateY(-1px) scale(1.08);
}
}
/* -------- Mobile top bar (floating title) -------- */
@media (max-width: 880px) {
.m-topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 16px 16px 12px;
background: color-mix(in oklab, var(--at-paper) 92%, transparent);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border-bottom: 1px solid var(--at-line);
position: sticky;
top: 0;
z-index: 20;
}
.m-topbar-title {
font-family: var(--at-serif);
font-weight: 400;
font-size: 26px;
letter-spacing: -0.02em;
line-height: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.m-topbar-title i {
font-style: italic;
color: var(--at-brand);
}
}