diff --git a/src/components/ui.tsx b/src/components/ui.tsx index aeabac9..5bfcdb7 100644 --- a/src/components/ui.tsx +++ b/src/components/ui.tsx @@ -4,7 +4,7 @@ import { SeyarMascot } from '@/mascots/SeyarMascot'; import type { SeyarChar, SeyarMood } from '@/mascots/seyar'; import { friendlyError } from '@/lib/errors'; 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 onBack = typeof back === 'function' ? back : nav.back; + const bg = onWhite(color); return (
diff --git a/src/lib/color.ts b/src/lib/color.ts index 6b6cd30..8af43ff 100644 --- a/src/lib/color.ts +++ b/src/lib/color.ts @@ -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); 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; +} diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index ebe0019..c30cb78 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -8,7 +8,7 @@ import { SeyarMascot } from '@/mascots/SeyarMascot'; import { IslandPath } from '@/components/IslandPath'; import { IslandSheet } from '@/components/IslandSheet'; 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 { MathText } from '@/lib/MathText'; @@ -106,7 +106,7 @@ export function HomeScreen() { type="button" 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" - style={{ backgroundColor: color, boxShadow: `0 4px 0 ${shade(color, -0.25)}` }} + style={{ backgroundColor: onWhite(color), boxShadow: `0 4px 0 ${shade(onWhite(color), -0.25)}` }} >
diff --git a/src/screens/LessonsScreen.tsx b/src/screens/LessonsScreen.tsx index 2de71f7..2825252 100644 --- a/src/screens/LessonsScreen.tsx +++ b/src/screens/LessonsScreen.tsx @@ -4,6 +4,7 @@ import { Brain, Check, ChevronDown, Lock, Play, Sparkles } from 'lucide-react'; import { api, type HomeNode, type HomeUnit } from '@/api'; import { useAppData } from '@/state/AppData'; import { findContinue, unitColor, unitProgress } from '@/state/curriculum'; +import { onWhite } from '@/lib/color'; import { useNav } from '@/nav/Navigator'; import { useAsync } from '@/lib/useAsync'; import { EmptyState, ErrorState, LoadingState, ProgressBar, Screen, ScreenHeader, Sep } from '@/components/ui'; @@ -36,7 +37,7 @@ export function LessonsScreen() { {gradeTitle && <>{gradeTitle}}اول بخون، بعد آزمونِ جزیره رو بده} - color="#2FB36B" + color="#97C0C0" mascot={{ char: 'sigma', mood: 'happy' }} > {home && totalNodes > 0 && ( @@ -62,7 +63,7 @@ export function LessonsScreen() { type="button" 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" - 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` }} > @@ -181,7 +182,7 @@ function NodeRow({ > {done ? : locked ? : toFa(num)} @@ -219,7 +220,7 @@ function NodeRow({ > {l.done ? : toFa(li + 1)} @@ -232,7 +233,7 @@ function NodeRow({ type="button" 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]" - style={{ backgroundColor: color, boxShadow: `0 3px 0 ${color}99` }} + style={{ backgroundColor: onWhite(color), boxShadow: `0 3px 0 ${onWhite(color)}99` }} > {done ? : } {done ? 'دوباره تمرین کن' : 'درس‌ها رو خوندی؟ برو آزمون'} diff --git a/src/screens/QuizScreen.tsx b/src/screens/QuizScreen.tsx index 213f4a3..baf0c39 100644 --- a/src/screens/QuizScreen.tsx +++ b/src/screens/QuizScreen.tsx @@ -1,6 +1,5 @@ import { useEffect, useRef, useState } from '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 { api, ApiError, type AnswerResult, type Hearts, type Question, type ReportReason } from '@/api'; 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); setPhase({ kind: 'result', result }); 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 refreshSummary(); } 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]' }`}> {bubble} + {answer?.correct && answer.xpEarned > 0 && ( + + +{toFa(answer.xpEarned)} ⭐ + + )}
diff --git a/src/screens/StreakScreen.tsx b/src/screens/StreakScreen.tsx index 5c686fe..d1ed28f 100644 --- a/src/screens/StreakScreen.tsx +++ b/src/screens/StreakScreen.tsx @@ -57,6 +57,8 @@ export function StreakScreen() { ))}
+ +
{WEEKDAYS.map((d) => {d})} @@ -90,3 +92,24 @@ export function StreakScreen() { ); } + +/** نقطه‌عطف‌های زنجیره؛ فقط پیام و نوار، هیچ پاداشِ واقعی‌ای پشتش نیست (پاداش تصمیمِ محصولی است). */ +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 ( +
+

+ {reached ? `🔥 ${toFa(current)} روزه‌ای! ` : ''} + {next ? `${toFa(next - current)} روز دیگه می‌شه زنجیره‌ی ${toFa(next)} روزه.` : 'از همه‌ی نقطه‌عطف‌ها رد شدی، افسانه‌ای!'} +

+ {next && ( +
+
+
+ )} +
+ ); +} diff --git a/src/screens/SubscriptionScreen.tsx b/src/screens/SubscriptionScreen.tsx index abf6cb1..6f89383 100644 --- a/src/screens/SubscriptionScreen.tsx +++ b/src/screens/SubscriptionScreen.tsx @@ -37,7 +37,7 @@ export function SubscriptionScreen() { return (
- +