draw every kind of textbook figure, lock math text left-to-right, student-first family screen

- src/diagram: plane (constructed points, graphs with computed roots, vertex,
  extrema, tangent, asymptotes, piecewise limits, regions, rotations and
  reflections, line from ax + by = c), number line with intervals and stacked
  fractions, solids with hidden edges, bar/line/pie charts, sign tables, Venn,
  arrow mappings, probability trees, fraction bars and mixed numbers, grids,
  groups, clock. Pure-math text is bidi-overridden: Persian digits used to turn
  3 × 4 = 12 into 12 = 4 × 3 even with dir=ltr.
- MathText locks math runs inside Persian sentences in chat bubbles and figure
  captions, so -1 no longer shows as 1-.
- Family screen: a student sees only the invite-code form; the parent form is
  behind a link.
- Dev-only diagram gallery at ?gallery (not in the production build).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-28 23:24:50 +03:30
parent 355bae7035
commit ec4c844531
18 changed files with 2165 additions and 419 deletions

View File

@@ -7,7 +7,8 @@ import { askStore, useAskState, type ChatMessage } from '@/state/askStore';
import { SeyarMascot } from '@/mascots/SeyarMascot';
import { useToast } from '@/components/Toast';
import { Screen, ScreenHeader } from '@/components/ui';
import { Diagram } from '@/components/Diagram';
import { Diagram } from '@/diagram';
import { MathText } from '@/lib/MathText';
import { friendlyError } from '@/lib/errors';
import { toFa } from '@/lib/format';
@@ -222,7 +223,7 @@ function Message({
}) {
const toast = useToast();
if (message.role === 'user') return <Bubble role="user">{message.text}</Bubble>;
if (message.role === 'user') return <Bubble role="user"><MathText text={message.text} /></Bubble>;
if (!message.text) {
return (
@@ -255,7 +256,7 @@ function Message({
return (
<div className="flex flex-col gap-1.5">
<Bubble role="assistant" extra={message.diagram ? <div className="whitespace-normal"><Diagram spec={message.diagram} /></div> : undefined}>
{message.text}
<MathText text={message.text} />
</Bubble>
<div className="flex flex-wrap items-center gap-2 pr-10">
{message.failedQuestion ? (

View File

@@ -1,6 +1,7 @@
import { useState } from 'react';
import { BookOpen, Flame, Link2, LogOut, Star, Trophy, UserPlus } from 'lucide-react';
import { useNav } from '@/nav/Navigator';
import { useAppData } from '@/state/AppData';
import { api, ApiError, type FamilyLink, type FamilyWeeklyReport } from '@/api';
import { useAsync } from '@/lib/useAsync';
import { useToast } from '@/components/Toast';
@@ -15,6 +16,11 @@ import { digitsOnly, formatJalaliDate, toFa } from '@/lib/format';
*/
export function FamilyScreen({ onSignedOut }: { onSignedOut: () => void }) {
const nav = useNav();
const { profile } = useAppData();
// دانش‌آموز (پایه دارد) پیش از اتصال فقط «کدِ دعوت» را می‌بیند؛ فرمِ والد برای او پشتِ یک
// پیوند است. قبلاً هر دو فرم با هم بود و بچه نمی‌دانست کدام مالِ اوست. والد پایه ندارد.
const isStudent = profile.gradeId !== null;
const [showParentForm, setShowParentForm] = useState(false);
// GET /family/link نمی‌گوید کاربر در این اتصال والد است یا فرزند؛ گزارشِ
// هفتگی فقط برای والد جواب می‌دهد و برای فرزند ۴۰۴ است، پس نقش از همان درمی‌آید.
const state = useAsync(async () => {
@@ -34,7 +40,7 @@ export function FamilyScreen({ onSignedOut }: { onSignedOut: () => void }) {
return (
<Screen>
<ScreenHeader title="بخشِ والدین" subtitle="پیشرفتِ فرزندت رو هر هفته ببین" color="#FF7A3D" back mascot={{ char: 'sigma', mood: 'happy', size: 56 }} />
<ScreenHeader title="بخشِ والدین" subtitle={isStudent ? 'به پدر یا مادرت وصل شو تا پیشرفتت رو ببینن' : 'پیشرفتِ فرزندت رو هر هفته ببین'} color="#FF7A3D" back mascot={{ char: 'sigma', mood: 'happy', size: 56 }} />
<ScreenBody className="flex flex-col gap-4">
{state.loading ? (
<LoadingState />
@@ -44,8 +50,14 @@ export function FamilyScreen({ onSignedOut }: { onSignedOut: () => void }) {
<>
{data?.link && <LinkStatus link={data.link} role={data.role} />}
{data?.report && <WeeklyReport report={data.report} />}
{data?.role !== 'child' && <InviteChild onDone={state.reload} />}
{!data?.role && <AcceptInvite onDone={state.reload} />}
{!data?.role && isStudent && <AcceptInvite onDone={state.reload} />}
{data?.role !== 'child' && (!isStudent || showParentForm || data?.role === 'parent' || data?.link?.status === 'pending') && <InviteChild onDone={state.reload} />}
{!data?.role && !isStudent && <AcceptInvite onDone={state.reload} />}
{!data?.role && isStudent && !showParentForm && data?.link?.status !== 'pending' && (
<button type="button" onClick={() => setShowParentForm(true)} className="py-1 text-center text-xs font-bold text-muted">
پدر یا مادرِ یک دانش‌آموز هستی؟ از اینجا دعوت بساز
</button>
)}
</>
)}
{/* والد بدونِ پایه نوارِ پایین را ندارد؛ خروج باید همین‌جا باشد. */}