play whole islands, make home the path itself, draw tutor diagrams, add tedi

- Home is now the island path of every unit with one clear Start button on the
  current island; the six-card dashboard is gone.
- Start opens /nodes/{id}/quiz (4 questions) instead of one-question lessons, so
  an island takes one round instead of ~11. Lessons stay one tap away in the sheet.
- Tutor answers render the backend's validated diagram (plane, number line,
  fraction) as React SVG; function expressions go through a small parser, no eval.
- Tedi, the mobile-preview mascot, joins as the fourth companion with all six moods
  and an onboarding slide.
- Light polish: 3D buttons, header gradients, warmer page background.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-28 17:45:57 +03:30
parent b5d97c0cda
commit 355bae7035
17 changed files with 1075 additions and 347 deletions

View File

@@ -7,6 +7,7 @@ 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 { friendlyError } from '@/lib/errors';
import { toFa } from '@/lib/format';
@@ -39,7 +40,7 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
askStore.setHistory(
r.messages
.filter((m) => m.role === 'user' || m.role === 'assistant')
.map((m) => ({ role: m.role as ChatMessage['role'], text: m.text, messageId: m.messageId })),
.map((m) => ({ role: m.role as ChatMessage['role'], text: m.text, messageId: m.messageId, diagram: m.diagram ?? null })),
),
)
.catch(() => askStore.reset(null));
@@ -85,6 +86,7 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
messageId: done.messageId,
usedChunks: done.usedChunks,
followUps: done.suggestedFollowUps,
diagram: done.diagram ?? null,
});
askStore.setConversation(done.conversationId);
setQuota(done.remainingQuota);
@@ -188,7 +190,7 @@ export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number;
);
}
function Bubble({ role, children }: { role: 'user' | 'assistant'; children: React.ReactNode }) {
function Bubble({ role, children, extra }: { role: 'user' | 'assistant'; children: React.ReactNode; extra?: React.ReactNode }) {
const mine = role === 'user';
return (
<div className={`flex gap-2 ${mine ? 'flex-row-reverse' : ''}`}>
@@ -203,6 +205,7 @@ function Bubble({ role, children }: { role: 'user' | 'assistant'; children: Reac
}`}
>
{children}
{extra}
</div>
</div>
);
@@ -251,7 +254,9 @@ function Message({
return (
<div className="flex flex-col gap-1.5">
<Bubble role="assistant">{message.text}</Bubble>
<Bubble role="assistant" extra={message.diagram ? <div className="whitespace-normal"><Diagram spec={message.diagram} /></div> : undefined}>
{message.text}
</Bubble>
<div className="flex flex-wrap items-center gap-2 pr-10">
{message.failedQuestion ? (
<button type="button" disabled={busy} onClick={() => onRetry(message)} className="flex items-center gap-1 rounded-full bg-white px-3 py-1 text-xs font-bold text-coral ring-1 ring-line disabled:opacity-50">