feat(learner): progress, toast and menu motion

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-30 20:53:34 +01:00
co-authored by Claude Opus 5.5
parent d5e836cc79
commit 370d381ff4
4 changed files with 44 additions and 5 deletions
+13 -4
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
import type { AuthUser } from "@stc/contracts";
import { reducedMotion } from "@stc/ui";
import type { CoursePayload, LibraryPayload } from "../shared/types";
import { learnerCss } from "./styles";
@@ -154,14 +155,22 @@ export function clearCourseCache(): void { courseCache.clear(); courseVersion.se
/* ---------- toast ---------- */
const toastStore = store<string | null>(null);
export interface ToastState { text: string; leaving: boolean }
const toastStore = store<ToastState | null>(null);
let toastTimer: ReturnType<typeof setTimeout> | undefined;
/** How long the toast takes to fade out (--t-exit). */
const TOAST_EXIT_MS = 120;
export function toast(msg: string): void {
toastStore.set(msg);
toastStore.set({ text: msg, leaving: false });
clearTimeout(toastTimer);
toastTimer = setTimeout(() => toastStore.set(null), 2600);
toastTimer = setTimeout(() => {
const t = toastStore.get();
if (!t || reducedMotion()) { toastStore.set(null); return; }
toastStore.set({ ...t, leaving: true });
toastTimer = setTimeout(() => toastStore.set(null), TOAST_EXIT_MS);
}, 2600);
}
export function useToast(): string | null { return useStore(toastStore); }
export function useToast(): ToastState | null { return useStore(toastStore); }
/* ---------- formatting ---------- */
@@ -37,7 +37,7 @@ export function HeaderExtras({ course, lookMode, outline }: {
</div>,
host,
)}
{msg ? <div className="lr lr-toast" role="status">{msg}</div> : null}
{msg ? <div className={msg.leaving ? "lr lr-toast is-leaving" : "lr lr-toast"} role="status">{msg.text}</div> : null}
</>
);
}
@@ -342,6 +342,11 @@ export const learnerCss = String.raw`
.lr .stage canvas[data-front][data-dir="next"] { animation: stc-slide-next var(--t-enter) var(--ease-out) backwards; }
.lr .stage canvas[data-front][data-dir="prev"] { animation: stc-slide-prev var(--t-enter) var(--ease-out) backwards; }
.lr .stage canvas[data-leaving] { animation: stc-hold-out var(--t-enter) linear both; }
/* overlays answer the action that opened them; closing is instant except for the toast's short fade */
.lr-menu { animation: stc-drop var(--t-fast) var(--ease-out) backwards; }
.lr-shell.side-open .lr-scrim { animation: stc-fade-in var(--t-fast) var(--ease-out) backwards; }
.lr-toast { animation: stc-enter var(--t-enter) var(--ease-out) backwards; }
.lr-toast.is-leaving { animation: stc-fade-out var(--t-exit) var(--ease-out) both; }
/* a module list the user opens */
.lr-lessons[data-enter] { animation: stc-enter var(--t-enter) var(--ease-out) backwards; }
/* folds and hint tiers grow to their height where the browser can animate it, and open instantly elsewhere */