Colors keep their role: headers/banners darken to readable contrast (same hue), lessons uses the teal token, packages use indigo; +XP pop, streak milestone card, lazy confetti

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 16:26:07 +03:30
parent dc1fb15a55
commit 54bd865835
7 changed files with 66 additions and 12 deletions

View File

@@ -4,7 +4,7 @@ import { SeyarMascot } from '@/mascots/SeyarMascot';
import type { SeyarChar, SeyarMood } from '@/mascots/seyar'; import type { SeyarChar, SeyarMood } from '@/mascots/seyar';
import { friendlyError } from '@/lib/errors'; import { friendlyError } from '@/lib/errors';
import { useNav } from '@/nav/Navigator'; import { useNav } from '@/nav/Navigator';
import { shade } from '@/lib/color'; import { onWhite, shade } from '@/lib/color';
/* ─── سربرگِ رنگیِ صفحه ──────────────────────────────────────────────────── */ /* ─── سربرگِ رنگیِ صفحه ──────────────────────────────────────────────────── */
@@ -26,10 +26,11 @@ export function ScreenHeader({
}) { }) {
const nav = useNav(); const nav = useNav();
const onBack = typeof back === 'function' ? back : nav.back; const onBack = typeof back === 'function' ? back : nav.back;
const bg = onWhite(color);
return ( return (
<header <header
className="relative flex-shrink-0 overflow-hidden rounded-b-[32px] px-5 pb-6 pt-safe text-white" className="relative flex-shrink-0 overflow-hidden rounded-b-[32px] px-5 pb-6 pt-safe text-white"
style={{ backgroundColor: color, backgroundImage: `linear-gradient(160deg, ${shade(color, 0.12)} 0%, ${color} 55%, ${shade(color, -0.06)} 100%)` }} style={{ backgroundColor: bg, backgroundImage: `linear-gradient(160deg, ${shade(bg, 0.12)} 0%, ${bg} 55%, ${shade(bg, -0.06)} 100%)` }}
> >
<div className="pointer-events-none absolute -left-12 -top-12 h-40 w-40 rounded-full bg-white/10" /> <div className="pointer-events-none absolute -left-12 -top-12 h-40 w-40 rounded-full bg-white/10" />
<div className="pointer-events-none absolute -right-10 top-10 h-28 w-28 rounded-full bg-white/10" /> <div className="pointer-events-none absolute -right-10 top-10 h-28 w-28 rounded-full bg-white/10" />

View File

@@ -10,3 +10,22 @@ export function shade(hex: string, amount: number): string {
const r = channel((n >> 16) & 255), g = channel((n >> 8) & 255), b = channel(n & 255); const r = channel((n >> 16) & 255), g = channel((n >> 8) & 255), b = channel(n & 255);
return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, '0')}`; return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, '0')}`;
} }
function luminance(hex: string): number {
const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
if (!m) return 1;
const n = parseInt(m[1], 16);
const lin = (c: number) => { const v = c / 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; };
return 0.2126 * lin((n >> 16) & 255) + 0.7152 * lin((n >> 8) & 255) + 0.0722 * lin(n & 255);
}
/**
* همان رنگ، فقط تا جایی تیره می‌شود که متنِ سفید رویش کنتراستِ دست‌کم `min` بگیرد
* (WCAG: ۳ برای متنِ درشت). رنگِ روشن برای آیکن و نوار می‌ماند و فقط پس‌زمینه‌ی متنِ سفید از این رد می‌شود،
* پس هیچ رنگِ تازه‌ای وارد اپ نمی‌شود، فقط سایه‌ی تیره‌ترِ همان.
*/
export function onWhite(hex: string, min = 3.2): string {
let c = hex;
for (let i = 0; i < 12 && 1.05 / (luminance(c) + 0.05) < min; i++) c = shade(c, -0.07);
return c;
}

View File

