update
This commit is contained in:
@@ -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>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -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 và 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>
|
||||
|
||||
@@ -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 ký
|
||||
</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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 lý hồ sơ, mục tiêu học tập và thông báo.</p>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
@import "./styles/mobile.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
|
||||
@@ -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
295
src/styles/mobile.css
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user