+
+
+
+
+ {/* oxlint-disable jsx-a11y/prefer-tag-over-role -- SVG needs an image role while retaining drawing geometry. */}
+
+ {(attempt.feedback === "correct" || attempt.feedback === "incorrect") && (
+
+
+
+ )}
+ {/* oxlint-enable jsx-a11y/prefer-tag-over-role */}
+
+ );
+}
diff --git a/src/features/writing/use-case/tracing/components/feedback.css b/src/features/writing/use-case/tracing/components/feedback.css
new file mode 100644
index 0000000..8db3141
--- /dev/null
+++ b/src/features/writing/use-case/tracing/components/feedback.css
@@ -0,0 +1,104 @@
+@keyframes letter-check {
+ from {
+ stroke-dasharray: 1;
+ stroke-dashoffset: 1;
+ }
+ to {
+ stroke-dasharray: 1;
+ stroke-dashoffset: 0;
+ }
+}
+
+@keyframes letter-retry {
+ from {
+ transform: rotate(-50deg);
+ }
+ to {
+ transform: rotate(0);
+ }
+}
+
+[data-writing-result="correct"] {
+ background-color: var(--color-success-soft);
+ color: var(--color-success);
+}
+
+[data-writing-result="incorrect"] {
+ background-color: var(--color-retry-soft);
+ color: var(--color-retry);
+}
+
+[data-writing-result="correct"] > svg {
+ border-color: var(--color-success);
+}
+
+[data-writing-result="incorrect"] > svg {
+ border-color: var(--color-retry);
+}
+
+[data-writing-feedback="correct"] {
+ color: var(--color-success);
+ font-weight: 650;
+}
+
+[data-writing-feedback="incorrect"] {
+ color: var(--color-retry);
+ font-weight: 650;
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ [data-writing-result="correct"] {
+ animation: canvas-celebrate 650ms cubic-bezier(0.16, 1, 0.3, 1);
+ }
+
+ [data-writing-result="incorrect"] {
+ animation: canvas-retry 500ms ease-out;
+ }
+
+ [data-writing-result="incorrect"] > div {
+ animation: canvas-nudge 360ms ease-out;
+ }
+}
+
+@keyframes canvas-celebrate {
+ 0% {
+ background-color: #bde8c9;
+ }
+ 100% {
+ background-color: var(--color-success-soft);
+ }
+}
+
+@keyframes canvas-retry {
+ from {
+ background-color: #f4caca;
+ }
+ to {
+ background-color: var(--color-retry-soft);
+ }
+}
+
+@keyframes canvas-nudge {
+ 0%,
+ 100% {
+ transform: translateX(0);
+ }
+ 25% {
+ transform: translateX(-5px);
+ }
+ 55% {
+ transform: translateX(4px);
+ }
+ 80% {
+ transform: translateX(-2px);
+ }
+}
+
+@keyframes result-stamp {
+ from {
+ transform: translateY(6px) scale(0.65) rotate(-12deg);
+ }
+ to {
+ transform: translateY(0) scale(1) rotate(0);
+ }
+}
diff --git a/src/features/writing/use-case/tracing/constants/development-lesson.ts b/src/features/writing/use-case/tracing/constants/development-lesson.ts
new file mode 100644
index 0000000..8f49b97
--- /dev/null
+++ b/src/features/writing/use-case/tracing/constants/development-lesson.ts
@@ -0,0 +1,37 @@
+export const DEVELOPMENT_LESSON = {
+ id: "la-development-1",
+ glyph: "ल",
+ pronunciation: "la",
+ review: "pending",
+ bounds: { x: 0, y: 0, width: 100, height: 100 },
+ strokes: [
+ {
+ points: [
+ { x: 38, y: 73 },
+ { x: 30, y: 66 },
+ { x: 25, y: 57 },
+ { x: 25, y: 48 },
+ { x: 29, y: 41 },
+ { x: 35, y: 38 },
+ { x: 41, y: 41 },
+ { x: 45, y: 48 },
+ { x: 47, y: 57 },
+ { x: 51, y: 49 },
+ { x: 58, y: 44 },
+ { x: 67, y: 44 },
+ ],
+ },
+ {
+ points: [
+ { x: 67, y: 25 },
+ { x: 67, y: 76 },
+ ],
+ },
+ {
+ points: [
+ { x: 22, y: 25 },
+ { x: 77, y: 25 },
+ ],
+ },
+ ],
+};
diff --git a/src/features/writing/use-case/tracing/helpers/check-character.ts b/src/features/writing/use-case/tracing/helpers/check-character.ts
new file mode 100644
index 0000000..0e84e0d
--- /dev/null
+++ b/src/features/writing/use-case/tracing/helpers/check-character.ts
@@ -0,0 +1,54 @@
+import type { Lesson } from "../../../domain/lessons";
+import type { Point } from "../../../domain/strokes";
+
+function distanceSquared(point: Point, start: Point, end: Point): number {
+ const dx = end.x - start.x;
+ const dy = end.y - start.y;
+ const length = dx * dx + dy * dy;
+ const fraction =
+ length === 0
+ ? 0
+ : Math.max(0, Math.min(1, ((point.x - start.x) * dx + (point.y - start.y) * dy) / length));
+ return (point.x - start.x - fraction * dx) ** 2 + (point.y - start.y - fraction * dy) ** 2;
+}
+
+function samples(strokes: readonly (readonly Point[])[], spacing: number): Point[] {
+ return strokes.flatMap((stroke) =>
+ stroke.flatMap((end, index) => {
+ const start = stroke[index - 1];
+ if (!start) return [end];
+ const steps = Math.max(1, Math.ceil(Math.hypot(end.x - start.x, end.y - start.y) / spacing));
+ return Array.from({ length: steps }, (_, step) => ({
+ x: start.x + ((end.x - start.x) * (step + 1)) / steps,
+ y: start.y + ((end.y - start.y) * (step + 1)) / steps,
+ }));
+ }),
+ );
+}
+
+function coverage(
+ points: readonly Point[],
+ target: readonly (readonly Point[])[],
+ tolerance: number,
+): number {
+ if (points.length === 0) return 0;
+ const matched = points.filter((point) =>
+ target.some((stroke) =>
+ stroke.some(
+ (end, index) => distanceSquared(point, stroke[index - 1] ?? end, end) <= tolerance ** 2,
+ ),
+ ),
+ );
+ return matched.length / points.length;
+}
+
+export function checkCharacter(lesson: Lesson, strokes: readonly (readonly Point[])[]): boolean {
+ const size = Math.min(lesson.bounds.width, lesson.bounds.height);
+ const reference = lesson.strokes.map((stroke) => stroke.points);
+ // shortcut: provisional shape tolerances; calibrate with real finger and stylus attempts.
+ const tolerance = size * 0.055;
+ return (
+ coverage(samples(reference, size / 100), strokes, tolerance) >= 0.9 &&
+ coverage(samples(strokes, size / 100), reference, tolerance) >= 0.9
+ );
+}
diff --git a/src/features/writing/use-case/tracing/helpers/index.ts b/src/features/writing/use-case/tracing/helpers/index.ts
new file mode 100644
index 0000000..1487bcf
--- /dev/null
+++ b/src/features/writing/use-case/tracing/helpers/index.ts
@@ -0,0 +1,43 @@
+import { Result, Schema } from "effect";
+import { type LessonContent, LESSON } from "../../../domain/lessons";
+import type { Attempt, Point } from "../../../domain/strokes";
+
+export const EMPTY: Attempt = { strokes: [], active: null, feedback: "ready" };
+
+export function sameMatrix(left: DOMMatrix, right: DOMMatrix): boolean {
+ return (
+ left.a === right.a &&
+ left.b === right.b &&
+ left.c === right.c &&
+ left.d === right.d &&
+ left.e === right.e &&
+ left.f === right.f
+ );
+}
+
+export function toPoint(
+ svg: SVGSVGElement,
+ x: number,
+ y: number,
+): { readonly point: Point; readonly matrix: DOMMatrix } | null {
+ const matrix = svg.getScreenCTM();
+ if (!matrix || matrix.a * matrix.d - matrix.b * matrix.c === 0) return null;
+ const point = new DOMPoint(x, y).matrixTransform(matrix.inverse());
+ if (!Number.isFinite(point.x) || !Number.isFinite(point.y)) return null;
+ return { point: { x: point.x, y: point.y }, matrix };
+}
+
+export function parseLesson(input: unknown): LessonContent {
+ const result = Schema.decodeUnknownResult(LESSON)(input);
+ if (Result.isFailure(result)) {
+ return {
+ kind: "unusable",
+ message: "This lesson cannot be opened. Its content needs repair.",
+ };
+ }
+ return { kind: "ready", lesson: result.success };
+}
+
+export function pointsAttribute(points: readonly Point[]): string {
+ return points.map((point) => `${point.x},${point.y}`).join(" ");
+}
diff --git a/src/features/writing/use-case/tracing/helpers/session.ts b/src/features/writing/use-case/tracing/helpers/session.ts
new file mode 100644
index 0000000..72e9793
--- /dev/null
+++ b/src/features/writing/use-case/tracing/helpers/session.ts
@@ -0,0 +1,96 @@
+import { checkCharacter } from "./check-character";
+import type { Lesson } from "../../../domain/lessons";
+import type { Attempt, Point } from "../../../domain/strokes";
+import { EMPTY } from "./index";
+
+export interface Session {
+ readonly attempt: Attempt;
+}
+
+export const INITIAL_SESSION: Session = { attempt: EMPTY };
+
+export type SessionAction =
+ | { readonly type: "start"; readonly pointerId: number; readonly point: Point }
+ | { readonly type: "extend"; readonly point: Point }
+ | { readonly type: "commit"; readonly point: Point }
+ | { readonly type: "interrupt" }
+ | { readonly type: "reset" }
+ | { readonly type: "check"; readonly lesson: Lesson };
+
+export function reduceSession(session: Session, action: SessionAction): Session {
+ switch (action.type) {
+ case "start":
+ return {
+ ...session,
+ attempt: {
+ ...session.attempt,
+ active: { pointerId: action.pointerId, points: [action.point] },
+ feedback: "drawing",
+ },
+ };
+ case "extend":
+ return session.attempt.active
+ ? {
+ ...session,
+ attempt: {
+ ...session.attempt,
+ active: {
+ ...session.attempt.active,
+ points: [...session.attempt.active.points, action.point],
+ },
+ },
+ }
+ : session;
+ case "commit":
+ return session.attempt.active
+ ? {
+ ...session,
+ attempt: {
+ strokes: [
+ ...session.attempt.strokes,
+ [...session.attempt.active.points, action.point],
+ ],
+ active: null,
+ feedback: "ready",
+ },
+ }
+ : session;
+ case "interrupt":
+ return {
+ ...session,
+ attempt: { ...session.attempt, active: null, feedback: "interrupted" },
+ };
+ case "check":
+ if (session.attempt.active || session.attempt.strokes.length === 0) return session;
+ return {
+ ...session,
+ attempt: {
+ ...session.attempt,
+ feedback: checkCharacter(action.lesson, session.attempt.strokes)
+ ? "correct"
+ : "incorrect",
+ },
+ };
+ case "reset":
+ return INITIAL_SESSION;
+ }
+}
+
+export function feedbackMessage(input: {
+ readonly attempt: Attempt;
+ readonly ready: boolean;
+}): string {
+ const count = input.attempt.strokes.length;
+ let feedback = "Ready to trace. Follow the faint guide.";
+ if (count > 0)
+ feedback = `${count} ${count === 1 ? "stroke" : "strokes"} drawn. Keep tracing or start again.`;
+ if (input.attempt.feedback === "drawing")
+ feedback = "Drawing… Lift your finger or pen to finish this stroke.";
+ if (input.attempt.feedback === "interrupted")
+ feedback = "Stroke interrupted. Your finished strokes are safe. Try again.";
+ if (input.attempt.feedback === "correct") feedback = "That’s it! You matched the letter.";
+ if (input.attempt.feedback === "incorrect")
+ feedback = "Not quite yet. Follow the whole shape and try again.";
+ if (!input.ready) feedback = "Preparing the writing area…";
+ return feedback;
+}
diff --git a/src/features/writing/use-case/tracing/hooks/use-drawing.ts b/src/features/writing/use-case/tracing/hooks/use-drawing.ts
new file mode 100644
index 0000000..c22fa79
--- /dev/null
+++ b/src/features/writing/use-case/tracing/hooks/use-drawing.ts
@@ -0,0 +1,109 @@
+import { useCallback, useEffect, useReducer, useRef, useState } from "react";
+import type { PointerEvent } from "react";
+import type { Lesson } from "../../../domain/lessons";
+import type { Capture, Drawing } from "../../../domain/strokes";
+import { sameMatrix, toPoint } from "../helpers";
+import { INITIAL_SESSION, reduceSession } from "../helpers/session";
+
+export function useDrawing(lesson: Lesson): Drawing {
+ const padRef = useRef