@@ -8,7 +8,7 @@ import { SeyarMascot } from '@/mascots/SeyarMascot';
import { IslandPath } from '@/components/IslandPath'; import { IslandPath } from '@/components/IslandPath';
import { IslandSheet } from '@/components/IslandSheet'; import { IslandSheet } from '@/components/IslandSheet';
import { EmptyState, ErrorState, LoadingState, Screen, Sep } from '@/components/ui'; import { EmptyState, ErrorState, LoadingState, Screen, Sep } from '@/components/ui';
import { shade } from '@/lib/color'; import { onWhite, shade } from '@/lib/color';
import { toFa } from '@/lib/format'; import { toFa } from '@/lib/format';
import { MathText } from '@/lib/MathText'; import { MathText } from '@/lib/MathText';
@@ -106,7 +106,7 @@ export function HomeScreen() {
type="button" type="button"
onClick={() => nav.push({ name: 'unit', unitId: unit.id })} onClick={() => nav.push({ name: 'unit', unitId: unit.id })}
className="flex w-full items-center gap-3 rounded-2xl px-4 py-3 text-right text-white active:translate-y-0.5" className="flex w-full items-center gap-3 rounded-2xl px-4 py-3 text-right text-white active:translate-y-0.5"
style={{ backgroundColor: color, boxShadow: `0 4px 0 ${shade(color, -0.25)}` }} style={{ backgroundColor: onWhite(color), boxShadow: `0 4px 0 ${shade(onWhite(color), -0.25)}` }}
> >
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="text-[11px] font-bold text-white/80"> <div className="text-[11px] font-bold text-white/80">

View File

@@ -4,6 +4,7 @@ import { Brain, Check, ChevronDown, Lock, Play, Sparkles } from 'lucide-react';
import { api, type HomeNode, type HomeUnit } from '@/api'; import { api, type HomeNode, type HomeUnit } from '@/api';
import { useAppData } from '@/state/AppData'; import { useAppData } from '@/state/AppData';
import { findContinue, unitColor, unitProgress } from '@/state/curriculum'; import { findContinue, unitColor, unitProgress } from '@/state/curriculum';
import { onWhite } from '@/lib/color';
import { useNav } from '@/nav/Navigator'; import { useNav } from '@/nav/Navigator';
import { useAsync } from '@/lib/useAsync'; import { useAsync } from '@/lib/useAsync';
import { EmptyState, ErrorState, LoadingState, ProgressBar, Screen, ScreenHeader, Sep } from '@/components/ui'; import { EmptyState, ErrorState, LoadingState, ProgressBar, Screen, ScreenHeader, Sep } from '@/components/ui';
@@ -36,7 +37,7 @@ export function LessonsScreen() {
<ScreenHeader <ScreenHeader
title="درس‌ها" title="درس‌ها"
subtitle={<>{gradeTitle && <>{gradeTitle}<Sep /></>}اول بخون، بعد آزمونِ جزیره رو بده</>} subtitle={<>{gradeTitle && <>{gradeTitle}<Sep /></>}اول بخون، بعد آزمونِ جزیره رو بده</>}
color="#2FB36B" color="#97C0C0"
mascot={{ char: 'sigma', mood: 'happy' }} mascot={{ char: 'sigma', mood: 'happy' }}
> >
{home && totalNodes > 0 && ( {home && totalNodes > 0 && (
@@ -62,7 +63,7 @@ export function LessonsScreen() {
type="button" type="button"
onClick={() => nav.push({ name: 'lesson', lessonId: current.node.nextLessonId as number, nodeId: current.node.id })} onClick={() => nav.push({ name: 'lesson', lessonId: current.node.nextLessonId as number, nodeId: current.node.id })}
className="flex items-center gap-3 rounded-2xl px-4 py-3.5 text-right text-white" className="flex items-center gap-3 rounded-2xl px-4 py-3.5 text-right text-white"
style={{ backgroundColor: unitColor(current.unitIndex), boxShadow: `0 4px 0 ${unitColor(current.unitIndex)}99` }} style={{ backgroundColor: onWhite(unitColor(current.unitIndex)), boxShadow: `0 4px 0 ${onWhite(unitColor(current.unitIndex))}99` }}
> >
<span className="flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-full bg-white/25"> <span className="flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-full bg-white/25">
<Play size={20} fill="white" /> <Play size={20} fill="white" />
@@ -181,7 +182,7 @@ function NodeRow({
> >
<span <span
className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full text-xs font-black" className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full text-xs font-black"
style={done ? { backgroundColor: '#5FBF8F', color: '#fff' } : { backgroundColor: locked ? '#f0eeff' : `${color}22`, color: locked ? '#727299' : color }} style={done ? { backgroundColor: '#3E9E73', color: '#fff' } : { backgroundColor: locked ? '#f0eeff' : `${color}22`, color: locked ? '#727299' : color }}
> >
{done ? <Check size={15} strokeWidth={3.5} /> : locked ? <Lock size={13} /> : toFa(num)} {done ? <Check size={15} strokeWidth={3.5} /> : locked ? <Lock size={13} /> : toFa(num)}
</span> </span>
@@ -219,7 +220,7 @@ function NodeRow({
> >
<span <span
className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full text-[11px] font-black" className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full text-[11px] font-black"
style={l.done ? { backgroundColor: '#5FBF8F', color: '#fff' } : { backgroundColor: '#fff', color: '#727299', border: '1px solid #e9e6f7' }} style={l.done ? { backgroundColor: '#3E9E73', color: '#fff' } : { backgroundColor: '#fff', color: '#727299', border: '1px solid #e9e6f7' }}
> >
{l.done ? <Check size={13} strokeWidth={3.5} /> : toFa(li + 1)} {l.done ? <Check size={13} strokeWidth={3.5} /> : toFa(li + 1)}
</span> </span>
@@ -232,7 +233,7 @@ function NodeRow({
type="button" type="button"
onClick={() => nav.push({ name: 'quiz', nodeId: node.id })} onClick={() => nav.push({ name: 'quiz', nodeId: node.id })}
className="mt-2.5 flex w-full items-center justify-center gap-2 rounded-xl py-3 text-sm font-black text-white active:translate-y-[2px]" className="mt-2.5 flex w-full items-center justify-center gap-2 rounded-xl py-3 text-sm font-black text-white active:translate-y-[2px]"
style={{ backgroundColor: color, boxShadow: `0 3px 0 ${color}99` }} style={{ backgroundColor: onWhite(color), boxShadow: `0 3px 0 ${onWhite(color)}99` }}
> >
{done ? <Sparkles size={15} /> : <Play size={15} fill="white" />} {done ? <Sparkles size={15} /> : <Play size={15} fill="white" />}
{done ? 'دوباره تمرین کن' : 'درس‌ها رو خوندی؟ برو آزمون'} {done ? 'دوباره تمرین کن' : 'درس‌ها رو خوندی؟ برو آزمون'}

View File

@@ -1,6 +1,5 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { AnimatePresence, motion } from 'motion/react'; import { AnimatePresence, motion } from 'motion/react';
import confetti from 'canvas-confetti';
import { Brain, Check, ChevronLeft, Coins, Flag, Heart, Infinity as InfinityIcon, Star, X } from 'lucide-react'; import { Brain, Check, ChevronLeft, Coins, Flag, Heart, Infinity as InfinityIcon, Star, X } from 'lucide-react';
import { api, ApiError, type AnswerResult, type Hearts, type Question, type ReportReason } from '@/api'; import { api, ApiError, type AnswerResult, type Hearts, type Question, type ReportReason } from '@/api';
import { useAppData } from '@/state/AppData'; import { useAppData } from '@/state/AppData';
@@ -165,7 +164,7 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
: await api.lesson.complete(lessonId, payload, completeKey.current); : await api.lesson.complete(lessonId, payload, completeKey.current);
setPhase({ kind: 'result', result }); setPhase({ kind: 'result', result });
sounds.finish(); sounds.finish();
if (result.stars >= 2) void confetti({ particleCount: 90, spread: 70, origin: { y: 0.35 } }); if (result.stars >= 2) void import('canvas-confetti').then(({ default: confetti }) => confetti({ particleCount: 90, spread: 70, origin: { y: 0.35 } }));
void refreshHome(); void refreshHome();
void refreshSummary(); void refreshSummary();
} catch (error) { } catch (error) {
@@ -283,6 +282,17 @@ export function QuizScreen({ lessonId, nodeId }: { lessonId?: number; nodeId?: s
!answer ? 'border-line bg-white text-indigo' : answer.correct ? 'border-green-200 bg-green-50 text-green-700' : 'border-purple-200 bg-purple-50 text-[#5B3FA0]' !answer ? 'border-line bg-white text-indigo' : answer.correct ? 'border-green-200 bg-green-50 text-green-700' : 'border-purple-200 bg-purple-50 text-[#5B3FA0]'
}`}> }`}>
{bubble} {bubble}
{answer?.correct && answer.xpEarned > 0 && (
<motion.span
key={question.id}
initial={{ opacity: 0, y: 8, scale: 0.8 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ type: 'spring', stiffness: 400, damping: 14 }}
className="mr-2 inline-block rounded-full bg-[#FFF3C4] px-2 py-0.5 text-[11px] font-black text-[#8A5A00]"
>
+{toFa(answer.xpEarned)} ⭐
</motion.span>
)}
</div> </div>
</div> </div>

View File

@@ -57,6 +57,8 @@ export function StreakScreen() {
))} ))}
</div> </div>
<StreakGoal current={calendar.data.currentStreak} />
<div className="rounded-3xl border border-line bg-white p-4"> <div className="rounded-3xl border border-line bg-white p-4">
<div className="mb-2 grid grid-cols-7 gap-1.5 text-center text-[10px] font-bold text-muted"> <div className="mb-2 grid grid-cols-7 gap-1.5 text-center text-[10px] font-bold text-muted">
{WEEKDAYS.map((d) => <span key={d}>{d}</span>)} {WEEKDAYS.map((d) => <span key={d}>{d}</span>)}
@@ -90,3 +92,24 @@ export function StreakScreen() {
</Screen> </Screen>
); );
} }
/** نقطه‌عطف‌های زنجیره؛ فقط پیام و نوار، هیچ پاداشِ واقعی‌ای پشتش نیست (پاداش تصمیمِ محصولی است). */
const MILESTONES = [3, 7, 14, 30, 60, 100];
function StreakGoal({ current }: { current: number }) {
const next = MILESTONES.find((m) => m > current);
const reached = MILESTONES.filter((m) => m <= current).pop();
return (
<div className="rounded-3xl border border-line bg-[#FFF3E8] px-4 py-3">
<p className="text-sm font-black leading-7 text-ink">
{reached ? `🔥 ${toFa(current)} روزه‌ای! ` : ''}
{next ? `${toFa(next - current)} روز دیگه می‌شه زنجیره‌ی ${toFa(next)} روزه.` : 'از همه‌ی نقطه‌عطف‌ها رد شدی، افسانه‌ای!'}
</p>
{next && (
<div className="mt-2 h-2 overflow-hidden rounded-full bg-white">
<div className="h-full rounded-full bg-orange transition-[width]" style={{ width: `${Math.round((current / next) * 100)}%` }} />
</div>
)}
</div>
);
}

View File

@@ -37,7 +37,7 @@ export function SubscriptionScreen() {
return ( return (
<Screen> <Screen>
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
<ScreenHeader title="بسته‌ی سوال" subtitle="هر وقت لازم داشتی، سوال بخر" color="#ED6956" mascot={{ char: 'pi', mood: 'excited', size: 60 }} /> <ScreenHeader title="بسته‌ی سوال" subtitle="هر وقت لازم داشتی، سوال بخر" color="#4C5FD5" mascot={{ char: 'pi', mood: 'excited', size: 60 }} />
<div className="flex flex-col gap-4 px-4 py-5"> <div className="flex flex-col gap-4 px-4 py-5">
<div className="rounded-3xl border border-line bg-white p-4"> <div className="rounded-3xl border border-line bg-white p-4">