From your notes: the generator now stores a verbatim source quote with every activity (schema + prompt + a server-side clamp so a chatty model cannot drop a card), and the feedback panel shows the sentence that carries it with the quote highlighted green. Decks made before the change, and quotes the model trimmed, fall back to a local IDF-weighted match over the saved notes text (lib/notes-source.ts): nothing is shown unless at least two distinct terms land. The passage hides in exam mode with the other reveals. Read aloud: a Web Speech API speaker control on the text a learner reads (flashcard faces, questions, statements, sentences, instructions) via lib/speech.ts and components/SpeakButton.tsx — one utterance app-wide, a voice matched to the deck's language, and no button at all where the browser has no speech synthesis. It used to sit inside the flashcard's flip button (nested buttons, tapping it flipped the card); it now lives next to the hint row. Timed runs: the feed chrome carries a timer chip (off by default) that gives each card 30 seconds; a card that runs out is recorded as a miss through the same path as a wrong answer (history + SRS agree), shows a toast, and the feed moves straight on with a fresh budget. handleAnswered ignores a second result per card, so a timeout plus a manual answer cannot record twice. The generator's type spec now names what each type suits (language decks lean on flashcards/matching/fill blank, ordering needs a real sequence, short answer reasoning), so the deck-aware guidance covers all seven types. New activities-test suite (24 checks: quote path, local path, the green mark, the speech stub, the timed run on Playwright's fake clock), registered in run-all; phase2 pins that the notes passage stays hidden in exam mode; en+cs keys for every new string; no new dependencies. Version 0.6.1, SW v83.
431 lines
12 KiB
TypeScript
431 lines
12 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* Small inline SVG icons used for the app chrome so that critical controls
|
|
* don't depend on a system emoji font being installed.
|
|
*/
|
|
|
|
type IconProps = { className?: string; strokeWidth?: number };
|
|
|
|
const base = "size-5";
|
|
|
|
function Svg({
|
|
children,
|
|
className,
|
|
filled,
|
|
}: {
|
|
children: React.ReactNode;
|
|
className?: string;
|
|
filled?: boolean;
|
|
}) {
|
|
return (
|
|
<svg
|
|
viewBox="0 0 24 24"
|
|
className={className ?? base}
|
|
fill={filled ? "currentColor" : "none"}
|
|
stroke={filled ? "none" : "currentColor"}
|
|
strokeWidth={1.8}
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
aria-hidden="true"
|
|
>
|
|
{children}
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
export function IceCubeIcon({ className }: IconProps) {
|
|
return (
|
|
<Svg className={className}>
|
|
<path d="M12 3.2 19 7v10l-7 3.8L5 17V7l7-3.8Z" />
|
|
<path d="M5 7l7 3.8L19 7M12 10.8V21" />
|
|
</Svg>
|
|
);
|
|
}
|
|
|
|
export function SnowflakeIcon({ className }: IconProps) {
|
|
return (
|
|
<Svg className={className}>
|
|
<path d="M12 2v20M4.5 6.5l15 11M19.5 6.5l-15 11" />
|
|
<path d="M12 6l-2.2-2.2M12 6l2.2-2.2M12 18l-2.2 2.2M12 18l2.2 2.2M7 9.2 4.8 8.6M7 14.8l-2.2.6M17 9.2l2.2-.6M17 14.8l2.2.6" />
|
|
</Svg>
|
|
);
|
|
}
|
|
|
|
export function GearIcon({ className }: IconProps) {
|
|
return (
|
|
<Svg className={className}>
|
|
<circle cx="12" cy="12" r="3.2" />
|
|
<path d="M19.4 15a1.7 1.7 0 0 0 .34 1.87l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.7 1.7 0 0 0-1.87-.34 1.7 1.7 0 0 0-1.03 1.56V21a2 2 0 1 1-4 0v-.11A1.7 1.7 0 0 0 8.9 19.3a1.7 1.7 0 0 0-1.87.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.7 1.7 0 0 0 4.7 15a1.7 1.7 0 0 0-1.56-1.03H3a2 2 0 1 1 0-4h.11A1.7 1.7 0 0 0 4.7 8.9a1.7 1.7 0 0 0-.34-1.87l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.7 1.7 0 0 0 9 4.7a1.7 1.7 0 0 0 1.03-1.56V3a2 2 0 1 1 4 0v.11A1.7 1.7 0 0 0 15.1 4.7a1.7 1.7 0 0 0 1.87-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.7 1.7 0 0 0 19.4 9v.03A1.7 1.7 0 0 0 21 10.06H21a2 2 0 1 1 0 4h-.11A1.7 1.7 0 0 0 19.4 15Z" />
|
|
</Svg>
|
|
);
|
|
}
|
|
|
|
export function SunIcon({ className }: IconProps) {
|
|
return (
|
|
<Svg className={className}>
|
|
<circle cx="12" cy="12" r="4" />
|
|
<path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" />
|
|
</Svg>
|
|
);
|
|
}
|
|
|
|
export function MoonIcon({ className }: IconProps) {
|
|
return (
|
|
<Svg className={className}>
|
|
<path d="M21 12.8A8.5 8.5 0 1 1 11.2 3a6.6 6.6 0 0 0 9.8 9.8Z" />
|
|
</Svg>
|
|
);
|
|
}
|
|
|
|
export function BoltIcon({ className }: IconProps) {
|
|
return (
|
|
<Svg className={className} filled>
|
|
<path d="M13.5 2 4 13.2h5.4L9.5 22 19 10.8h-5.4L13.5 2Z" />
|
|
</Svg>
|
|
);
|
|
}
|
|
|
|
export function FlameIcon({ className }: IconProps) {
|
|
return (
|
|
<Svg className={className} filled>
|
|
<path d="M12 2s4.5 4.2 4.5 8.2c0 1.2-.5 2.2-1.2 2.9.1-1.6-.5-3.3-1.8-4.6.3 2.6-1 4-2.2 5.1-1 .9-1.8 1.8-1.8 3.2 0 1 .4 1.9 1.1 2.6-2.9-.3-5.1-2.6-5.1-5.5C5.5 9.7 12 8 12 2Z" />
|
|
<path d="M12 22c2.2 0 4-1.6 4-3.7 0-1.6-.8-2.4-1.7-3.3-.6-.6-1.3-1.3-1.6-2.3-.8.8-1.4 2-1.4 3.2 0 .9-.4 1.4-1 1.8-.5.4-1.1.9-1.1 1.9C9.2 21.1 10.4 22 12 22Z" />
|
|
</Svg>
|
|
);
|
|
}
|
|
|
|
export function SparklesIcon({ className }: IconProps) {
|
|
return (
|
|
<Svg className={className}>
|
|
<path d="M12 3l1.6 4.4L18 9l-4.4 1.6L12 15l-1.6-4.4L6 9l4.4-1.6L12 3Z" />
|
|
<path d="M18.5 15.5l.8 2.2 2.2.8-2.2.8-.8 2.2-.8-2.2-2.2-.8 2.2-.8.8-2.2Z" />
|
|
</Svg>
|
|
);
|
|
}
|
|
|
|
export function LayersIcon({ className }: IconProps) {
|
|
return (
|
|
<Svg className={className}>
|
|
<path d="M12 3.5 20 8l-8 4.5L4 8l8-4.5Z" />
|
|
<path d="m4.6 12.4 7.4 4.2 7.4-4.2M4.6 16.4l7.4 4.2 7.4-4.2" />
|
|
</Svg>
|
|
);
|
|
}
|
|
|
|
export function FolderIcon({ className }: IconProps) {
|
|
return (
|
|
<Svg className={className}>
|
|
<path d="M3 7.5A1.5 1.5 0 0 1 4.5 6h4l2 2.5h9A1.5 1.5 0 0 1 21 10v8a1.5 1.5 0 0 1-1.5 1.5h-15A1.5 1.5 0 0 1 3 18V7.5Z" />
|
|
</Svg>
|
|
);
|
|
}
|
|
|
|
export function CameraIcon({ className }: IconProps) {
|
|
return (
|
|
<Svg className={className}>
|
|
<path d="M4 8.5A1.5 1.5 0 0 1 5.5 7h1.9l1.1-1.8A1 1 0 0 1 9.4 5h5.2a1 1 0 0 1 .9.5L16.6 7h1.9A1.5 1.5 0 0 1 20 8.5v9A1.5 1.5 0 0 1 18.5 19h-13A1.5 1.5 0 0 1 4 17.5v-9Z" />
|
|
<circle cx="12" cy="12.8" r="3.2" />
|
|
</Svg>
|
|
);
|
|
}
|
|
|
|
export function PlayIcon({ className }: IconProps) {
|
|
return (
|
|
<Svg className={className} filled>
|
|
<path d="M8 5.5v13l11-6.5-11-6.5Z" />
|
|
</Svg>
|
|
);
|
|
}
|
|
|
|
export function DownloadIcon({ className }: IconProps) {
|
|
return (
|
|
<Svg className={className}>
|
|
<path d="M12 3v12M7.5 10.5 12 15l4.5-4.5M4 20h16" />
|
|
</Svg>
|
|
);
|
|
}
|
|
|
|
export function UploadIcon({ className }: IconProps) {
|
|
return (
|
|
<Svg className={className}>
|
|
<path d="M12 16V4M7.5 8.5 12 4l4.5 4.5M4 20h16" />
|
|
</Svg>
|
|
);
|
|
}
|
|
|
|
export function HomeIcon({ className }: IconProps) {
|
|
return (
|
|
<Svg className={className}>
|
|
<path d="M4 10.5 12 4l8 6.5V19a1 1 0 0 1-1 1h-4v-5H9v5H5a1 1 0 0 1-1-1v-8.5Z" />
|
|
</Svg>
|
|
);
|
|
}
|
|
|
|
export function CheckCircleIcon({ className }: IconProps) {
|
|
return (
|
|
<Svg className={className}>
|
|
<circle cx="12" cy="12" r="9" />
|
|
<path d="m8.2 12.4 2.6 2.6 5-5.4" />
|
|
</Svg>
|
|
);
|
|
}
|
|
|
|
export function AlertTriangleIcon({ className }: IconProps) {
|
|
return (
|
|
<Svg className={className}>
|
|
<path d="M12 4.5 21 19.5H3L12 4.5Z" />
|
|
<path d="M12 10v4.2M12 17.2v.1" />
|
|
</Svg>
|
|
);
|
|
}
|
|
|
|
export function XCircleIcon({ className }: IconProps) {
|
|
return (
|
|
<Svg className={className}>
|
|
<circle cx="12" cy="12" r="9" />
|
|
<path d="m9 9 6 6M15 9l-6 6" />
|
|
</Svg>
|
|
);
|
|
}
|
|
|
|
export function PlusIcon({ className }: IconProps) {
|
|
return (
|
|
<Svg className={className}>
|
|
<path d="M12 5v14M5 12h14" />
|
|
</Svg>
|
|
);
|
|
}
|
|
|
|
/** A chat bubble with a plus: "new chat" — distinct from the plain plus the
|
|
* rail uses for "new deck" (two identical + buttons read as the same thing). */
|
|
export function ChatPlusIcon({ className }: IconProps) {
|
|
return (
|
|
<Svg className={className}>
|
|
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5Z" />
|
|
<path d="M12 8.5v5M9.5 11h5" />
|
|
</Svg>
|
|
);
|
|
}
|
|
|
|
export function ShareIcon({ className }: IconProps) {
|
|
return (
|
|
<Svg className={className}>
|
|
<path d="M12 3v12M8.2 6.8 12 3l3.8 3.8" />
|
|
<path d="M6 12H5a1 1 0 0 0-1 1v7a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-7a1 1 0 0 0-1-1h-1" />
|
|
</Svg>
|
|
);
|
|
}
|
|
|
|
export function ChevronLeftIcon({ className }: IconProps) {
|
|
return (
|
|
<Svg className={className}>
|
|
<path d="M14.5 5.5 8 12l6.5 6.5" />
|
|
</Svg>
|
|
);
|
|
}
|
|
|
|
export function ChevronRightIcon({ className }: IconProps) {
|
|
return (
|
|
<Svg className={className}>
|
|
<path d="m9.5 5.5 6.5 6.5-6.5 6.5" />
|
|
</Svg>
|
|
);
|
|
}
|
|
|
|
export function RefreshIcon({ className }: IconProps) {
|
|
return (
|
|
<Svg className={className}>
|
|
<path d="M20 12a8 8 0 1 1-2.4-5.7M20 4v4h-4" />
|
|
</Svg>
|
|
);
|
|
}
|
|
|
|
export function EyeIcon({ className }: IconProps) {
|
|
return (
|
|
<Svg className={className}>
|
|
<path d="M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12Z" />
|
|
<circle cx="12" cy="12" r="3" />
|
|
</Svg>
|
|
);
|
|
}
|
|
|
|
export function EyeOffIcon({ className }: IconProps) {
|
|
return (
|
|
<Svg className={className}>
|
|
<path d="m3 3 18 18" />
|
|
<path d="M10.6 5.7c.5-.1.9-.2 1.4-.2 6 0 9.5 6.5 9.5 6.5a17.5 17.5 0 0 1-2.6 3.5" />
|
|
<path d="M6.6 6.8A16.6 16.6 0 0 0 2.5 12S6 18.5 12 18.5c1.4 0 2.7-.4 3.8-1" />
|
|
<path d="M9.9 9.9a3 3 0 0 0 4.2 4.2" />
|
|
</Svg>
|
|
);
|
|
}
|
|
|
|
export function QuestionIcon({ className }: IconProps) {
|
|
return (
|
|
<Svg className={className}>
|
|
<circle cx="12" cy="12" r="9" />
|
|
<path d="M9.7 9.4a2.5 2.5 0 0 1 4.9.7c0 1.7-2.5 2-2.5 3.5" />
|
|
<path d="M12 17h.01" />
|
|
</Svg>
|
|
);
|
|
}
|
|
|
|
export function XIcon({ className }: IconProps) {
|
|
return (
|
|
<Svg className={className}>
|
|
<path d="m6 6 12 12M18 6 6 18" />
|
|
</Svg>
|
|
);
|
|
}
|
|
|
|
export function ExternalLinkIcon({ className }: IconProps) {
|
|
return (
|
|
<Svg className={className}>
|
|
<path d="M14 4h6v6" />
|
|
<path d="m20 4-8.5 8.5" />
|
|
<path d="M18 13.5V19a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5.5" />
|
|
</Svg>
|
|
);
|
|
}
|
|
|
|
/* --- Install-sheet chips ------------------------------------------------ */
|
|
|
|
export function CompassIcon({ className }: IconProps) {
|
|
return (
|
|
<Svg className={className}>
|
|
<circle cx="12" cy="12" r="9" />
|
|
<path d="m15.6 8.4-2.3 5.3-5.3 2.3 2.3-5.3 5.3-2.3Z" />
|
|
</Svg>
|
|
);
|
|
}
|
|
|
|
export function EllipsisIcon({ className }: IconProps) {
|
|
return (
|
|
<Svg className={className}>
|
|
<circle cx="5.5" cy="12" r="1.4" fill="currentColor" stroke="none" />
|
|
<circle cx="12" cy="12" r="1.4" fill="currentColor" stroke="none" />
|
|
<circle cx="18.5" cy="12" r="1.4" fill="currentColor" stroke="none" />
|
|
</Svg>
|
|
);
|
|
}
|
|
|
|
export function ChevronDownIcon({ className }: IconProps) {
|
|
return (
|
|
<Svg className={className}>
|
|
<path d="m6 9.5 6 6 6-6" />
|
|
</Svg>
|
|
);
|
|
}
|
|
|
|
export function AddToHomeIcon({ className }: IconProps) {
|
|
return (
|
|
<Svg className={className}>
|
|
<rect x="4" y="4" width="16" height="16" rx="4.5" />
|
|
<path d="M12 8.8v6.4M8.8 12h6.4" />
|
|
</Svg>
|
|
);
|
|
}
|
|
|
|
/* --- Chat --------------------------------------------------------------- */
|
|
|
|
export function ChatIcon({ className }: IconProps) {
|
|
return (
|
|
<Svg className={className}>
|
|
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5Z" />
|
|
</Svg>
|
|
);
|
|
}
|
|
|
|
export function SendIcon({ className }: IconProps) {
|
|
return (
|
|
<Svg className={className}>
|
|
<path d="M12 19V5" />
|
|
<path d="m6 11 6-6 6 6" />
|
|
</Svg>
|
|
);
|
|
}
|
|
|
|
export function StopIcon({ className }: IconProps) {
|
|
return (
|
|
<Svg className={className}>
|
|
<rect x="7" y="7" width="10" height="10" rx="2" fill="currentColor" stroke="none" />
|
|
</Svg>
|
|
);
|
|
}
|
|
|
|
export function CopyIcon({ className }: IconProps) {
|
|
return (
|
|
<Svg className={className}>
|
|
<rect x="9" y="9" width="11" height="11" rx="2.5" />
|
|
<path d="M5.5 15H5a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h9a1 1 0 0 1 1 1v.5" />
|
|
</Svg>
|
|
);
|
|
}
|
|
|
|
export function PaperclipIcon({ className }: IconProps) {
|
|
return (
|
|
<Svg className={className}>
|
|
<path d="m20 11.5-7.8 7.8a5 5 0 0 1-7-7l8.3-8.3a3.3 3.3 0 0 1 4.7 4.7l-8.3 8.3a1.7 1.7 0 0 1-2.4-2.4l7.4-7.4" />
|
|
</Svg>
|
|
);
|
|
}
|
|
|
|
export function PencilIcon({ className }: IconProps) {
|
|
return (
|
|
<Svg className={className}>
|
|
<path d="M4 20h4.2L20 8.2a2.1 2.1 0 0 0-3-3L5.2 17 4 20Z" />
|
|
<path d="m14.5 6.7 2.8 2.8" />
|
|
</Svg>
|
|
);
|
|
}
|
|
|
|
export function HistoryIcon({ className }: IconProps) {
|
|
return (
|
|
<Svg className={className}>
|
|
<path d="M4.5 12a7.5 7.5 0 1 1 2.2 5.3" />
|
|
<path d="M4.5 12H2m2.5 0 2.3-2.3M4.5 12l2.3 2.3" />
|
|
<path d="M12 8.5V12l2.4 1.6" />
|
|
</Svg>
|
|
);
|
|
}
|
|
|
|
export function ChromeIcon({ className }: IconProps) {
|
|
return (
|
|
<Svg className={className}>
|
|
<circle cx="12" cy="12" r="9" />
|
|
<circle cx="12" cy="12" r="3.4" />
|
|
<path d="M12 8.6V3M9.05 13.7 4.2 16.5M14.95 13.7l4.85 2.8" />
|
|
</Svg>
|
|
);
|
|
}
|
|
|
|
/** Read-aloud control (study cards); the waves light up while speaking. */
|
|
export function SpeakerIcon({ className }: IconProps) {
|
|
return (
|
|
<Svg className={className}>
|
|
<path d="M4.5 9.4h2.9l4-3.2v11.6l-4-3.2H4.5z" />
|
|
<path d="M15.2 9.2a3.9 3.9 0 0 1 0 5.6M17.7 6.7a7.3 7.3 0 0 1 0 10.6" />
|
|
</Svg>
|
|
);
|
|
}
|
|
|
|
/** Timed runs. */
|
|
export function ClockIcon({ className }: IconProps) {
|
|
return (
|
|
<Svg className={className}>
|
|
<circle cx="12" cy="12" r="8.2" />
|
|
<path d="M12 7.4V12l3.1 1.9" />
|
|
</Svg>
|
|
);
|
|
}
|
|
|
|
/** "From your notes" — a page with a folded corner and text lines. */
|
|
export function NotesIcon({ className }: IconProps) {
|
|
return (
|
|
<Svg className={className}>
|
|
<path d="M6.4 3.6h7.4l4.8 4.8v12H6.4z" />
|
|
<path d="M13.6 3.9v4.7h4.7M9.4 12.4h5.2M9.4 15.8h5.2" />
|
|
</Svg>
|
|
);
|
|
}
|