21 Commits

Author SHA1 Message Date
21dbe336ca Merge pull request 'web' (#1) from web into main
Reviewed-on: #1
2026-03-11 20:41:05 +00:00
bd35f6a852 chore(build): refactor codebase for production 2026-03-12 02:39:34 +06:00
121cc2bf71 feat(quests): improve 3d island styling
fix(test): fix context image not appearing on test screen
2026-03-12 01:09:07 +06:00
f00aad2bbd feat(quests): add 3d functionality for quests 2026-03-11 03:17:08 +06:00
575d392afc fix(quests): fix island rendering problems on desktop view 2026-03-11 00:59:31 +06:00
c09ecd7926 fix: sidebar spacing and topics overflowing 2026-03-10 07:38:56 +06:00
a1295a0eb3 fix(lesson): fix lesson modal title 2026-03-10 00:53:08 +06:00
59e601052f feat(lessons): add new lessons for english section 2026-03-09 16:41:06 +06:00
b5edb3554f feat(auth): add registration page 2026-03-09 15:05:09 +06:00
8dbadae58c fix(test): fix directions text 2026-03-04 20:54:25 +06:00
980eb130e2 fix: duplicate code related bug 2026-03-04 05:07:19 +06:00
bd3974e2f0 fix: responsive inventory 2026-03-04 03:34:31 +06:00
a08476ec53 Merge branch 'web' of https://git.omukk.dev/shafin808s/edbridge-scholars into web 2026-03-04 03:26:29 +06:00
437c7a517f feat: Quests page responsiveness and sidebar enhancements 2026-03-04 03:25:50 +06:00
c35f328e30 feat: responsive for web with sidebar and different styling for test ui on web 2026-03-04 03:24:17 +06:00
e75233929a feat: Quests page responsiveness and sidebar enhancements 2026-03-04 03:22:22 +06:00
f154ebf033 Merge branch 'web' of https://git.omukk.dev/shafin808s/edbridge-scholars into web 2026-03-03 23:31:57 +06:00
634c67b741 feat: responsive for web with sidebar and different styling for test ui on web 2026-03-03 23:29:20 +06:00
4df5707ebd Merge branch 'web' of https://git.omukk.dev/shafin808s/edbridge-scholars into web 2026-02-27 05:17:12 +06:00
e7db0a5d31 feat: responsive for web with sidebar and different styling for test ui on web 2026-02-27 05:13:06 +06:00
7dfa73c397 feat: responsive for web with sidebar and different styling for test ui on web 2026-02-27 02:36:54 +06:00
143 changed files with 25441 additions and 5380 deletions

8044
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -15,6 +15,8 @@
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.5.0",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"canvas-confetti": "^1.9.4", "canvas-confetti": "^1.9.4",
@ -23,6 +25,7 @@
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"framer-motion": "^12.30.0", "framer-motion": "^12.30.0",
"katex": "^0.16.28", "katex": "^0.16.28",
"leva": "^0.10.1",
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
"react": "^19.2.0", "react": "^19.2.0",
@ -31,6 +34,7 @@
"react-router-dom": "^7.12.0", "react-router-dom": "^7.12.0",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"three": "^0.183.2",
"vaul": "^1.1.2", "vaul": "^1.1.2",
"zustand": "^5.0.9" "zustand": "^5.0.9"
}, },

View File

@ -19,9 +19,8 @@ import { StudentLayout } from "./pages/student/StudentLayout";
import { TargetedPractice } from "./pages/student/targeted-practice/page"; import { TargetedPractice } from "./pages/student/targeted-practice/page";
import { Drills } from "./pages/student/drills/page"; import { Drills } from "./pages/student/drills/page";
import { HardTestModules } from "./pages/student/hard-test-modules/page"; import { HardTestModules } from "./pages/student/hard-test-modules/page";
import { Analytics } from "./pages/student/Analytics";
import { QuestMap } from "./pages/student/QuestMap"; import { QuestMap } from "./pages/student/QuestMap";
import ErrorPage from "./pages/ErrorPage"; import { Register } from "./pages/auth/Register";
function App() { function App() {
const router = createBrowserRouter([ const router = createBrowserRouter([
@ -29,12 +28,17 @@ function App() {
path: "/login", path: "/login",
element: <Login />, element: <Login />,
}, },
{
path: "/register",
element: <Register />,
},
{ {
path: "/student", path: "/student",
element: <ProtectedRoute />, element: <ProtectedRoute />,
children: [ children: [
{ {
element: <StudentLayout />, element: <StudentLayout />,
children: [ children: [
{ {
path: "home", path: "home",
@ -56,10 +60,6 @@ function App() {
path: "profile", path: "profile",
element: <Profile />, element: <Profile />,
}, },
{
path: "analytics",
element: <Analytics />,
},
{ {
path: "quests", path: "quests",
element: <QuestMap />, element: <QuestMap />,

View File

@ -1,5 +1,4 @@
import { import {
Sidebar,
SidebarContent, SidebarContent,
SidebarHeader, SidebarHeader,
SidebarFooter, SidebarFooter,
@ -15,202 +14,561 @@ import {
ChevronDown, ChevronDown,
BookOpen, BookOpen,
Home, Home,
Video,
User,
Target, Target,
Zap, Zap,
Trophy, Trophy,
LayoutGrid, Map,
SquareLibrary,
} from "lucide-react"; } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import logo from "../assets/ed_logo1.png"; import logo from "../assets/ed_logo1.png";
import { NavLink } from "react-router-dom"; import { NavLink, useNavigate, useLocation } from "react-router-dom";
import { useAuthStore } from "../stores/authStore"; import { useAuthStore } from "../stores/authStore";
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
export function AppSidebar() { export function AppSidebar() {
const [open, setOpen] = useState(true); const [open, setOpen] = useState(false);
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const navigate = useNavigate();
const location = useLocation();
const isQuestPage = location.pathname.startsWith("/student/quests");
const STYLES = `
/* ══ DEFAULT sidebar (cream frosted glass) ══ */
.as-sidebar-container {
position: fixed;
top: 0.5rem;
bottom: 0.5rem;
left: 0.5rem;
width: 16rem;
z-index: 10;
border-radius: 1.75rem;
overflow: hidden;
pointer-events: auto;
background: rgba(255,251,244,0.72);
backdrop-filter: blur(24px) saturate(180%);
-webkit-backdrop-filter: blur(24px) saturate(180%);
border: 1.5px solid rgba(255,255,255,0.7);
box-shadow:
0 8px 32px rgba(0,0,0,0.12),
0 2px 8px rgba(0,0,0,0.06),
inset 0 1px 0 rgba(255,255,255,0.8);
transition:
background 0.4s ease,
border-color 0.4s ease,
box-shadow 0.4s ease,
z-index 0.3s ease;
display: flex;
flex-direction: column;
height: calc(100vh - 1rem);
}
/* ══ QUEST mode sidebar (dark navy pirate + gold) ══ */
.as-sidebar-container.quest-mode {
background: linear-gradient(
180deg,
rgba(251,191,36,0.12) 0%,
rgba(251,191,36,0.08) 50%,
rgba(251,191,36,0.1) 100%
);
background-size: 100% 200%;
animation: asSweepDown 3s linear infinite;
border-color: rgba(251,191,36,0.28);
box-shadow:
0 8px 32px rgba(0,0,0,0.25),
0 2px 8px rgba(0,0,0,0.15),
inset 0 1px 0 rgba(255,255,255,0.08);
}
/* Shimmer animation from top to bottom */
@keyframes asSweepDown {
0% { background-position: 0% 200%; }
100% { background-position: 0% -200%; }
}
/* On quest page, sidebar stays visible above content */
.as-sidebar-container.quest-mode {
z-index: 40;
}
.as-sidebar-inner {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
height: 100%;
}
.as-gradient-overlay {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 0;
}
.as-gradient-overlay.default {
background: radial-gradient(
circle at top,
rgba(168,85,247,0.18),
transparent 55%
),
radial-gradient(
circle at bottom,
rgba(249,115,22,0.1),
transparent 55%
);
}
.as-gradient-overlay.quest {
background: transparent;
}
/* Ensure Sidebar sub-components are transparent */
.as-sidebar-inner > * {
background: transparent;
}
.as-sidebar-inner [class*="SidebarHeader"],
.as-sidebar-inner [class*="SidebarContent"],
.as-sidebar-inner [class*="SidebarFooter"],
.as-sidebar-inner [class*="SidebarGroup"],
.as-sidebar-inner [class*="SidebarMenu"] {
background: transparent;
}
/* Quest mode text visibility */
.as-sidebar-container.quest-mode [class*="SidebarGroupLabel"] {
color: rgba(255, 255, 255, 0.4) !important;
}
.as-sidebar-container.quest-mode a {
color: rgba(255, 255, 255, 0.6) !important;
background: transparent !important;
}
.as-sidebar-container.quest-mode a:hover {
color: rgba(255, 255, 255, 0.85) !important;
background: rgba(255, 255, 255, 0.08) !important;
}
.as-sidebar-container.quest-mode a.active {
color: #fbbf24 !important;
}
.as-sidebar-container.quest-mode span {
color: inherit;
}
/* Quest header text */
.as-sidebar-container.quest-mode .text-slate-900 {
color: rgba(255, 255, 255, 0.9) !important;
}
.as-sidebar-container.quest-mode .text-slate-400 {
color: rgba(255, 255, 255, 0.4) !important;
}
/* Quest mode removes white hover background from menu buttons */
.as-sidebar-container.quest-mode [class*="SidebarMenuButton"]:hover {
background: transparent !important;
}
.as-sidebar-container.quest-mode [class*="SidebarMenuButton"] {
background: transparent !important;
}
/* Prevent group-hover from adding white background in quest mode */
.as-sidebar-container.quest-mode .group:hover {
background: transparent !important;
}
/* Quest mode footer button styling */
.as-sidebar-container.quest-mode [class*="SidebarFooter"] button {
background: rgba(255, 255, 255, 0.08) !important;
--tw-ring-color: rgba(255, 255, 255, 0.05) !important;
border-color: rgba(255, 255, 255, 0.05) !important;
}
.as-sidebar-container.quest-mode [class*="SidebarFooter"] button:hover {
background: rgba(255, 255, 255, 0.12) !important;
}
/* Override Tailwind bg-white and ring classes for quest mode */
.as-sidebar-container.quest-mode button[class*="bg-white"] {
background-color: rgba(255, 255, 255, 0.08) !important;
}
.as-sidebar-container.quest-mode button[class*="ring-white"]:not(:hover) {
--tw-ring-color: rgba(255, 255, 255, 0.05) !important;
border-color: rgba(255, 255, 255, 0.05) !important;
}
.as-sidebar-container.quest-mode button:hover[class*="hover:bg-white"] {
background-color: rgba(255, 255, 255, 0.12) !important;
}
.as-sidebar-container.quest-mode a:hover {
--tw-ring-color: rgba(255, 255, 255, 0.05) !important;
}
`;
return ( return (
<Sidebar className="border-r bg-black text-white"> <>
<style>{STYLES}</style>
<div
className={`as-sidebar-container${isQuestPage ? " quest-mode" : ""}`}
>
<div
className={`as-gradient-overlay ${isQuestPage ? "quest" : "default"}`}
/>
<div className="as-sidebar-inner">
{/* HEADER */} {/* HEADER */}
<SidebarHeader> <SidebarHeader className="px-3 pb-4 pt-1">
<div className="flex items-center justify-between px-2 py-2 rounded-lg hover:bg-white/10 cursor-pointer"> <div className="flex items-center justify-start gap-2">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3 rounded-2xl px-2 py-2">
<div className="flex rounded-md w-10 h-10 border overflow-hidden"> <div className="flex h-10 w-10 items-center justify-center overflow-hidden rounded-full bg-linear-to-br from-purple-400 to-purple-500 shadow-[0_6px_18px_rgba(168,85,247,0.55)]">
<img <img
src={logo} src={logo}
className="w-full h-full object-cover object-left" className="h-full w-full object-cover object-left"
alt="Logo" alt="Logo"
/> />
</div> </div>
<div className="flex flex-col text-sm"> <div className="flex flex-col text-sm">
<span className="font-satoshi-medium text-black"> <span className="font-satoshi-medium text-slate-900">
Edbridge Scholars Edbridge Scholars
</span> </span>
<span className="text-xs text-gray-400 font-satoshi"> <span className="font-satoshi text-xs text-slate-400">
Student Student
</span> </span>
</div> </div>
</div> </div>
<ChevronDown size={16} />
</div> </div>
</SidebarHeader> </SidebarHeader>
{/* CONTENT */} {/* CONTENT */}
<SidebarContent> <SidebarContent className="px-1">
<SidebarGroup> <SidebarGroup>
<SidebarGroupLabel className="text-gray-400 font-satoshi"> <SidebarGroupLabel className="px-2 text-[0.7rem] font-satoshi tracking-[0.16em] text-slate-400">
Platform PLATFORM
</SidebarGroupLabel> </SidebarGroupLabel>
<SidebarMenu> <SidebarMenu className="mt-1 space-y-1.5">
{/* HOME */}
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton asChild> <SidebarMenuButton
asChild
className="group cursor-pointer px-2 py-2.5 transition-colors duration-200"
>
<NavLink <NavLink
to="/student/home" to="/student/home"
className={({ isActive }) => className={({ isActive }) =>
`flex items-center gap-2.5 text-sm font-satoshi rounded-2xl px-2 py-2.5 transition-all duration-200 ${
isActive isActive
? "bg-zinc-800 text-white" ? "text-slate-900"
: "text-zinc-400 hover:bg-zinc-800" : "text-slate-500 group-hover:text-slate-900"
}`
} }
> >
<Home size={18} className="text-black" /> {({ isActive }) => (
<span className="font-satoshi text-black">Home</span> <>
<Home
size={18}
strokeWidth={3}
className={
isActive ? "text-orange-400" : "text-slate-400"
}
/>
<span
className={
isActive
? "text-orange-400 font-extrabold"
: "text-slate-400 font-bold"
}
>
Home
</span>
</>
)}
</NavLink> </NavLink>
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
<SidebarMenuItem> {/* PRACTICE */}
<SidebarMenuButton <SidebarMenuItem
className="cursor-pointer" onMouseEnter={() => setOpen(true)}
asChild onMouseLeave={() => setOpen(false)}
onClick={() => setOpen(!open)}
> >
<div> <SidebarMenuButton
<BookOpen size={18} className="text-black" /> className="group cursor-pointer px-2 py-2.5 transition-colors duration-200"
<span className="font-satoshi text-black">Practice</span> asChild
>
<NavLink
to="/student/practice"
className={({ isActive }) =>
`flex items-center gap-2.5 text-sm font-satoshi rounded-2xl px-2 py-2.5 transition-all duration-200 ${
isActive
? "text-slate-900"
: "text-slate-500 group-hover:text-slate-900"
}`
}
>
{({ isActive }) => (
<>
<BookOpen
size={18}
strokeWidth={3}
className={
isActive ? "text-purple-500" : "text-slate-400"
}
/>
<span
className={
isActive
? "text-purple-500 font-extrabold"
: "text-slate-400 font-bold"
}
>
Practice
</span>
<ChevronDown <ChevronDown
size={16} size={16}
className={`ml-auto transition-transform ${ strokeWidth={3}
className={`ml-auto text-slate-400 transition-transform ${
open ? "rotate-180" : "" open ? "rotate-180" : ""
}`} }`}
/> />
</div> </>
)}
</NavLink>
</SidebarMenuButton> </SidebarMenuButton>
{open && ( {open && (
<SidebarMenuSub className="space-y-3 mt-2"> <SidebarMenuSub className="mt-2 space-y-1.5 pl-3">
<NavLink
to="/student/practice"
className="text-black text-sm flex items-center gap-3"
>
<LayoutGrid size={18} className="text-black" />
<span className="font-satoshi text-black">
Practice your way
</span>
</NavLink>
<NavLink <NavLink
to="/student/practice/targeted-practice" to="/student/practice/targeted-practice"
className="text-black text-sm flex items-center gap-3" className={({ isActive }) =>
`flex items-center gap-2.5 rounded-2xl px-2 py-2 text-sm font-satoshi transition-colors duration-200 ${
isActive
? "bg-white text-slate-900"
: "text-slate-500 hover:bg-white hover:text-slate-900"
}`
}
> >
<Target size={18} className="text-black" /> <Target
<span className="font-satoshi text-black"> size={18}
Targeted Practice strokeWidth={3}
</span> className="text-slate-400"
/>
<span>Targeted Practice</span>
</NavLink> </NavLink>
<NavLink <NavLink
to="/student/practice/drills" to="/student/practice/drills"
className="text-black text-sm flex items-center gap-3" className={({ isActive }) =>
`flex items-center gap-2.5 rounded-2xl px-2 py-2 text-sm font-satoshi transition-colors duration-200 ${
isActive
? "bg-white text-slate-900"
: "text-slate-500 hover:bg-white hover:text-slate-900"
}`
}
> >
<Zap size={18} className="text-black" /> <Zap
<span className="font-satoshi text-black">Drills</span> size={18}
strokeWidth={3}
className="text-slate-400"
/>
<span>Drills</span>
</NavLink> </NavLink>
<NavLink <NavLink
to="/student/practice/hard-test-modules" to="/student/practice/hard-test-modules"
className="text-black text-sm flex items-center gap-3" className={({ isActive }) =>
`flex items-center gap-2.5 rounded-2xl px-2 py-2 text-sm font-satoshi transition-colors duration-200 ${
isActive
? "bg-white text-slate-900"
: "text-slate-500 hover:bg-white hover:text-slate-900"
}`
}
> >
<Trophy size={18} className="text-black" /> <Trophy
<span className="font-satoshi text-black"> size={18}
Hard Test Modules strokeWidth={3}
</span> className="text-slate-400"
/>
<span>Hard Test Modules</span>
</NavLink> </NavLink>
</SidebarMenuSub> </SidebarMenuSub>
)} )}
</SidebarMenuItem> </SidebarMenuItem>
{/* DOCS */} {/* QUESTS */}
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton
asChild
className="group cursor-pointer px-2 py-2.5 transition-colors duration-200"
>
<NavLink <NavLink
to={`/student/lessons`} to="/student/quests"
className={({ isActive }) => className={({ isActive }) => {
isActive if (isActive && isQuestPage) {
? "bg-zinc-800 text-white" return "flex items-center gap-2.5 text-sm rounded-2xl px-2 py-2.5 transition-all duration-200";
: "text-zinc-400 hover:bg-zinc-800" }
if (isActive) {
return "flex items-center gap-2.5 text-sm font-satoshi rounded-2xl px-2 py-2.5 transition-all duration-200 text-slate-900";
}
return "flex items-center gap-2.5 text-sm font-satoshi rounded-2xl px-2 py-2.5 transition-all duration-200 text-slate-500 group-hover:text-slate-900";
}}
>
{({ isActive }) => (
<>
<Map
size={18}
strokeWidth={3}
className={
isActive && isQuestPage
? "text-amber-400"
: isActive
? "text-blue-500"
: "text-slate-400"
}
/>
<span
className={
isActive && isQuestPage
? ""
: isActive
? "text-blue-500 font-extrabold"
: "text-slate-400 font-bold"
}
style={
isActive && isQuestPage
? {
fontFamily: "'Sorts Mill Goudy', serif",
fontSize: "0.95rem",
fontWeight: 900,
letterSpacing: "0.05em",
color: "#fbbf24",
textShadow: "0 0 12px rgba(251,191,36,0.5)",
}
: {}
} }
> >
<SidebarMenuButton className="cursor-pointer"> Quests
<Video size={18} className="text-black" /> </span>
<span className="text-black font-satoshi">Lessons</span> </>
</SidebarMenuButton> )}
</NavLink> </NavLink>
</SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
{/* SETTINGS */} {/* LESSONS */}
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton
asChild
className="group cursor-pointer px-2 py-2.5 transition-colors duration-200"
>
<NavLink <NavLink
to={`/student/rewards`} to="/student/lessons"
className={({ isActive }) => className={({ isActive }) =>
`flex items-center gap-2.5 text-sm font-satoshi rounded-2xl px-2 py-2.5 transition-all duration-200 ${
isActive isActive
? "bg-zinc-800 text-white" ? "text-slate-900"
: "text-zinc-400 hover:bg-zinc-800" : "text-slate-500 group-hover:text-slate-900"
}`
} }
> >
<SidebarMenuButton className="cursor-pointer"> {({ isActive }) => (
<Trophy size={18} className="text-black" /> <>
<span className="text-black font-satoshi">Rewards</span> <SquareLibrary
</SidebarMenuButton> size={18}
strokeWidth={3}
className={
isActive ? "text-cyan-500" : "text-slate-400"
}
/>
<span
className={
isActive
? "text-cyan-500 font-extrabold"
: "text-slate-400 font-bold"
}
>
Lessons
</span>
</>
)}
</NavLink> </NavLink>
</SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
{/* REWARDS */}
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton
asChild
className="group cursor-pointer px-2 py-2.5 transition-colors duration-200"
>
<NavLink <NavLink
to={`/student/profile`} to="/student/rewards"
className={({ isActive }) => className={({ isActive }) =>
`flex items-center gap-2.5 text-sm font-satoshi rounded-2xl px-2 py-2.5 transition-all duration-200 ${
isActive isActive
? "bg-zinc-800 text-white" ? "text-slate-900"
: "text-zinc-400 hover:bg-zinc-800" : "text-slate-500 group-hover:text-slate-900"
}`
} }
> >
<SidebarMenuButton className="cursor-pointer"> {({ isActive }) => (
<User size={18} className="text-black" /> <>
<span className="text-black font-satoshi">Profile</span> <Trophy
</SidebarMenuButton> size={18}
strokeWidth={3}
className={
isActive ? "text-emerald-500" : "text-slate-400"
}
/>
<span
className={
isActive
? "text-emerald-500 font-extrabold"
: "text-slate-400 font-bold"
}
>
Rewards
</span>
</>
)}
</NavLink> </NavLink>
</SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
</SidebarMenu> </SidebarMenu>
</SidebarGroup> </SidebarGroup>
</SidebarContent> </SidebarContent>
{/* FOOTER */} {/* FOOTER links to profile */}
<SidebarFooter> <SidebarFooter className="mt-auto px-3 pb-3 pt-4">
<div className="flex items-center gap-3 px-2 py-2 rounded-lg hover:bg-white/10 cursor-pointer"> <button
type="button"
onClick={() => navigate("/student/profile")}
className="flex w-full items-center gap-3 rounded-2xl bg-white/60 px-3 py-2 text-left shadow-sm ring-1 ring-white/80 hover:bg-white"
>
<Avatar> <Avatar>
<AvatarImage src={user?.avatar_url} /> <AvatarImage src={user?.avatar_url} />
<AvatarFallback className="font-satoshi-bold bg-linear-to-br from-purple-400 to-purple-500 uppercase"> <AvatarFallback className="bg-linear-to-br from-purple-400 to-purple-500 font-satoshi-bold uppercase text-white">
{user?.name.slice(0, 1)} {user?.name.slice(0, 1)}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<div className="flex flex-col text-sm"> <div className="flex flex-col text-sm">
<span className="font-medium text-black">{user?.name}</span> <span className="font-medium text-slate-900">{user?.name}</span>
<span className="text-xs text-gray-400">{user?.email}</span> <span className="text-xs text-slate-400">{user?.email}</span>
</div>
<ChevronDown size={16} className="ml-auto" />
</div> </div>
<ChevronDown
size={16}
strokeWidth={3}
className="ml-auto text-slate-400"
/>
</button>
</SidebarFooter> </SidebarFooter>
</Sidebar> </div>
</div>
</>
); );
} }

View File

@ -1,4 +1,4 @@
import { useEffect, useRef, useState, useCallback } from "react"; import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { X, Calculator, Maximize2, Minimize2 } from "lucide-react"; import { X, Calculator, Maximize2, Minimize2 } from "lucide-react";

View File

@ -417,7 +417,7 @@ interface Props {
onClose: () => void; onClose: () => void;
} }
export const ChestOpenModal = ({ node, claimResult, onClose }: Props) => { export const ChestOpenModal = ({ claimResult, onClose }: Props) => {
const [phase, setPhase] = useState<Phase>("idle"); const [phase, setPhase] = useState<Phase>("idle");
const [showXP, setShowXP] = useState(false); const [showXP, setShowXP] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

View File

@ -5,22 +5,16 @@ import { lazy, type ComponentType } from "react";
export type LessonId = export type LessonId =
// ---- EBRW ---- // ---- EBRW ----
| "ebrw-main-idea" | "ebrw-words-in-context"
| "ebrw-explicit-meaning" | "ebrw-text-structure-purpose"
| "ebrw-cross-text-connections"
| "ebrw-central-ideas-details"
| "ebrw-inferences" | "ebrw-inferences"
| "ebrw-graphic-displays" | "ebrw-command-of-evidence"
| "ebrw-craft-structure" | "ebrw-boundaries"
| "ebrw-vocab-precise" | "ebrw-form-structure-sense"
| "ebrw-vocab-meaning"
| "ebrw-expression-ideas"
| "ebrw-transitions" | "ebrw-transitions"
| "ebrw-commas" | "ebrw-rhetorical-synthesis"
| "ebrw-semicolons-colons"
| "ebrw-dashes-apostrophes"
| "ebrw-subject-verb"
| "ebrw-pronouns"
| "ebrw-verbs"
| "ebrw-sentence-structure"
// ---- MATH ---- // ---- MATH ----
| "alg-linear-eq-1var" | "alg-linear-eq-1var"
@ -45,55 +39,45 @@ export type LessonId =
| "geom-circles"; | "geom-circles";
// ---- EBRW ---- // ---- EBRW ----
const EBRWMainIdea = lazy( const EBRWWordsInContext = lazy(
() => import("../pages/student/lessons/EBRWMainIdeaLesson"), () => import("../pages/student/lessons/EBRWWordsInContextLesson"),
); );
const EBRWExplicitMeaning = lazy(
() => import("../pages/student/lessons/EBRWExplicitMeaningLesson"), const EBRWTextStructurePurpose = lazy(
() => import("../pages/student/lessons/EBRWTextStructurePurposeLesson"),
); );
const EBRWCrossText = lazy(
() => import("../pages/student/lessons/EBRWCrossTextLesson"),
);
const EBRWCentralIdeas = lazy(
() => import("../pages/student/lessons/EBRWCentralIdeasLesson"),
);
const EBRWInferences = lazy( const EBRWInferences = lazy(
() => import("../pages/student/lessons/EBRWInferencesLesson"), () => import("../pages/student/lessons/EBRWInferencesLesson"),
); );
const EBRWGraphicDisplays = lazy(
() => import("../pages/student/lessons/EBRWGraphicDisplaysLesson"), const EBRWCommandEvidence = lazy(
() => import("../pages/student/lessons/EBRWCommandEvidenceLesson"),
); );
const EBRWCraftStructure = lazy(
() => import("../pages/student/lessons/EBRWCraftStructureLesson"), const EBRWBoundaries = lazy(
() => import("../pages/student/lessons/EBRWBoundariesLesson"),
); );
const EBRWVocabPrecise = lazy(
() => import("../pages/student/lessons/EBRWVocabPreciseLesson"), const EBRWFormStructureSense = lazy(
); () => import("../pages/student/lessons/EBRWFormStructureSenseLesson"),
const EBRWVocabMeaning = lazy(
() => import("../pages/student/lessons/EBRWVocabMeaningLesson"),
);
const EBRWExpressionIdeas = lazy(
() => import("../pages/student/lessons/EBRWExpressionIdeasLesson"),
); );
const EBRWTransitions = lazy( const EBRWTransitions = lazy(
() => import("../pages/student/lessons/EBRWTransitionsLesson"), () => import("../pages/student/lessons/EBRWTransitionsLesson"),
); );
const EBRWCommas = lazy(
() => import("../pages/student/lessons/EBRWCommasLesson"),
);
const EBRWSemicolonsColons = lazy(
() => import("../pages/student/lessons/EBRWSemicolonsColonsLesson"),
);
const EBRWDashesApostrophes = lazy(
() => import("../pages/student/lessons/EBRWDashesApostrophesLesson"),
);
const EBRWSubjectVerb = lazy(
() => import("../pages/student/lessons/EBRWSubjectVerbLesson"),
);
const EBRWPronouns = lazy(
() => import("../pages/student/lessons/EBRWPronounsLesson"),
);
const EBRWVerbs = lazy(
() => import("../pages/student/lessons/EBRWVerbsLesson"),
);
const EBRWSentenceStructure = lazy(
() => import("../pages/student/lessons/EBRWSentenceStructureLesson"),
);
const EBRWRhetoricalSynthesis = lazy(
() => import("../pages/student/lessons/EBRWRhetoricalSynthesisLesson"),
);
// ---- MATH ---- // ---- MATH ----
const AlgLinearEq1Var = lazy( const AlgLinearEq1Var = lazy(
() => import("../pages/student/lessons/LinearEq1VarLesson"), () => import("../pages/student/lessons/LinearEq1VarLesson"),
@ -158,24 +142,19 @@ const GeomCircles = lazy(
// ---- Registry Map ---- // ---- Registry Map ----
export const LESSON_COMPONENT_MAP: Record<LessonId, ComponentType> = { export const LESSON_COMPONENT_MAP: Record<LessonId, ComponentType> = {
// EBRW // ---- EBRW ----
"ebrw-main-idea": EBRWMainIdea, "ebrw-words-in-context": EBRWWordsInContext,
"ebrw-explicit-meaning": EBRWExplicitMeaning, "ebrw-text-structure-purpose": EBRWTextStructurePurpose,
"ebrw-cross-text-connections": EBRWCrossText,
"ebrw-central-ideas-details": EBRWCentralIdeas,
"ebrw-inferences": EBRWInferences, "ebrw-inferences": EBRWInferences,
"ebrw-graphic-displays": EBRWGraphicDisplays, "ebrw-command-of-evidence": EBRWCommandEvidence,
"ebrw-craft-structure": EBRWCraftStructure, "ebrw-boundaries": EBRWBoundaries,
"ebrw-vocab-precise": EBRWVocabPrecise, "ebrw-form-structure-sense": EBRWFormStructureSense,
"ebrw-vocab-meaning": EBRWVocabMeaning,
"ebrw-expression-ideas": EBRWExpressionIdeas,
"ebrw-transitions": EBRWTransitions, "ebrw-transitions": EBRWTransitions,
"ebrw-commas": EBRWCommas, "ebrw-rhetorical-synthesis": EBRWRhetoricalSynthesis,
"ebrw-semicolons-colons": EBRWSemicolonsColons,
"ebrw-dashes-apostrophes": EBRWDashesApostrophes, // ---- MATH ----
"ebrw-subject-verb": EBRWSubjectVerb,
"ebrw-pronouns": EBRWPronouns,
"ebrw-verbs": EBRWVerbs,
"ebrw-sentence-structure": EBRWSentenceStructure,
// MATH
"alg-linear-eq-1var": AlgLinearEq1Var, "alg-linear-eq-1var": AlgLinearEq1Var,
"alg-linear-eq-2var": AlgLinearEq2Var, "alg-linear-eq-2var": AlgLinearEq2Var,
"alg-linear-functions": AlgLinearFunctions, "alg-linear-functions": AlgLinearFunctions,

View File

@ -17,11 +17,10 @@ import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
import { Drawer, DrawerContent, DrawerTrigger } from "./ui/drawer"; import { Drawer, DrawerContent, DrawerTrigger } from "./ui/drawer";
import { PredictedScoreCard } from "./PredictedScoreCard"; import { PredictedScoreCard } from "./PredictedScoreCard";
import { ChestOpenModal } from "./ChestOpenModal"; import { ChestOpenModal } from "./ChestOpenModal";
// Re-use the same theme generator that QuestMap uses so arc colours are consistent
import { generateArcTheme } from "../pages/student/QuestMap"; import { generateArcTheme } from "../pages/student/QuestMap";
import { InventoryButton } from "./InventoryButton"; import { InventoryButton } from "./InventoryButton";
// ─── Requirement helpers (mirrors QuestMap) ─────────────────────────────────── // ─── Requirement helpers ──────────────────────────────────────────────────────
const REQ_EMOJI: Record<string, string> = { const REQ_EMOJI: Record<string, string> = {
questions: "❓", questions: "❓",
accuracy: "🎯", accuracy: "🎯",
@ -256,37 +255,93 @@ const STYLES = `
border: 1px solid rgba(251,191,36,0.18); border-radius: 100px; border: 1px solid rgba(251,191,36,0.18); border-radius: 100px;
padding: 0.2rem 0.6rem; padding: 0.2rem 0.6rem;
} }
/* ── Rank ladder scroll container ──
Always scrollable on mobile. On desktop (≥1024px) we lock the width and
disable scrolling — nodes are spaced by percentage so no overflow occurs. */
.hc-ext-scroll { .hc-ext-scroll {
position: relative; z-index: 2; overflow-x: auto;
overflow-x: auto; overflow-y: hidden; overflow-y: hidden;
-webkit-overflow-scrolling: touch; scrollbar-width: none; scrollbar-width: none;
cursor: grab; padding: 1.0rem 1.0rem 0.8rem; -webkit-overflow-scrolling: touch;
cursor: grab;
position: relative;
z-index: 2;
} }
.hc-ext-scroll::-webkit-scrollbar { display: none; } .hc-ext-scroll::-webkit-scrollbar { display: none; }
.hc-ext-scroll:active { cursor: grabbing; } .hc-ext-scroll:active { cursor: grabbing; }
/* ── Rank ladder inner track ──
On mobile: fixed pixel width (fits all 6 nodes without squishing).
On desktop: 100% width, nodes spaced purely by percentage. */
.hc-ext-inner { .hc-ext-inner {
display: flex; align-items: flex-end; display: flex;
align-items: flex-end;
position: relative; position: relative;
height: 110px; height: 110px;
/* Mobile: fixed width so nodes have room and scroll kicks in */
width: 520px;
} }
@media (min-width: 1024px) {
.hc-ext-scroll {
overflow-x: hidden;
cursor: default;
}
.hc-ext-inner {
/* Desktop: fill the card width; percentage positions work correctly */
width: 100%;
}
/* Upscale fonts for desktop readability */
.hc-ext-title { font-size: 0.72rem; }
.hc-ext-earned { font-size: 0.82rem; }
.hc-ext-label-name { font-size: 0.56rem; }
.hc-ext-label-xp { font-size: 0.5rem; }
.hc-ext-node { width: 56px; height: 56px; font-size: 1.5rem; }
.hc-ext-ship { font-size: 1.7rem; }
}
/* ── Baseline and progress line ──
Use percentage-based left/right so they align with percentage-positioned nodes
regardless of container width. A small inset (half a node width as %) keeps
the line from starting/ending at the very edge of the outer nodes. */
.hc-ext-baseline { .hc-ext-baseline {
position: absolute; position: absolute;
top: 56px; left: 26px; right: 26px; height: 2px; top: 56px;
/* Inset matches half the outer col width relative to inner width */
left: 4%;
right: 4%;
height: 2px;
background: rgba(255,255,255,0.07); background: rgba(255,255,255,0.07);
border-radius: 2px; z-index: 0; border-radius: 2px;
z-index: 0;
} }
.hc-ext-progress-line { .hc-ext-progress-line {
position: absolute; position: absolute;
top: 56px; left: 26px; height: 2px; top: 56px;
left: 4%;
height: 2px;
background: linear-gradient(90deg, #fbbf24, #f59e0b); background: linear-gradient(90deg, #fbbf24, #f59e0b);
box-shadow: 0 0 10px rgba(251,191,36,0.5); box-shadow: 0 0 10px rgba(251,191,36,0.5);
border-radius: 2px; z-index: 1; border-radius: 2px;
z-index: 1;
/* Width is set inline as a % of the 92% usable span (100% - 4% - 4%) */
transition: width 1.2s cubic-bezier(0.34,1.56,0.64,1); transition: width 1.2s cubic-bezier(0.34,1.56,0.64,1);
} }
.hc-ext-progress-line::after {
content: "";
position: absolute;
right: -6px; top: -3px;
width: 10px; height: 10px;
background: #fbbf24;
border-radius: 50%;
box-shadow: 0 0 10px rgba(251,191,36,0.9);
}
.hc-ext-ship-wrap { .hc-ext-ship-wrap {
position: absolute; position: absolute;
top: 25px; z-index: 10; pointer-events: none; top: 20px; z-index: 10; pointer-events: none;
display: flex; flex-direction: column; align-items: center; gap: 0px; display: flex; flex-direction: column; align-items: center;
transition: left 1.2s cubic-bezier(0.34,1.56,0.64,1); transition: left 1.2s cubic-bezier(0.34,1.56,0.64,1);
transform: translateX(-50%); transform: translateX(-50%);
} }
@ -304,18 +359,23 @@ const STYLES = `
width: 1px; height: 14px; width: 1px; height: 14px;
background: linear-gradient(to bottom, rgba(251,191,36,0.5), transparent); background: linear-gradient(to bottom, rgba(251,191,36,0.5), transparent);
} }
/* ── Node columns ──
Each col takes an equal share; absolute positioning within .hc-ext-inner
is replaced by evenly-spaced flex. The node itself is centred in the col. */
.hc-ext-col { .hc-ext-col {
display: flex; flex-direction: column; align-items: center; flex: 1;
position: relative; z-index: 2; display: flex;
width: 88px; flex-shrink: 0; flex-direction: column;
align-items: center;
position: relative;
z-index: 2;
} }
.hc-ext-col:first-child,
.hc-ext-col:last-child { width: 52px; }
.hc-ext-node { .hc-ext-node {
width: 52px; height: 52px; border-radius: 50%; flex-shrink: 0; width: 52px; height: 52px; border-radius: 50%; flex-shrink: 0;
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
font-size: 1.4rem; position: relative; z-index: 2; font-size: 1.4rem; position: relative; z-index: 2;
margin-top: 42px; margin-top: 30px;
} }
.hc-ext-node.reached { .hc-ext-node.reached {
background: linear-gradient(145deg, #1e0e4a, #3730a3); background: linear-gradient(145deg, #1e0e4a, #3730a3);
@ -377,7 +437,6 @@ function getActiveQuests(arcs: QuestArc[]) {
for (const node of arc.nodes) for (const node of arc.nodes)
if (node.status === "claimable" || node.status === "active") if (node.status === "claimable" || node.status === "active")
out.push({ node, arc }); out.push({ node, arc });
// Claimable nodes bubble to the top
out.sort((a, b) => out.sort((a, b) =>
a.node.status === "claimable" && b.node.status !== "claimable" a.node.status === "claimable" && b.node.status !== "claimable"
? -1 ? -1
@ -388,14 +447,6 @@ function getActiveQuests(arcs: QuestArc[]) {
return out.slice(0, 2); return out.slice(0, 2);
} }
const SEG_W = 88;
const EDGE_W = 52;
function nodeX(i: number, total: number): number {
if (i === 0) return EDGE_W / 2;
if (i === total - 1) return EDGE_W / 2 + SEG_W * (total - 2) + EDGE_W / 2;
return EDGE_W + SEG_W * (i - 1) + SEG_W / 2;
}
// ─── QUEST_EXTENDED sub-component ──────────────────────────────────────────── // ─── QUEST_EXTENDED sub-component ────────────────────────────────────────────
const RankLadder = ({ const RankLadder = ({
earnedXP, earnedXP,
@ -425,12 +476,25 @@ const RankLadder = ({
) )
: 1; : 1;
const shipX = nextRank // ── Geometry ────────────────────────────────────────────────────────────────
? nodeX(currentIdx, N) + // Nodes are evenly distributed via flex (each col = flex:1).
(nodeX(currentIdx + 1, N) - nodeX(currentIdx, N)) * progressToNext // The centre of node[i] sits at: leftInset + (i / (N-1)) * usableSpan
: nodeX(currentIdx, N); // where leftInset = 4% and usableSpan = 92% (100% - 4% left - 4% right).
const progressLineW = shipX; // The baseline and progress line also start at 4% so everything aligns.
const totalW = EDGE_W + SEG_W * (N - 2) + EDGE_W; const LEFT_INSET_PCT = 4; // matches left: 4% on baseline/progress
const USABLE_PCT = 92; // 100 - 4 - 4
// Ship position as % of the inner container width
const nodePosPct = (i: number) => LEFT_INSET_PCT + (i / (N - 1)) * USABLE_PCT;
const shipPct = nextRank
? nodePosPct(currentIdx) +
(nodePosPct(currentIdx + 1) - nodePosPct(currentIdx)) * progressToNext
: nodePosPct(currentIdx);
// Progress line width: distance from left inset to ship position
// expressed as % of the container (not of usable span)
const progressLinePct = shipPct - LEFT_INSET_PCT;
const [animated, setAnimated] = useState(false); const [animated, setAnimated] = useState(false);
useEffect(() => { useEffect(() => {
@ -440,14 +504,36 @@ const RankLadder = ({
return () => cancelAnimationFrame(id); return () => cancelAnimationFrame(id);
}, []); }, []);
// Mouse-drag scroll for mobile
useEffect(() => { useEffect(() => {
if (!scrollRef.current) return;
const el = scrollRef.current; const el = scrollRef.current;
el.scrollTo({ if (!el) return;
left: Math.max(0, shipX - el.offsetWidth / 2), let isDown = false,
behavior: "smooth", startX = 0,
}); scrollLeft = 0;
}, [shipX]); const down = (e: MouseEvent) => {
isDown = true;
startX = e.pageX - el.offsetLeft;
scrollLeft = el.scrollLeft;
};
const leave = () => (isDown = false);
const up = () => (isDown = false);
const move = (e: MouseEvent) => {
if (!isDown) return;
e.preventDefault();
el.scrollLeft = scrollLeft - (e.pageX - el.offsetLeft - startX) * 1.2;
};
el.addEventListener("mousedown", down);
el.addEventListener("mouseleave", leave);
el.addEventListener("mouseup", up);
el.addEventListener("mousemove", move);
return () => {
el.removeEventListener("mousedown", down);
el.removeEventListener("mouseleave", leave);
el.removeEventListener("mouseup", up);
el.removeEventListener("mousemove", move);
};
}, []);
const rankPct = nextRank ? Math.round(progressToNext * 100) : 100; const rankPct = nextRank ? Math.round(progressToNext * 100) : 100;
const nextLabel = nextRank const nextLabel = nextRank
@ -495,21 +581,28 @@ const RankLadder = ({
</div> </div>
<div className="hc-ext-scroll" ref={scrollRef}> <div className="hc-ext-scroll" ref={scrollRef}>
<div className="hc-ext-inner" style={{ width: totalW }}> <div className="hc-ext-inner">
{/* Baseline — left: 4%, right: 4% (set in CSS) */}
<div className="hc-ext-baseline" /> <div className="hc-ext-baseline" />
{/* Progress line — starts at left: 4%, width grows to ship position */}
<div <div
className="hc-ext-progress-line" className="hc-ext-progress-line"
style={{ width: animated ? progressLineW : 26 }} style={{ width: animated ? `${progressLinePct}%` : "0%" }}
/> />
{/* Ship — positioned as % of container */}
<div <div
className="hc-ext-ship-wrap" className="hc-ext-ship-wrap"
style={{ left: animated ? shipX : nodeX(0, N) }} style={{ left: animated ? `${shipPct}%` : `${nodePosPct(0)}%` }}
> >
<span className="hc-ext-ship" role="img" aria-label="ship"> <span className="hc-ext-ship" role="img" aria-label="ship">
</span> </span>
<div className="hc-ext-ship-tether" /> <div className="hc-ext-ship-tether" />
</div> </div>
{/* Nodes — evenly spaced via flex:1 on each col */}
{ladder.map((r, i) => { {ladder.map((r, i) => {
const state = const state =
i < currentIdx i < currentIdx
@ -551,14 +644,11 @@ export const InfoHeader = ({ onViewAll, mode = "DEFAULT" }: Props) => {
const navigate = useNavigate(); const navigate = useNavigate();
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
// Select all needed store slices — earnedXP and earnedTitles are now first-class state
const arcs = useQuestStore((s) => s.arcs); const arcs = useQuestStore((s) => s.arcs);
const earnedXP = user?.total_xp ?? 0; const earnedXP = user?.total_xp ?? 0;
const earnedTitles = useQuestStore((s) => s.earnedTitles); const earnedTitles = useQuestStore((s) => s.earnedTitles);
const claimNode = useQuestStore((s) => s.claimNode); const claimNode = useQuestStore((s) => s.claimNode);
// Updated signatures: getQuestSummary needs earnedXP + earnedTitles,
// getCrewRank takes earnedXP directly (no longer iterates nodes)
const summary = getQuestSummary(arcs, earnedXP, earnedTitles); const summary = getQuestSummary(arcs, earnedXP, earnedTitles);
const rank = getCrewRank(earnedXP); const rank = getCrewRank(earnedXP);
const activeQuests = getActiveQuests(arcs); const activeQuests = getActiveQuests(arcs);
@ -598,7 +688,6 @@ export const InfoHeader = ({ onViewAll, mode = "DEFAULT" }: Props) => {
node: QuestNode; node: QuestNode;
arcId: string; arcId: string;
} | null>(null); } | null>(null);
// Holds the API response from the claim call so ChestOpenModal can display real rewards
const [claimResult, setClaimResult] = useState<ClaimedRewardResponse | null>( const [claimResult, setClaimResult] = useState<ClaimedRewardResponse | null>(
null, null,
); );
@ -609,7 +698,7 @@ export const InfoHeader = ({ onViewAll, mode = "DEFAULT" }: Props) => {
}; };
const handleClaim = (node: QuestNode, arcId: string) => { const handleClaim = (node: QuestNode, arcId: string) => {
setClaimResult(null); // clear any previous result before opening setClaimResult(null);
setClaimingNode({ node, arcId }); setClaimingNode({ node, arcId });
}; };
@ -617,7 +706,7 @@ export const InfoHeader = ({ onViewAll, mode = "DEFAULT" }: Props) => {
if (!claimingNode) return; if (!claimingNode) return;
claimNode( claimNode(
claimingNode.arcId, claimingNode.arcId,
claimingNode.node.node_id, // node_id replaces old id claimingNode.node.node_id,
claimResult?.xp_awarded ?? 0, claimResult?.xp_awarded ?? 0,
claimResult?.title_unlocked.map((t) => t.name) ?? [], claimResult?.title_unlocked.map((t) => t.name) ?? [],
); );
@ -656,7 +745,6 @@ export const InfoHeader = ({ onViewAll, mode = "DEFAULT" }: Props) => {
<style>{STYLES}</style> <style>{STYLES}</style>
<div className="hc-card"> <div className="hc-card">
{/* Identity — DEFAULT only */}
{showIdentity && ( {showIdentity && (
<> <>
<div className="hc-top"> <div className="hc-top">
@ -685,6 +773,7 @@ export const InfoHeader = ({ onViewAll, mode = "DEFAULT" }: Props) => {
<p className="hc-role">{roleLabel}</p> <p className="hc-role">{roleLabel}</p>
</div> </div>
</div> </div>
{/* @ts-ignore */}
<InventoryButton label="Inventory" /> <InventoryButton label="Inventory" />
<Drawer direction="top"> <Drawer direction="top">
<DrawerTrigger asChild> <DrawerTrigger asChild>
@ -697,12 +786,10 @@ export const InfoHeader = ({ onViewAll, mode = "DEFAULT" }: Props) => {
</DrawerContent> </DrawerContent>
</Drawer> </Drawer>
</div> </div>
<div className="hc-sep" /> <div className="hc-sep" />
</> </>
)} )}
{/* XP bar — DEFAULT + LEVEL */}
{showLevel && ( {showLevel && (
<div className="hc-xp-row"> <div className="hc-xp-row">
<span className="hc-lvl-tag">Lv {level}</span> <span className="hc-lvl-tag">Lv {level}</span>
@ -718,7 +805,6 @@ export const InfoHeader = ({ onViewAll, mode = "DEFAULT" }: Props) => {
</div> </div>
)} )}
{/* Rank + collapsible quests — DEFAULT + QUEST_COMPACT */}
{showQuestCompact && ( {showQuestCompact && (
<> <>
<div className="hc-rank-row" onClick={() => setOpen((o) => !o)}> <div className="hc-rank-row" onClick={() => setOpen((o) => !o)}>
@ -749,22 +835,17 @@ export const InfoHeader = ({ onViewAll, mode = "DEFAULT" }: Props) => {
<p className="hc-empty"> All caught up keep sailing!</p> <p className="hc-empty"> All caught up keep sailing!</p>
) : ( ) : (
activeQuests.map(({ node, arc }) => { activeQuests.map(({ node, arc }) => {
// Progress uses new field names
const pct = Math.min( const pct = Math.min(
100, 100,
Math.round((node.current_value / node.req_target) * 100), Math.round((node.current_value / node.req_target) * 100),
); );
const isClaimable = node.status === "claimable"; const isClaimable = node.status === "claimable";
// Arc accent colour via theme generator — arc.accentColor no longer exists
const accentColor = generateArcTheme(arc).accent; const accentColor = generateArcTheme(arc).accent;
// Node icon derived from req_type — node.emoji no longer exists
const nodeEmoji = REQ_EMOJI[node.req_type] ?? "🏝️"; const nodeEmoji = REQ_EMOJI[node.req_type] ?? "🏝️";
// Progress label derived from req_type — node.requirement.label no longer exists
const reqLabel = REQ_LABEL[node.req_type] ?? node.req_type; const reqLabel = REQ_LABEL[node.req_type] ?? node.req_type;
return ( return (
<div <div
key={node.node_id} // node_id replaces old id key={node.node_id}
className="hc-quest-row" className="hc-quest-row"
style={{ "--ac": accentColor } as React.CSSProperties} style={{ "--ac": accentColor } as React.CSSProperties}
onClick={() => !isClaimable && handleViewAll()} onClick={() => !isClaimable && handleViewAll()}
@ -775,13 +856,11 @@ export const InfoHeader = ({ onViewAll, mode = "DEFAULT" }: Props) => {
{isClaimable ? "📦" : nodeEmoji} {isClaimable ? "📦" : nodeEmoji}
</div> </div>
<div className="hc-q-body"> <div className="hc-q-body">
{/* node.name replaces old node.title */}
<p className="hc-q-name">{node.name ?? "—"}</p> <p className="hc-q-name">{node.name ?? "—"}</p>
{isClaimable ? ( {isClaimable ? (
<p className="hc-q-claimable"> Ready to claim!</p> <p className="hc-q-claimable"> Ready to claim!</p>
) : ( ) : (
<p className="hc-q-sub"> <p className="hc-q-sub">
{/* current_value / req_target replace old progress / requirement.target */}
{node.current_value}/{node.req_target} {reqLabel}{" "} {node.current_value}/{node.req_target} {reqLabel}{" "}
· {pct}% · {pct}%
</p> </p>

View File

@ -3,7 +3,6 @@ import {
useInventoryStore, useInventoryStore,
getLiveEffects, getLiveEffects,
formatTimeLeft, formatTimeLeft,
hasActiveEffect,
} from "../stores/useInventoryStore"; } from "../stores/useInventoryStore";
import { InventoryModal } from "./InventoryModal"; import { InventoryModal } from "./InventoryModal";

View File

@ -39,6 +39,12 @@ const STYLES = `
animation: invSlideUp 0.38s cubic-bezier(0.34,1.56,0.64,1) both; animation: invSlideUp 0.38s cubic-bezier(0.34,1.56,0.64,1) both;
position: relative; position: relative;
} }
@media (min-width: 1024px) {
.inv-sheet {
max-width: 1000px;
}
}
@keyframes invSlideUp { @keyframes invSlideUp {
from { transform: translateY(100%); opacity:0; } from { transform: translateY(100%); opacity:0; }
to { transform: translateY(0); opacity:1; } to { transform: translateY(0); opacity:1; }

1047
src/components/Island3D.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@ -13,7 +13,7 @@ import type { LessonId } from "./FetchLessonPage";
import type { LessonDetails } from "../types/lesson"; import type { LessonDetails } from "../types/lesson";
interface LessonModalProps { interface LessonModalProps {
lessonId: string | null; selectedLessonData: { id: string | null; name: string | null };
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
} }
@ -23,14 +23,6 @@ const UUID_REGEX =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const isVideoLesson = (id: string) => UUID_REGEX.test(id); const isVideoLesson = (id: string) => UUID_REGEX.test(id);
function getLocalLessonTitle(lessonId: string): string {
const comp = LESSON_COMPONENT_MAP[lessonId as LessonId] as any;
if (comp?.displayName) return comp.displayName;
return lessonId
.replace(/[-_]/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase());
}
const STYLES = ` const STYLES = `
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap');
@ -48,6 +40,12 @@ const STYLES = `
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
@media (min-width: 1024px) {
.lm-content {
max-width: 1000px;
}
}
.lm-dialog-header-hidden { .lm-dialog-header-hidden {
position: absolute; width: 1px; height: 1px; position: absolute; width: 1px; height: 1px;
padding: 0; margin: -1px; overflow: hidden; padding: 0; margin: -1px; overflow: hidden;
@ -152,8 +150,9 @@ const LoadingSpinner = () => (
); );
export const LessonModal = ({ export const LessonModal = ({
lessonId, selectedLessonData,
open, open,
onOpenChange, onOpenChange,
}: LessonModalProps) => { }: LessonModalProps) => {
const user = useAuthStore((state) => state.user); const user = useAuthStore((state) => state.user);
@ -162,17 +161,21 @@ export const LessonModal = ({
const [lesson, setLesson] = useState<LessonDetails | null>(null); const [lesson, setLesson] = useState<LessonDetails | null>(null);
const [error, setError] = useState(false); const [error, setError] = useState(false);
const fetchingForId = useRef<string | null>(null); const fetchingForId = useRef<string | null>(null);
const lessonId = selectedLessonData.id;
const LocalLessonComponent = const LocalLessonComponent =
lessonId && !isVideoLesson(lessonId) lessonId && !isVideoLesson(lessonId)
? LESSON_COMPONENT_MAP[lessonId as LessonId] ? LESSON_COMPONENT_MAP[lessonId as LessonId]
: null; : null;
const modalTitle = LocalLessonComponent // const modalTitle = LocalLessonComponent
? getLocalLessonTitle(lessonId!) // ? getLocalLessonTitle(lessonId!)
: loading // : loading
? "Loading..." // ? "Loading..."
: (lesson?.title ?? "Lesson"); // : (lesson?.title ?? "Lesson");
const modalTitle =
selectedLessonData.name || selectedLessonData.id || "Lesson";
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
@ -196,11 +199,12 @@ export const LessonModal = ({
const authStorage = localStorage.getItem("auth-storage"); const authStorage = localStorage.getItem("auth-storage");
if (!authStorage) throw new Error("No auth storage"); if (!authStorage) throw new Error("No auth storage");
const { const {
// @ts-ignore
state: { token }, state: { token },
} = JSON.parse(authStorage) as { state?: { token?: string } }; } = JSON.parse(authStorage) as { state?: { token?: string } };
if (!token) throw new Error("No token"); if (!token) throw new Error("No token");
// fetchLessonById returns LessonDetails directly // @ts-ignore
const response: LessonDetails = await api.fetchLessonById( const response: LessonDetails = await api.fetchLessonById(
token, token,
lessonId, lessonId,

View File

@ -281,6 +281,7 @@ const SectionDetail = ({
<div className="psc-detail-card"> <div className="psc-detail-card">
<div className="psc-detail-top"> <div className="psc-detail-top">
<div className="psc-detail-icon-wrap" style={{ background: iconBg }}> <div className="psc-detail-icon-wrap" style={{ background: iconBg }}>
{/* @ts-ignore */}
<Icon size={15} color={barColor} /> <Icon size={15} color={barColor} />
</div> </div>
<span className="psc-detail-label">{label}</span> <span className="psc-detail-label">{label}</span>

View File

@ -846,7 +846,6 @@ export const QuestNodeModal = ({
node, node,
arc, arc,
arcAccent, arcAccent,
arcDark,
arcId = "east_blue", arcId = "east_blue",
nodeIndex = 0, nodeIndex = 0,
onClose, onClose,

View File

@ -1,507 +0,0 @@
import { useState } from "react";
import { ChevronDown, ChevronRight } from "lucide-react";
import { useNavigate } from "react-router-dom";
import type { QuestNode, QuestArc } from "../types/quest";
import { CREW_RANKS } from "../types/quest";
import {
useQuestStore,
getQuestSummary,
getCrewRank,
} from "../stores/useQuestStore";
import { ChestOpenModal } from "./ChestOpenModal";
// ─── Styles ───────────────────────────────────────────────────────────────────
const STYLES = `
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@600;700;900&family=Sorts+Mill+Goudy:ital@0;1&family=Nunito:wght@700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap');
/* ══ CARD SHELL ══ */
.qpc2-card {
position: relative; overflow: hidden;
border-radius: 24px;
background: linear-gradient(160deg, #0b1a35 0%, #060e1f 55%, #0d1530 100%);
border: 1.5px solid rgba(251,191,36,0.2);
box-shadow:
0 8px 32px rgba(0,0,0,0.35),
0 0 0 1px rgba(255,255,255,0.04) inset,
0 1px 0 rgba(255,255,255,0.08) inset;
}
/* Animated sea shimmer behind everything */
.qpc2-sea {
position: absolute; inset: 0; pointer-events: none; z-index: 0;
background:
repeating-linear-gradient(105deg, transparent 0%, transparent 55%,
rgba(56,189,248,0.022) 56%, transparent 57%),
repeating-linear-gradient(75deg, transparent 0%, transparent 70%,
rgba(56,189,248,0.014) 71%, transparent 72%);
background-size: 300% 300%, 250% 250%;
animation: qpc2Sea 12s ease-in-out infinite alternate;
}
@keyframes qpc2Sea {
0% { background-position: 0% 0%, 100% 0%; }
100% { background-position: 100% 100%, 0% 100%; }
}
/* Faint gold orb top-right */
.qpc2-orb {
position: absolute; top: -40px; right: -30px;
width: 160px; height: 160px; border-radius: 50%;
background: radial-gradient(circle, rgba(251,191,36,0.14) 0%, transparent 70%);
pointer-events: none; z-index: 0;
}
/* ══ RANK HERO (always visible) ══ */
.qpc2-hero {
position: relative; z-index: 2;
padding: 1rem 1.1rem 0.9rem;
cursor: pointer;
transition: background 0.18s ease;
}
.qpc2-hero:hover { background: rgba(255,255,255,0.025); }
.qpc2-hero-row {
display: flex; align-items: center; justify-content: space-between; gap: 0.75rem;
}
.qpc2-hero-left { display: flex; align-items: center; gap: 0.75rem; flex: 1; min-width: 0; }
.qpc2-hero-right { display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0; }
/* Rank badge icon */
.qpc2-rank-icon {
width: 44px; height: 44px; border-radius: 14px; flex-shrink: 0;
background: linear-gradient(135deg, #1e0e4a, #3730a3);
border: 1.5px solid rgba(251,191,36,0.35);
display: flex; align-items: center; justify-content: center;
font-size: 1.35rem;
box-shadow: 0 4px 0 rgba(30,14,74,0.7), 0 0 16px rgba(251,191,36,0.1);
}
.qpc2-rank-label {
font-family: 'Cinzel', serif;
font-size: 0.78rem; font-weight: 700;
color: rgba(255,255,255,0.45); letter-spacing: 0.12em;
text-transform: uppercase; margin-bottom: 0.1rem;
}
.qpc2-rank-name {
font-family: 'Sorts Mill Goudy', serif;
font-size: 1.05rem; font-weight: 700;
color: #fbbf24;
text-shadow: 0 0 18px rgba(251,191,36,0.45);
line-height: 1.1;
}
/* Rank progress bar */
.qpc2-rank-bar-wrap {
margin-top: 0.55rem;
display: flex; align-items: center; gap: 0.6rem;
}
.qpc2-rank-bar-track {
flex: 1; height: 5px; border-radius: 100px;
background: rgba(255,255,255,0.1); overflow: hidden;
}
.qpc2-rank-bar-fill {
height: 100%; border-radius: 100px;
background: linear-gradient(90deg, #fbbf24, #f59e0b);
box-shadow: 0 0 8px rgba(251,191,36,0.5);
transition: width 0.7s cubic-bezier(0.34,1.56,0.64,1);
}
.qpc2-rank-bar-label {
font-family: 'Nunito Sans', sans-serif;
font-size: 0.6rem; font-weight: 700;
color: rgba(255,255,255,0.35); white-space: nowrap;
}
/* Stats row */
.qpc2-stats {
display: flex; gap: 0.5rem; margin-top: 0.75rem;
padding-top: 0.7rem;
border-top: 1px solid rgba(255,255,255,0.07);
}
.qpc2-stat {
flex: 1; display: flex; flex-direction: column; align-items: center; gap: 0.1rem;
}
.qpc2-stat-val {
font-family: 'Nunito', sans-serif;
font-size: 0.95rem; font-weight: 900; color: #fbbf24;
}
.qpc2-stat-lbl {
font-family: 'Nunito Sans', sans-serif;
font-size: 0.56rem; font-weight: 700;
color: rgba(255,255,255,0.35); text-align: center;
letter-spacing: 0.06em; text-transform: uppercase;
}
.qpc2-stat-div {
width: 1px; background: rgba(255,255,255,0.08); margin: 0.1rem 0;
}
/* Chest badge */
.qpc2-chest-badge {
display: flex; align-items: center; gap: 0.22rem;
padding: 0.22rem 0.6rem;
background: linear-gradient(135deg, #fbbf24, #f59e0b);
border-radius: 100px;
font-family: 'Nunito', sans-serif;
font-size: 0.65rem; font-weight: 900; color: #1a0800;
box-shadow: 0 2px 0 #d97706, 0 0 10px rgba(251,191,36,0.35);
animation: qpc2ChestPop 1.8s ease-in-out infinite;
}
@keyframes qpc2ChestPop {
0%,100%{ transform: scale(1); }
50% { transform: scale(1.07); }
}
/* Expand chevron */
.qpc2-chevron {
color: rgba(255,255,255,0.35);
transition: transform 0.3s cubic-bezier(0.34,1.56,0.64,1), color 0.2s;
}
.qpc2-chevron.open { transform: rotate(180deg); color: #fbbf24; }
/* ══ COLLAPSIBLE BODY ══ */
.qpc2-body {
position: relative; z-index: 2;
overflow: hidden;
max-height: 0;
transition: max-height 0.4s cubic-bezier(0.4,0,0.2,1);
}
.qpc2-body.open { max-height: 600px; }
.qpc2-divider {
height: 1px; background: rgba(255,255,255,0.07); margin: 0 1.1rem;
}
/* ══ QUEST ROWS ══ */
.qpc2-quest-list { display: flex; flex-direction: column; padding: 0.5rem 0; }
.qpc2-quest-row {
display: flex; align-items: center; gap: 0.7rem;
padding: 0.75rem 1.1rem;
cursor: pointer;
transition: background 0.15s ease;
position: relative;
}
.qpc2-quest-row:hover { background: rgba(255,255,255,0.03); }
/* Left accent line = arc colour */
.qpc2-quest-row::before {
content: ''; position: absolute; left: 0; top: 16%; bottom: 16%;
width: 3px; border-radius: 0 3px 3px 0;
background: var(--ac);
opacity: 0.7;
}
.qpc2-quest-icon {
width: 38px; height: 38px; border-radius: 12px; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
font-size: 1.2rem;
background: rgba(255,255,255,0.05);
border: 1.5px solid rgba(255,255,255,0.08);
transition: transform 0.2s ease;
}
.qpc2-quest-row:hover .qpc2-quest-icon { transform: scale(1.1) rotate(-5deg); }
.qpc2-quest-icon.claimable {
background: rgba(251,191,36,0.12);
border-color: rgba(251,191,36,0.4);
animation: qpc2Wiggle 2s ease-in-out infinite;
}
@keyframes qpc2Wiggle {
0%,100%{ transform: rotate(0deg); }
25% { transform: rotate(-8deg) scale(1.06); }
75% { transform: rotate(8deg) scale(1.06); }
}
.qpc2-quest-body { flex: 1; min-width: 0; }
.qpc2-quest-arc {
font-size: 0.57rem; font-weight: 800; letter-spacing: 0.12em;
text-transform: uppercase; color: var(--ac);
margin-bottom: 0.08rem;
}
.qpc2-quest-title {
font-family: 'Sorts Mill Goudy', serif;
font-size: 0.82rem; font-weight: 700; color: rgba(255,255,255,0.9);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
margin-bottom: 0.28rem;
}
.qpc2-mini-track {
height: 4px; background: rgba(255,255,255,0.08);
border-radius: 100px; overflow: hidden; margin-bottom: 0.18rem;
}
.qpc2-mini-fill {
height: 100%; border-radius: 100px;
background: var(--ac);
box-shadow: 0 0 5px color-mix(in srgb, var(--ac) 55%, transparent);
transition: width 0.5s cubic-bezier(0.34,1.56,0.64,1);
}
.qpc2-mini-label {
font-family: 'Nunito Sans', sans-serif;
font-size: 0.58rem; font-weight: 700; color: rgba(255,255,255,0.3);
}
.qpc2-claimable-label {
font-family: 'Nunito Sans', sans-serif;
font-size: 0.62rem; font-weight: 700; color: #fbbf24;
}
/* Claim button */
.qpc2-claim-btn {
padding: 0.32rem 0.7rem; border: none; border-radius: 100px; cursor: pointer;
background: linear-gradient(135deg, #fbbf24, #f59e0b);
font-family: 'Nunito', sans-serif;
font-size: 0.65rem; font-weight: 900; color: #1a0800;
box-shadow: 0 2px 0 #d97706, 0 3px 8px rgba(251,191,36,0.25);
flex-shrink: 0; white-space: nowrap;
transition: all 0.12s ease;
}
.qpc2-claim-btn:hover { transform: translateY(-1px); box-shadow: 0 3px 0 #d97706; }
.qpc2-claim-btn:active { transform: translateY(1px); }
/* ══ FOOTER LINK ══ */
.qpc2-footer {
position: relative; z-index: 2;
display: flex; align-items: center; justify-content: center; gap: 0.3rem;
padding: 0.65rem 1.1rem;
border-top: 1px solid rgba(255,255,255,0.07);
cursor: pointer;
transition: background 0.15s ease;
}
.qpc2-footer:hover { background: rgba(255,255,255,0.03); }
.qpc2-footer-label {
font-family: 'Nunito', sans-serif;
font-size: 0.72rem; font-weight: 800;
color: rgba(251,191,36,0.7);
letter-spacing: 0.04em;
}
.qpc2-footer:hover .qpc2-footer-label { color: #fbbf24; }
/* ══ EMPTY STATE ══ */
.qpc2-empty {
padding: 1.25rem 1.1rem; text-align: center;
display: flex; flex-direction: column; align-items: center; gap: 0.35rem;
}
.qpc2-empty-title {
font-family: 'Sorts Mill Goudy', serif;
font-size: 0.88rem; font-weight: 700; color: rgba(255,255,255,0.55);
}
.qpc2-empty-sub {
font-family: 'Nunito Sans', sans-serif;
font-size: 0.68rem; font-weight: 600; color: rgba(255,255,255,0.25);
}
`;
// ─── Helpers ──────────────────────────────────────────────────────────────────
function getActiveQuests(arcs: QuestArc[]) {
const results: { node: QuestNode; arc: QuestArc }[] = [];
for (const arc of arcs) {
for (const node of arc.nodes) {
if (node.status === "claimable" || node.status === "active") {
results.push({ node, arc });
}
}
}
// Claimable first, then active; max 2 shown
results.sort((a, b) => {
if (a.node.status === "claimable" && b.node.status !== "claimable")
return -1;
if (b.node.status === "claimable" && a.node.status !== "claimable")
return 1;
return 0;
});
return results.slice(0, 2);
}
// ─── Component ────────────────────────────────────────────────────────────────
interface Props {
onViewAll?: () => void;
}
export const QuestProgressCard = ({ onViewAll }: Props) => {
const navigate = useNavigate();
const arcs = useQuestStore((s) => s.arcs);
const claimNode = useQuestStore((s) => s.claimNode);
const summary = getQuestSummary(arcs);
const rank = getCrewRank(arcs);
const activeQuests = getActiveQuests(arcs);
const [open, setOpen] = useState(false);
const [claimingNode, setClaimingNode] = useState<{
node: QuestNode;
arcId: string;
} | null>(null);
const handleViewAll = () => {
if (onViewAll) onViewAll();
else navigate("/student/quests");
};
const handleClaim = (node: QuestNode, arcId: string) => {
setClaimingNode({ node, arcId });
};
const handleChestClose = () => {
if (!claimingNode) return;
claimNode(claimingNode.arcId, claimingNode.node.id);
setClaimingNode(null);
};
// Next rank label
const nextRankLabel = rank.next
? `${Math.round(rank.progressToNext * 100)}% to ${rank.next.label}`
: "Max rank reached";
return (
<>
<style>{STYLES}</style>
<div className="qpc2-card">
{/* Atmosphere layers */}
<div className="qpc2-sea" />
<div className="qpc2-orb" />
{/* ── Rank hero (always visible, tap to expand) ── */}
<div className="qpc2-hero" onClick={() => setOpen((o) => !o)}>
<div className="qpc2-hero-row">
<div className="qpc2-hero-left">
<div className="qpc2-rank-icon">{rank.emoji}</div>
<div style={{ flex: 1, minWidth: 0 }}>
<p className="qpc2-rank-label">Crew Rank</p>
<p className="qpc2-rank-name">{rank.label}</p>
</div>
</div>
<div className="qpc2-hero-right">
{summary.claimableNodes > 0 && (
<div className="qpc2-chest-badge">
📦 {summary.claimableNodes}
</div>
)}
<ChevronDown
size={18}
className={`qpc2-chevron${open ? " open" : ""}`}
/>
</div>
</div>
{/* Rank progress bar */}
<div className="qpc2-rank-bar-wrap">
<div className="qpc2-rank-bar-track">
<div
className="qpc2-rank-bar-fill"
style={{ width: `${Math.round(rank.progressToNext * 100)}%` }}
/>
</div>
<span className="qpc2-rank-bar-label">{nextRankLabel}</span>
</div>
{/* Stats strip */}
<div className="qpc2-stats">
{[
{ val: `${summary.earnedXP}`, lbl: "XP Earned" },
null,
{
val: `${summary.completedNodes}/${summary.totalNodes}`,
lbl: "Quests Done",
},
null,
{
val: `${summary.arcsCompleted}/${summary.totalArcs}`,
lbl: "Arcs",
},
].map((item, i) =>
item === null ? (
<div key={i} className="qpc2-stat-div" />
) : (
<div key={i} className="qpc2-stat">
<span className="qpc2-stat-val">{item.val}</span>
<span className="qpc2-stat-lbl">{item.lbl}</span>
</div>
),
)}
</div>
</div>
{/* ── Collapsible quest list ── */}
<div className={`qpc2-body${open ? " open" : ""}`}>
<div className="qpc2-divider" />
<div className="qpc2-quest-list">
{activeQuests.length === 0 ? (
<div className="qpc2-empty">
<span style={{ fontSize: "1.75rem" }}></span>
<p className="qpc2-empty-title">All caught up, Captain!</p>
<p className="qpc2-empty-sub">
No active quests keep sailing
</p>
</div>
) : (
activeQuests.map(({ node, arc }) => {
const pct = Math.min(
100,
Math.round((node.progress / node.requirement.target) * 100),
);
const isClaimable = node.status === "claimable";
return (
<div
key={node.id}
className="qpc2-quest-row"
style={{ "--ac": arc.accentColor } as React.CSSProperties}
onClick={() => !isClaimable && handleViewAll()}
>
<div
className={`qpc2-quest-icon${isClaimable ? " claimable" : ""}`}
>
{isClaimable ? "📦" : node.emoji}
</div>
<div className="qpc2-quest-body">
<p className="qpc2-quest-arc">
{arc.emoji} {arc.name}
</p>
<p className="qpc2-quest-title">{node.title}</p>
{isClaimable ? (
<p className="qpc2-claimable-label">
Chest ready to open!
</p>
) : (
<>
<div className="qpc2-mini-track">
<div
className="qpc2-mini-fill"
style={{ width: `${pct}%` }}
/>
</div>
<p className="qpc2-mini-label">
{node.progress} / {node.requirement.target}{" "}
{node.requirement.label}
</p>
</>
)}
</div>
{isClaimable ? (
<button
className="qpc2-claim-btn"
onClick={(e) => {
e.stopPropagation();
handleClaim(node, arc.id);
}}
>
Open 📦
</button>
) : (
<ChevronRight size={14} color="rgba(255,255,255,0.2)" />
)}
</div>
);
})
)}
</div>
{/* Footer — navigate to full map */}
<div className="qpc2-footer" onClick={handleViewAll}>
<span className="qpc2-footer-label">View full quest map</span>
<ChevronRight size={14} color="rgba(251,191,36,0.7)" />
</div>
</div>
</div>
{claimingNode && (
<ChestOpenModal node={claimingNode.node} onClose={handleChestClose} />
)}
</>
);
};

View File

@ -1,16 +1,77 @@
import { Component, type ReactNode } from "react";
// @ts-ignore
import { BlockMath, InlineMath } from "react-katex"; import { BlockMath, InlineMath } from "react-katex";
// ─── Error boundary ───────────────────────────────────────────────────────────
// react-katex throws synchronously during render for invalid LaTeX, so a class
// error boundary is the only reliable way to catch it.
interface MathErrorBoundaryProps {
raw: string; // the original LaTeX string, shown as fallback
children: ReactNode;
}
interface MathErrorBoundaryState {
failed: boolean;
}
export class MathErrorBoundary extends Component<
MathErrorBoundaryProps,
MathErrorBoundaryState
> {
state: MathErrorBoundaryState = { failed: false };
static getDerivedStateFromError(): MathErrorBoundaryState {
return { failed: true };
}
render() {
if (this.state.failed) {
return (
<span
title={`Could not render: ${this.props.raw}`}
style={{
fontFamily: "monospace",
background: "rgba(239,68,68,0.08)",
border: "1px solid rgba(239,68,68,0.3)",
borderRadius: 4,
padding: "0 4px",
color: "#f87171",
fontSize: "0.9em",
}}
>
{this.props.raw}
</span>
);
}
return this.props.children;
}
}
// ─── Renderer ─────────────────────────────────────────────────────────────────
export const renderQuestionText = (text: string) => { export const renderQuestionText = (text: string) => {
const parts = text.split(/(\$\$.*?\$\$|\$.*?\$)/g); if (!text) return null;
const parts = text.split(/(\$\$.*?\$\$|\$.*?\$)/gs);
return ( return (
<> <>
{parts.map((part, index) => { {parts.map((part, index) => {
if (part.startsWith("$$")) { if (part.startsWith("$$")) {
return <BlockMath key={index}>{part.slice(2, -2)}</BlockMath>; const latex = part.slice(2, -2);
return (
<MathErrorBoundary key={index} raw={part}>
<BlockMath>{latex}</BlockMath>
</MathErrorBoundary>
);
} }
if (part.startsWith("$")) { if (part.startsWith("$")) {
return <InlineMath key={index}>{part.slice(1, -1)}</InlineMath>; const latex = part.slice(1, -1);
return (
<MathErrorBoundary key={index} raw={part}>
<InlineMath>{latex}</InlineMath>
</MathErrorBoundary>
);
} }
return <span key={index}>{part}</span>; return <span key={index}>{part}</span>;
})} })}

View File

@ -28,7 +28,7 @@ interface Props {
// ─── Nav items ──────────────────────────────────────────────────────────────── // ─── Nav items ────────────────────────────────────────────────────────────────
const NAV_ITEMS: (SearchItem & { const NAV_ITEMS: (SearchItem & {
icon: React.ElementType; icon: React.ComponentType<any>;
color: string; color: string;
bg: string; bg: string;
})[] = [ })[] = [
@ -490,6 +490,7 @@ export const SearchOverlay = ({
className="so-item-icon" className="so-item-icon"
style={{ background: bg }} style={{ background: bg }}
> >
{/* @ts-ignore */}
<Icon size={16} color={color} /> <Icon size={16} color={color} />
</div> </div>
<div className="so-item-body"> <div className="so-item-body">
@ -517,6 +518,7 @@ export const SearchOverlay = ({
className="so-quick-chip" className="so-quick-chip"
onClick={() => handleSelect(item)} onClick={() => handleSelect(item)}
> >
{/* @ts-ignore */}
<item.icon size={13} color={item.color} /> <item.icon size={13} color={item.color} />
{item.title} {item.title}
</button> </button>
@ -533,6 +535,7 @@ export const SearchOverlay = ({
.filter((s) => s.user_status === "IN_PROGRESS") .filter((s) => s.user_status === "IN_PROGRESS")
.slice(0, 3) .slice(0, 3)
.map((sheet) => { .map((sheet) => {
// @ts-ignore
const item: SearchItem = { const item: SearchItem = {
type: "sheet", type: "sheet",
title: sheet.title, title: sheet.title,
@ -602,8 +605,9 @@ export const SearchOverlay = ({
const Icon = navMeta?.icon ?? BookOpen; const Icon = navMeta?.icon ?? BookOpen;
const iconColor = navMeta?.color ?? "#a855f7"; const iconColor = navMeta?.color ?? "#a855f7";
const iconBg = navMeta?.bg ?? "#fdf4ff"; const iconBg = navMeta?.bg ?? "#fdf4ff";
const statusMeta = item.status const statusMeta = item.status
? STATUS_META[item.status as keyof typeof STATUS_META] ? STATUS_META[item?.status as keyof typeof STATUS_META]
: null; : null;
return ( return (

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React, { useState } from "react";
const BoxPlotComparisonWidget: React.FC = () => { const BoxPlotComparisonWidget: React.FC = () => {
// Box Plot A is fixed // Box Plot A is fixed
@ -9,16 +9,24 @@ const BoxPlotComparisonWidget: React.FC = () => {
const [spread, setSpread] = useState(1); // Scale spread const [spread, setSpread] = useState(1); // Scale spread
const statsB = { const statsB = {
min: 10 + shift - (5 * (spread - 1)), // Just approximating visual expansion min: 10 + shift - 5 * (spread - 1), // Just approximating visual expansion
q1: 16 + shift - (2 * (spread - 1)), q1: 16 + shift - 2 * (spread - 1),
med: 26 + shift, med: 26 + shift,
q3: 34 + shift + (2 * (spread - 1)), q3: 34 + shift + 2 * (spread - 1),
max: 38 + shift + (4 * (spread - 1)) max: 38 + shift + 4 * (spread - 1),
}; };
const scaleX = (val: number) => (val / 60) * 100; // 0 to 60 range mapping to % const scaleX = (val: number) => (val / 60) * 100; // 0 to 60 range mapping to %
const BoxPlot = ({ stats, color, label }: { stats: any, color: string, label: string }) => { const BoxPlot = ({
stats,
color,
label,
}: {
stats: any;
color: string;
label: string;
}) => {
const leftW = scaleX(stats.min); const leftW = scaleX(stats.min);
const rightW = scaleX(stats.max); const rightW = scaleX(stats.max);
const boxL = scaleX(stats.q1); const boxL = scaleX(stats.q1);
@ -27,27 +35,47 @@ const BoxPlotComparisonWidget: React.FC = () => {
return ( return (
<div className="relative h-16 w-full mb-8 group"> <div className="relative h-16 w-full mb-8 group">
<div className="absolute left-0 top-0 text-xs font-bold text-slate-400">{label}</div> <div className="absolute left-0 top-0 text-xs font-bold text-slate-400">
{label}
{/* Main Line (Whisker to Whisker) */}
<div className="absolute top-1/2 left-0 h-0.5 bg-slate-300 -translate-y-1/2"
style={{ left: `${leftW}%`, width: `${rightW - leftW}%` }} />
{/* Whiskers */}
<div className="absolute top-1/2 h-3 w-0.5 bg-slate-400 -translate-y-1/2" style={{ left: `${leftW}%` }} />
<div className="absolute top-1/2 h-3 w-0.5 bg-slate-400 -translate-y-1/2" style={{ left: `${rightW}%` }} />
{/* Box */}
<div className={`absolute top-1/2 -translate-y-1/2 h-8 border-2 ${color} bg-white opacity-90`}
style={{ left: `${boxL}%`, width: `${boxR - boxL}%`, borderColor: 'currentColor' }}>
</div> </div>
{/* Main Line (Whisker to Whisker) */}
<div
className="absolute top-1/2 left-0 h-0.5 bg-slate-300 -translate-y-1/2"
style={{ left: `${leftW}%`, width: `${rightW - leftW}%` }}
/>
{/* Whiskers */}
<div
className="absolute top-1/2 h-3 w-0.5 bg-slate-400 -translate-y-1/2"
style={{ left: `${leftW}%` }}
/>
<div
className="absolute top-1/2 h-3 w-0.5 bg-slate-400 -translate-y-1/2"
style={{ left: `${rightW}%` }}
/>
{/* Box */}
<div
className={`absolute top-1/2 -translate-y-1/2 h-8 border-2 ${color} bg-white opacity-90`}
style={{
left: `${boxL}%`,
width: `${boxR - boxL}%`,
borderColor: "currentColor",
}}
></div>
{/* Median Line */} {/* Median Line */}
<div className="absolute top-1/2 h-8 w-1 bg-slate-800 -translate-y-1/2" style={{ left: `${med}%` }} /> <div
className="absolute top-1/2 h-8 w-1 bg-slate-800 -translate-y-1/2"
style={{ left: `${med}%` }}
/>
{/* Labels on Hover */} {/* Labels on Hover */}
<div className="opacity-0 group-hover:opacity-100 transition-opacity absolute top-10 left-0 w-full text-center text-xs font-mono text-slate-500 pointer-events-none"> <div className="opacity-0 group-hover:opacity-100 transition-opacity absolute top-10 left-0 w-full text-center text-xs font-mono text-slate-500 pointer-events-none">
Min:{stats.min.toFixed(0)} Q1:{stats.q1.toFixed(0)} Med:{stats.med.toFixed(0)} Q3:{stats.q3.toFixed(0)} Max:{stats.max.toFixed(0)} Min:{stats.min.toFixed(0)} Q1:{stats.q1.toFixed(0)} Med:
{stats.med.toFixed(0)} Q3:{stats.q3.toFixed(0)} Max:
{stats.max.toFixed(0)}
</div> </div>
</div> </div>
); );
@ -55,52 +83,98 @@ const BoxPlotComparisonWidget: React.FC = () => {
const iqrA = statsA.q3 - statsA.q1; const iqrA = statsA.q3 - statsA.q1;
const iqrB = statsB.q3 - statsB.q1; const iqrB = statsB.q3 - statsB.q1;
const rangeA = statsA.max - statsA.min;
const rangeB = statsB.max - statsB.min;
return ( return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200"> <div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
<div className="mb-6 relative h-48 border-b border-slate-200"> <div className="mb-6 relative h-48 border-b border-slate-200">
<BoxPlot stats={statsA} color="text-indigo-500" label="Dataset A (Fixed)" /> <BoxPlot
<BoxPlot stats={statsB} color="text-rose-500" label="Dataset B (Adjustable)" /> stats={statsA}
color="text-indigo-500"
label="Dataset A (Fixed)"
/>
<BoxPlot
stats={statsB}
color="text-rose-500"
label="Dataset B (Adjustable)"
/>
{/* Axis */} {/* Axis */}
<div className="absolute bottom-0 w-full flex justify-between text-xs text-slate-400 font-mono px-2"> <div className="absolute bottom-0 w-full flex justify-between text-xs text-slate-400 font-mono px-2">
<span>0</span><span>10</span><span>20</span><span>30</span><span>40</span><span>50</span><span>60</span> <span>0</span>
<span>10</span>
<span>20</span>
<span>30</span>
<span>40</span>
<span>50</span>
<span>60</span>
</div> </div>
</div> </div>
<div className="flex flex-col md:flex-row gap-8"> <div className="flex flex-col md:flex-row gap-8">
<div className="w-full md:w-1/3 space-y-6"> <div className="w-full md:w-1/3 space-y-6">
<div> <div>
<label className="text-xs font-bold text-slate-500 uppercase">Shift Center (Median B)</label> <label className="text-xs font-bold text-slate-500 uppercase">
<input type="range" min="-15" max="15" value={shift} onChange={e => setShift(parseInt(e.target.value))} className="w-full h-2 bg-rose-100 rounded-lg appearance-none cursor-pointer accent-rose-600"/> Shift Center (Median B)
</label>
<input
type="range"
min="-15"
max="15"
value={shift}
onChange={(e) => setShift(parseInt(e.target.value))}
className="w-full h-2 bg-rose-100 rounded-lg appearance-none cursor-pointer accent-rose-600"
/>
</div> </div>
<div> <div>
<label className="text-xs font-bold text-slate-500 uppercase">Adjust Spread (IQR B)</label> <label className="text-xs font-bold text-slate-500 uppercase">
<input type="range" min="0.5" max="2" step="0.1" value={spread} onChange={e => setSpread(parseFloat(e.target.value))} className="w-full h-2 bg-rose-100 rounded-lg appearance-none cursor-pointer accent-rose-600"/> Adjust Spread (IQR B)
</label>
<input
type="range"
min="0.5"
max="2"
step="0.1"
value={spread}
onChange={(e) => setSpread(parseFloat(e.target.value))}
className="w-full h-2 bg-rose-100 rounded-lg appearance-none cursor-pointer accent-rose-600"
/>
</div> </div>
</div> </div>
<div className="flex-1 grid grid-cols-2 gap-4"> <div className="flex-1 grid grid-cols-2 gap-4">
<div className="bg-slate-50 p-3 rounded border border-slate-200"> <div className="bg-slate-50 p-3 rounded border border-slate-200">
<div className="text-xs font-bold text-slate-400 uppercase">Median Comparison</div> <div className="text-xs font-bold text-slate-400 uppercase">
Median Comparison
</div>
<div className="flex justify-between items-center mt-1"> <div className="flex justify-between items-center mt-1">
<span className="text-indigo-600 font-bold">{statsA.med}</span> <span className="text-indigo-600 font-bold">{statsA.med}</span>
<span className="text-slate-400">{statsA.med > statsB.med ? '>' : statsA.med < statsB.med ? '<' : '='}</span> <span className="text-slate-400">
{statsA.med > statsB.med
? ">"
: statsA.med < statsB.med
? "<"
: "="}
</span>
<span className="text-rose-600 font-bold">{statsB.med}</span> <span className="text-rose-600 font-bold">{statsB.med}</span>
</div> </div>
</div> </div>
<div className="bg-slate-50 p-3 rounded border border-slate-200"> <div className="bg-slate-50 p-3 rounded border border-slate-200">
<div className="text-xs font-bold text-slate-400 uppercase">IQR Comparison</div> <div className="text-xs font-bold text-slate-400 uppercase">
IQR Comparison
</div>
<div className="flex justify-between items-center mt-1"> <div className="flex justify-between items-center mt-1">
<span className="text-indigo-600 font-bold">{iqrA.toFixed(0)}</span> <span className="text-indigo-600 font-bold">
<span className="text-slate-400">{iqrA > iqrB ? '>' : iqrA < iqrB ? '<' : '='}</span> {iqrA.toFixed(0)}
</span>
<span className="text-slate-400">
{iqrA > iqrB ? ">" : iqrA < iqrB ? "<" : "="}
</span>
<span className="text-rose-600 font-bold">{iqrB.toFixed(0)}</span> <span className="text-rose-600 font-bold">{iqrB.toFixed(0)}</span>
</div> </div>
</div> </div>
<div className="col-span-2 text-xs text-slate-500 text-center"> <div className="col-span-2 text-xs text-slate-500 text-center">
The box length represents the IQR (Middle 50%). The whiskers represent the full Range. The box length represents the IQR (Middle 50%). The whiskers
represent the full Range.
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,4 +1,4 @@
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef } from "react";
const CircleTheoremsWidget: React.FC = () => { const CircleTheoremsWidget: React.FC = () => {
// C is the point on the major arc // C is the point on the major arc
@ -9,23 +9,9 @@ const CircleTheoremsWidget: React.FC = () => {
const R = 120; const R = 120;
const center = { x: 200, y: 180 }; const center = { x: 200, y: 180 };
// Fixed points A and B at the bottom
const angleA = 330; // 30 deg below x axis
const angleB = 210; // 150 deg below x axis? No, let's place them symmetrically
// Let's place A and B to define a nice arc
// A at -30 deg (330), B at 210 is too far.
// Let's put A at 320 (-40) and B at 220 (-140).
// Wait, standard unit circle angles.
// A at 340 (-20), B at 200. Arc is 140 deg at bottom.
// Major arc is top. C moves on top.
const posA = { x: center.x + R * Math.cos(340 * Math.PI/180), y: center.y - R * Math.sin(340 * Math.PI/180) }; // SVG y inverted logic?
// Let's just use standard math cos/sin and add to center.y
// SVG y is positive down.
const getPos = (deg: number) => ({ const getPos = (deg: number) => ({
x: center.x + R * Math.cos(deg * Math.PI / 180), x: center.x + R * Math.cos((deg * Math.PI) / 180),
y: center.y + R * Math.sin(deg * Math.PI / 180) y: center.y + R * Math.sin((deg * Math.PI) / 180),
}); });
const A = getPos(30); // Bottom Right const A = getPos(30); // Bottom Right
@ -38,7 +24,7 @@ const CircleTheoremsWidget: React.FC = () => {
const rect = svgRef.current.getBoundingClientRect(); const rect = svgRef.current.getBoundingClientRect();
const dx = e.clientX - rect.left - center.x; const dx = e.clientX - rect.left - center.x;
const dy = e.clientY - rect.top - center.y; const dy = e.clientY - rect.top - center.y;
let deg = Math.atan2(dy, dx) * 180 / Math.PI; let deg = (Math.atan2(dy, dx) * 180) / Math.PI;
if (deg < 0) deg += 360; if (deg < 0) deg += 360;
// Constrain C to the major arc (approx 160 to 350 is the "bad" zone? No, A=30, B=150. // Constrain C to the major arc (approx 160 to 350 is the "bad" zone? No, A=30, B=150.
@ -53,9 +39,12 @@ const CircleTheoremsWidget: React.FC = () => {
return ( return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200 flex flex-col items-center"> <div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200 flex flex-col items-center">
<h3 className="font-bold text-slate-700 mb-2">Central vs. Inscribed Angle</h3> <h3 className="font-bold text-slate-700 mb-2">
Central vs. Inscribed Angle
</h3>
<div className="text-sm text-slate-500 mb-4 text-center max-w-md"> <div className="text-sm text-slate-500 mb-4 text-center max-w-md">
Drag point <strong className="text-emerald-600">C</strong> along the top arc. Notice that the inscribed angle stays constant! Drag point <strong className="text-emerald-600">C</strong> along the top
arc. Notice that the inscribed angle stays constant!
</div> </div>
<svg <svg
@ -63,52 +52,113 @@ const CircleTheoremsWidget: React.FC = () => {
width="400" width="400"
height="350" height="350"
onMouseMove={handleMouseMove} onMouseMove={handleMouseMove}
onMouseUp={() => isDragging.current = false} onMouseUp={() => (isDragging.current = false)}
onMouseLeave={() => isDragging.current = false} onMouseLeave={() => (isDragging.current = false)}
className="select-none" className="select-none"
> >
{/* Circle */} {/* Circle */}
<circle cx={center.x} cy={center.y} r={R} stroke="#cbd5e1" strokeWidth="2" fill="transparent" /> <circle
cx={center.x}
cy={center.y}
r={R}
stroke="#cbd5e1"
strokeWidth="2"
fill="transparent"
/>
{/* Central Angle Lines */} {/* Central Angle Lines */}
<path d={`M ${A.x} ${A.y} L ${center.x} ${center.y} L ${B.x} ${B.y}`} stroke="#e2e8f0" strokeWidth="2" fill="transparent" strokeDasharray="5,5"/> <path
d={`M ${A.x} ${A.y} L ${center.x} ${center.y} L ${B.x} ${B.y}`}
stroke="#e2e8f0"
strokeWidth="2"
fill="transparent"
strokeDasharray="5,5"
/>
{/* Central Angle Wedge */} {/* Central Angle Wedge */}
{/* 30 to 150 */} {/* 30 to 150 */}
<path d={`M ${center.x} ${center.y} L ${A.x} ${A.y} A ${R} ${R} 0 0 1 ${B.x} ${B.y} Z`} fill="rgba(99, 102, 241, 0.1)" stroke="none" /> <path
<text x={center.x} y={center.y + 40} textAnchor="middle" className="text-sm font-bold fill-indigo-600">{centralAngleValue}°</text> d={`M ${center.x} ${center.y} L ${A.x} ${A.y} A ${R} ${R} 0 0 1 ${B.x} ${B.y} Z`}
<text x={center.x} y={center.y + 60} textAnchor="middle" className="text-xs fill-indigo-400 uppercase">Central</text> fill="rgba(99, 102, 241, 0.1)"
stroke="none"
/>
<text
x={center.x}
y={center.y + 40}
textAnchor="middle"
className="text-sm font-bold fill-indigo-600"
>
{centralAngleValue}°
</text>
<text
x={center.x}
y={center.y + 60}
textAnchor="middle"
className="text-xs fill-indigo-400 uppercase"
>
Central
</text>
{/* Inscribed Angle Lines */} {/* Inscribed Angle Lines */}
<path d={`M ${A.x} ${A.y} L ${C.x} ${C.y} L ${B.x} ${B.y}`} stroke="#059669" strokeWidth="3" fill="transparent" strokeLinejoin="round" /> <path
d={`M ${A.x} ${A.y} L ${C.x} ${C.y} L ${B.x} ${B.y}`}
stroke="#059669"
strokeWidth="3"
fill="transparent"
strokeLinejoin="round"
/>
{/* Points */} {/* Points */}
<circle cx={center.x} cy={center.y} r="4" fill="#64748b" /> {/* Center */} <circle cx={center.x} cy={center.y} r="4" fill="#64748b" />{" "}
<text x={center.x + 10} y={center.y} className="text-xs fill-slate-400">O</text> {/* Center */}
<text x={center.x + 10} y={center.y} className="text-xs fill-slate-400">
O
</text>
<circle cx={A.x} cy={A.y} r="5" fill="#475569" /> <circle cx={A.x} cy={A.y} r="5" fill="#475569" />
<text x={A.x + 10} y={A.y} className="text-xs font-bold fill-slate-600">A</text> <text x={A.x + 10} y={A.y} className="text-xs font-bold fill-slate-600">
A
</text>
<circle cx={B.x} cy={B.y} r="5" fill="#475569" /> <circle cx={B.x} cy={B.y} r="5" fill="#475569" />
<text x={B.x - 20} y={B.y} className="text-xs font-bold fill-slate-600">B</text> <text x={B.x - 20} y={B.y} className="text-xs font-bold fill-slate-600">
B
</text>
{/* Draggable C */} {/* Draggable C */}
<g onMouseDown={() => isDragging.current = true} className="cursor-grab active:cursor-grabbing"> <g
<circle cx={C.x} cy={C.y} r="15" fill="transparent" /> {/* Hit area */} onMouseDown={() => (isDragging.current = true)}
<circle cx={C.x} cy={C.y} r="8" fill="#059669" stroke="white" strokeWidth="2" className="shadow-lg" /> className="cursor-grab active:cursor-grabbing"
<text x={C.x} y={C.y - 15} textAnchor="middle" className="text-sm font-bold fill-emerald-700">C</text> >
<circle cx={C.x} cy={C.y} r="15" fill="transparent" />{" "}
{/* Hit area */}
<circle
cx={C.x}
cy={C.y}
r="8"
fill="#059669"
stroke="white"
strokeWidth="2"
className="shadow-lg"
/>
<text
x={C.x}
y={C.y - 15}
textAnchor="middle"
className="text-sm font-bold fill-emerald-700"
>
C
</text>
</g> </g>
{/* Inscribed Angle Label */} {/* Inscribed Angle Label */}
{/* Simple approximation for label placement: slightly "in" from C towards center */} {/* Simple approximation for label placement: slightly "in" from C towards center */}
<text x={C.x + (center.x - C.x)*0.2} y={C.y + (center.y - C.y)*0.2 + 5} textAnchor="middle" className="text-lg font-bold fill-emerald-600"> <text
x={C.x + (center.x - C.x) * 0.2}
y={C.y + (center.y - C.y) * 0.2 + 5}
textAnchor="middle"
className="text-lg font-bold fill-emerald-600"
>
{centralAngleValue / 2}° {centralAngleValue / 2}°
</text> </text>
</svg> </svg>
<div className="bg-slate-50 p-4 rounded-lg border border-slate-200 mt-4 w-full text-center"> <div className="bg-slate-50 p-4 rounded-lg border border-slate-200 mt-4 w-full text-center">
<p className="font-mono text-lg text-slate-800"> <p className="font-mono text-lg text-slate-800">
Inscribed Angle = <span className="text-emerald-600">½</span> × Central Angle Inscribed Angle = <span className="text-emerald-600">½</span> ×
Central Angle
</p> </p>
<p className="font-mono text-md text-slate-600 mt-1"> <p className="font-mono text-md text-slate-600 mt-1">
{centralAngleValue / 2}° = ½ × {centralAngleValue}° {centralAngleValue / 2}° = ½ × {centralAngleValue}°

View File

@ -1,7 +1,14 @@
import React, { useState } from 'react'; import { useState } from "react";
import { MousePointerClick } from 'lucide-react'; import { MousePointerClick } from "lucide-react";
export type SegmentType = 'ic' | 'dc' | 'modifier' | 'conjunction' | 'punct' | 'subject' | 'verb'; export type SegmentType =
| "ic"
| "dc"
| "modifier"
| "conjunction"
| "punct"
| "subject"
| "verb";
export interface Segment { export interface Segment {
text: string; text: string;
@ -19,52 +26,95 @@ interface ClauseBreakdownWidgetProps {
accentColor?: string; accentColor?: string;
} }
const TYPE_STYLES: Record<SegmentType, { bg: string; text: string; border: string; ring: string }> = { const TYPE_STYLES: Record<
ic: { bg: 'bg-blue-100', text: 'text-blue-800', border: 'border-blue-300', ring: '#93c5fd' }, SegmentType,
dc: { bg: 'bg-green-100', text: 'text-green-800', border: 'border-green-300', ring: '#86efac' }, { bg: string; text: string; border: string; ring: string }
modifier: { bg: 'bg-orange-100', text: 'text-orange-800', border: 'border-orange-300', ring: '#fdba74' }, > = {
conjunction: { bg: 'bg-purple-100', text: 'text-purple-800', border: 'border-purple-300', ring: '#c4b5fd' }, ic: {
subject: { bg: 'bg-sky-100', text: 'text-sky-800', border: 'border-sky-300', ring: '#7dd3fc' }, bg: "bg-blue-100",
verb: { bg: 'bg-rose-100', text: 'text-rose-800', border: 'border-rose-300', ring: '#fda4af' }, text: "text-blue-800",
punct: { bg: 'bg-gray-100', text: 'text-gray-600', border: 'border-gray-300', ring: '#d1d5db' }, border: "border-blue-300",
ring: "#93c5fd",
},
dc: {
bg: "bg-green-100",
text: "text-green-800",
border: "border-green-300",
ring: "#86efac",
},
modifier: {
bg: "bg-orange-100",
text: "text-orange-800",
border: "border-orange-300",
ring: "#fdba74",
},
conjunction: {
bg: "bg-purple-100",
text: "text-purple-800",
border: "border-purple-300",
ring: "#c4b5fd",
},
subject: {
bg: "bg-sky-100",
text: "text-sky-800",
border: "border-sky-300",
ring: "#7dd3fc",
},
verb: {
bg: "bg-rose-100",
text: "text-rose-800",
border: "border-rose-300",
ring: "#fda4af",
},
punct: {
bg: "bg-gray-100",
text: "text-gray-600",
border: "border-gray-300",
ring: "#d1d5db",
},
}; };
const TYPE_LABELS: Record<SegmentType, string> = { const TYPE_LABELS: Record<SegmentType, string> = {
ic: 'Independent Clause', ic: "Independent Clause",
dc: 'Dependent Clause', dc: "Dependent Clause",
modifier: 'Modifier', modifier: "Modifier",
conjunction: 'Conjunction', conjunction: "Conjunction",
subject: 'Subject', subject: "Subject",
verb: 'Verb / Predicate', verb: "Verb / Predicate",
punct: 'Punctuation', punct: "Punctuation",
}; };
// Pre-resolved tab accent classes (avoids Tailwind purge issues with dynamic strings) // Pre-resolved tab accent classes (avoids Tailwind purge issues with dynamic strings)
const TAB_ACTIVE: Record<string, string> = { const TAB_ACTIVE: Record<string, string> = {
purple: 'border-b-2 border-purple-600 text-purple-700 bg-white', purple: "border-b-2 border-purple-600 text-purple-700 bg-white",
teal: 'border-b-2 border-teal-600 text-teal-700 bg-white', teal: "border-b-2 border-teal-600 text-teal-700 bg-white",
fuchsia: 'border-b-2 border-fuchsia-600 text-fuchsia-700 bg-white', fuchsia: "border-b-2 border-fuchsia-600 text-fuchsia-700 bg-white",
amber: 'border-b-2 border-amber-600 text-amber-700 bg-white', amber: "border-b-2 border-amber-600 text-amber-700 bg-white",
}; };
export default function ClauseBreakdownWidget({ examples, accentColor = 'purple' }: ClauseBreakdownWidgetProps) { export default function ClauseBreakdownWidget({
examples,
accentColor = "purple",
}: ClauseBreakdownWidgetProps) {
const [activeTab, setActiveTab] = useState(0); const [activeTab, setActiveTab] = useState(0);
const [selected, setSelected] = useState<number | null>(null); const [selected, setSelected] = useState<number | null>(null);
const example = examples[activeTab]; const example = examples[activeTab];
const switchTab = (i: number) => { setActiveTab(i); setSelected(null); }; const switchTab = (i: number) => {
setActiveTab(i);
setSelected(null);
};
const selectedSeg = selected !== null ? example.segments[selected] : null; const selectedSeg = selected !== null ? example.segments[selected] : null;
const tabActive = TAB_ACTIVE[accentColor] ?? TAB_ACTIVE.purple; const tabActive = TAB_ACTIVE[accentColor] ?? TAB_ACTIVE.purple;
// Unique labeled segment types for the legend // Unique labeled segment types for the legend
const legendTypes = Array.from( const legendTypes = Array.from(
new Set(example.segments.filter(s => s.label).map(s => s.type)) new Set(example.segments.filter((s) => s.label).map((s) => s.type)),
); );
return ( return (
<div className="rounded-2xl border border-gray-200 bg-white overflow-hidden shadow-sm"> <div className="rounded-2xl border border-gray-200 bg-white overflow-hidden shadow-sm">
{/* Tab strip */} {/* Tab strip */}
{examples.length > 1 && ( {examples.length > 1 && (
<div className="flex border-b border-gray-200 bg-gray-50 overflow-x-auto"> <div className="flex border-b border-gray-200 bg-gray-50 overflow-x-auto">
@ -73,7 +123,9 @@ export default function ClauseBreakdownWidget({ examples, accentColor = 'purple'
key={i} key={i}
onClick={() => switchTab(i)} onClick={() => switchTab(i)}
className={`px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-colors ${ className={`px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-colors ${
i === activeTab ? tabActive : 'text-gray-500 hover:text-gray-700' i === activeTab
? tabActive
: "text-gray-500 hover:text-gray-700"
}`} }`}
> >
{ex.title} {ex.title}
@ -83,14 +135,18 @@ export default function ClauseBreakdownWidget({ examples, accentColor = 'purple'
)} )}
{examples.length === 1 && ( {examples.length === 1 && (
<div className="px-5 pt-4 pb-1"> <div className="px-5 pt-4 pb-1">
<p className="text-xs font-semibold uppercase tracking-wider text-gray-400">{example.title}</p> <p className="text-xs font-semibold uppercase tracking-wider text-gray-400">
{example.title}
</p>
</div> </div>
)} )}
{/* Instruction */} {/* Instruction */}
<div className="px-5 pt-3 pb-1 flex items-center gap-1.5"> <div className="px-5 pt-3 pb-1 flex items-center gap-1.5">
<MousePointerClick className="w-3.5 h-3.5 text-gray-400 shrink-0" /> <MousePointerClick className="w-3.5 h-3.5 text-gray-400 shrink-0" />
<p className="text-xs text-gray-400 italic">Click any colored part to see its grammatical role</p> <p className="text-xs text-gray-400 italic">
Click any colored part to see its grammatical role
</p>
</div> </div>
{/* Sentence display */} {/* Sentence display */}
@ -99,7 +155,11 @@ export default function ClauseBreakdownWidget({ examples, accentColor = 'purple'
{example.segments.map((seg, i) => { {example.segments.map((seg, i) => {
if (!seg.label) { if (!seg.label) {
// Punctuation / unlabeled — plain unstyled text, not clickable // Punctuation / unlabeled — plain unstyled text, not clickable
return <span key={i} className="text-gray-700">{seg.text}</span>; return (
<span key={i} className="text-gray-700">
{seg.text}
</span>
);
} }
const style = TYPE_STYLES[seg.type]; const style = TYPE_STYLES[seg.type];
const isSelected = selected === i; const isSelected = selected === i;
@ -112,7 +172,14 @@ export default function ClauseBreakdownWidget({ examples, accentColor = 'purple'
? `border-2 ${style.border} font-semibold` ? `border-2 ${style.border} font-semibold`
: `border ${style.border} hover:opacity-80` : `border ${style.border} hover:opacity-80`
}`} }`}
style={isSelected ? { outline: `2.5px solid ${style.ring}`, outlineOffset: '1px' } : {}} style={
isSelected
? {
outline: `2.5px solid ${style.ring}`,
outlineOffset: "1px",
}
: {}
}
> >
{seg.text} {seg.text}
</span> </span>
@ -130,29 +197,38 @@ export default function ClauseBreakdownWidget({ examples, accentColor = 'purple'
style={{ backgroundColor: TYPE_STYLES[selectedSeg.type].ring }} style={{ backgroundColor: TYPE_STYLES[selectedSeg.type].ring }}
/> />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className={`text-xs font-bold uppercase tracking-wider mb-0.5 ${TYPE_STYLES[selectedSeg.type].text}`}> <p
className={`text-xs font-bold uppercase tracking-wider mb-0.5 ${TYPE_STYLES[selectedSeg.type].text}`}
>
{selectedSeg.label ?? TYPE_LABELS[selectedSeg.type]} {selectedSeg.label ?? TYPE_LABELS[selectedSeg.type]}
</p> </p>
<p className={`text-sm font-semibold leading-snug ${TYPE_STYLES[selectedSeg.type].text}`}> <p
className={`text-sm font-semibold leading-snug ${TYPE_STYLES[selectedSeg.type].text}`}
>
"{selectedSeg.text.trim()}" "{selectedSeg.text.trim()}"
</p> </p>
</div> </div>
</div> </div>
) : ( ) : (
<p className="mt-2 text-xs text-gray-400 italic px-1">No element selected click a colored span above.</p> <p className="mt-2 text-xs text-gray-400 italic px-1">
No element selected click a colored span above.
</p>
)} )}
</div> </div>
{/* Legend */} {/* Legend */}
<div className="px-5 py-3 border-t border-gray-100 bg-gray-50 flex flex-wrap gap-2"> <div className="px-5 py-3 border-t border-gray-100 bg-gray-50 flex flex-wrap gap-2">
{legendTypes.map(type => { {legendTypes.map((type) => {
const style = TYPE_STYLES[type]; const style = TYPE_STYLES[type];
return ( return (
<span <span
key={type} key={type}
className={`inline-flex items-center gap-1.5 text-xs font-medium px-2.5 py-1 rounded-full border ${style.bg} ${style.text} ${style.border}`} className={`inline-flex items-center gap-1.5 text-xs font-medium px-2.5 py-1 rounded-full border ${style.bg} ${style.text} ${style.border}`}
> >
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: TYPE_STYLES[type].ring }} /> <span
className="w-2 h-2 rounded-full"
style={{ backgroundColor: TYPE_STYLES[type].ring }}
/>
{TYPE_LABELS[type]} {TYPE_LABELS[type]}
</span> </span>
); );

View File

@ -1,5 +1,5 @@
import React, { useState } from 'react'; import { useState } from "react";
import { CheckCircle2, RotateCcw, ChevronRight } from 'lucide-react'; import { CheckCircle2, RotateCcw, ChevronRight } from "lucide-react";
export interface VocabOption { export interface VocabOption {
id: string; id: string;
@ -20,41 +20,58 @@ interface ContextEliminationWidgetProps {
accentColor?: string; accentColor?: string;
} }
export default function ContextEliminationWidget({ exercises, accentColor = 'rose' }: ContextEliminationWidgetProps) { export default function ContextEliminationWidget({
exercises,
accentColor = "rose",
}: ContextEliminationWidgetProps) {
const [activeEx, setActiveEx] = useState(0); const [activeEx, setActiveEx] = useState(0);
const [eliminated, setEliminated] = useState<Set<string>>(new Set()); const [eliminated, setEliminated] = useState<Set<string>>(new Set());
const [revealed, setRevealed] = useState(false); const [revealed, setRevealed] = useState(false);
const [triedCorrect, setTriedCorrect] = useState(false); const [triedCorrect, setTriedCorrect] = useState(false);
const exercise = exercises[activeEx]; const exercise = exercises[activeEx];
const wrongIds = exercise.options.filter(o => !o.isCorrect).map(o => o.id); const wrongIds = exercise.options
const allWrongEliminated = wrongIds.every(id => eliminated.has(id)); .filter((o) => !o.isCorrect)
.map((o) => o.id);
const eliminate = (id: string) => { const eliminate = (id: string) => {
const opt = exercise.options.find(o => o.id === id)!; const opt = exercise.options.find((o) => o.id === id)!;
if (opt.isCorrect) { if (opt.isCorrect) {
setTriedCorrect(true); setTriedCorrect(true);
setTimeout(() => setTriedCorrect(false), 1500); setTimeout(() => setTriedCorrect(false), 1500);
} else { } else {
const newElim = new Set([...eliminated, id]); const newElim = new Set([...eliminated, id]);
setEliminated(newElim); setEliminated(newElim);
if (wrongIds.every(wid => newElim.has(wid))) { if (wrongIds.every((wid) => newElim.has(wid))) {
setRevealed(true); setRevealed(true);
} }
} }
}; };
const reset = () => { setEliminated(new Set()); setRevealed(false); setTriedCorrect(false); }; const reset = () => {
const switchEx = (i: number) => { setActiveEx(i); setEliminated(new Set()); setRevealed(false); setTriedCorrect(false); }; setEliminated(new Set());
setRevealed(false);
setTriedCorrect(false);
};
const switchEx = (i: number) => {
setActiveEx(i);
setEliminated(new Set());
setRevealed(false);
setTriedCorrect(false);
};
// Highlight the target word in the sentence // Highlight the target word in the sentence
const renderSentence = () => { const renderSentence = () => {
const idx = exercise.sentence.toLowerCase().indexOf(exercise.word.toLowerCase()); const idx = exercise.sentence
.toLowerCase()
.indexOf(exercise.word.toLowerCase());
if (idx === -1) return <>{exercise.sentence}</>; if (idx === -1) return <>{exercise.sentence}</>;
return ( return (
<> <>
{exercise.sentence.slice(0, idx)} {exercise.sentence.slice(0, idx)}
<mark className={`bg-${accentColor}-200 text-${accentColor}-900 font-bold px-0.5 rounded not-italic`}> <mark
className={`bg-${accentColor}-200 text-${accentColor}-900 font-bold px-0.5 rounded not-italic`}
>
{exercise.sentence.slice(idx, idx + exercise.word.length)} {exercise.sentence.slice(idx, idx + exercise.word.length)}
</mark> </mark>
{exercise.sentence.slice(idx + exercise.word.length)} {exercise.sentence.slice(idx + exercise.word.length)}
@ -74,7 +91,7 @@ export default function ContextEliminationWidget({ exercises, accentColor = 'ros
className={`px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-colors ${ className={`px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-colors ${
i === activeEx i === activeEx
? `bg-white border-b-2 border-${accentColor}-600 text-${accentColor}-700` ? `bg-white border-b-2 border-${accentColor}-600 text-${accentColor}-700`
: 'text-gray-500 hover:text-gray-700' : "text-gray-500 hover:text-gray-700"
}`} }`}
> >
Word {i + 1} Word {i + 1}
@ -84,17 +101,27 @@ export default function ContextEliminationWidget({ exercises, accentColor = 'ros
)} )}
{/* Sentence in context */} {/* Sentence in context */}
<div className={`px-5 py-4 border-b border-gray-100 bg-${accentColor}-50`}> <div
<p className={`text-xs font-semibold uppercase tracking-wider text-${accentColor}-500 mb-2`}>Sentence in Context</p> className={`px-5 py-4 border-b border-gray-100 bg-${accentColor}-50`}
<p className="text-gray-700 italic leading-relaxed text-sm">{renderSentence()}</p> >
<p
className={`text-xs font-semibold uppercase tracking-wider text-${accentColor}-500 mb-2`}
>
Sentence in Context
</p>
<p className="text-gray-700 italic leading-relaxed text-sm">
{renderSentence()}
</p>
</div> </div>
{/* Question + instruction */} {/* Question + instruction */}
<div className="px-5 pt-4 pb-2"> <div className="px-5 pt-4 pb-2">
<p className="font-medium text-gray-800 text-sm mb-1">{exercise.question}</p> <p className="font-medium text-gray-800 text-sm mb-1">
{exercise.question}
</p>
<p className="text-xs text-gray-400 italic"> <p className="text-xs text-gray-400 italic">
{revealed {revealed
? 'You found it! The correct definition is highlighted.' ? "You found it! The correct definition is highlighted."
: 'Click "Eliminate" on definitions that don\'t fit the context. Work by elimination.'} : 'Click "Eliminate" on definitions that don\'t fit the context. Work by elimination.'}
</p> </p>
</div> </div>
@ -108,40 +135,52 @@ export default function ContextEliminationWidget({ exercises, accentColor = 'ros
{/* Options */} {/* Options */}
<div className="px-5 py-3 space-y-2"> <div className="px-5 py-3 space-y-2">
{exercise.options.map(opt => { {exercise.options.map((opt) => {
const isElim = eliminated.has(opt.id); const isElim = eliminated.has(opt.id);
const isAnswer = opt.isCorrect && revealed; const isAnswer = opt.isCorrect && revealed;
let wrapCls = 'border-gray-200 bg-white'; let wrapCls = "border-gray-200 bg-white";
if (isAnswer) wrapCls = 'border-green-400 bg-green-50'; if (isAnswer) wrapCls = "border-green-400 bg-green-50";
else if (isElim) wrapCls = 'border-gray-100 bg-gray-50'; else if (isElim) wrapCls = "border-gray-100 bg-gray-50";
return ( return (
<div <div
key={opt.id} key={opt.id}
className={`rounded-xl border px-4 py-3 transition-all ${wrapCls} ${isElim ? 'opacity-50' : ''}`} className={`rounded-xl border px-4 py-3 transition-all ${wrapCls} ${isElim ? "opacity-50" : ""}`}
> >
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<span className={`text-xs font-bold mt-0.5 shrink-0 ${isElim ? 'text-gray-400' : isAnswer ? 'text-green-700' : 'text-gray-500'}`}> <span
className={`text-xs font-bold mt-0.5 shrink-0 ${isElim ? "text-gray-400" : isAnswer ? "text-green-700" : "text-gray-500"}`}
>
{opt.id}. {opt.id}.
</span> </span>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className={`text-sm leading-snug ${ <p
isElim ? 'text-gray-400 line-through' : className={`text-sm leading-snug ${
isAnswer ? 'text-green-800 font-semibold' : isElim
'text-gray-700' ? "text-gray-400 line-through"
}`}> : isAnswer
? "text-green-800 font-semibold"
: "text-gray-700"
}`}
>
{opt.definition} {opt.definition}
</p> </p>
{isElim && ( {isElim && (
<p className="text-xs text-gray-400 mt-0.5 italic">{opt.elimReason}</p> <p className="text-xs text-gray-400 mt-0.5 italic">
{opt.elimReason}
</p>
)} )}
{isAnswer && ( {isAnswer && (
<p className="text-xs text-green-700 mt-1">✓ {opt.elimReason}</p> <p className="text-xs text-green-700 mt-1">
{opt.elimReason}
</p>
)} )}
</div> </div>
<div className="shrink-0"> <div className="shrink-0">
{isAnswer && <CheckCircle2 className="w-5 h-5 text-green-500" />} {isAnswer && (
<CheckCircle2 className="w-5 h-5 text-green-500" />
)}
{!isElim && !isAnswer && !revealed && ( {!isElim && !isAnswer && !revealed && (
<button <button
onClick={() => eliminate(opt.id)} onClick={() => eliminate(opt.id)}
@ -158,7 +197,10 @@ export default function ContextEliminationWidget({ exercises, accentColor = 'ros
</div> </div>
<div className="px-5 pb-5 flex items-center gap-3"> <div className="px-5 pb-5 flex items-center gap-3">
<button onClick={reset} className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700 transition-colors"> <button
onClick={reset}
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700 transition-colors"
>
<RotateCcw className="w-3.5 h-3.5" /> Reset <RotateCcw className="w-3.5 h-3.5" /> Reset
</button> </button>
{revealed && activeEx < exercises.length - 1 && ( {revealed && activeEx < exercises.length - 1 && (

View File

@ -1,6 +1,11 @@
import React, { useRef, useState, useEffect } from 'react'; import React, { useRef, useState } from "react";
import { scaleToSvg, scaleFromSvg, round, calculateDistanceSquared } from '../utils/math'; import {
import { CircleState, Point } from '../types'; scaleToSvg,
scaleFromSvg,
round,
calculateDistanceSquared,
} from "../../utils/math";
import { type CircleState, type Point } from "../../types/lesson";
interface CoordinatePlaneProps { interface CoordinatePlaneProps {
circle: CircleState; circle: CircleState;
@ -8,7 +13,7 @@ interface CoordinatePlaneProps {
onPointClick?: (p: Point) => void; onPointClick?: (p: Point) => void;
interactive?: boolean; interactive?: boolean;
showDistance?: boolean; showDistance?: boolean;
mode?: 'view' | 'place_point'; mode?: "view" | "place_point";
} }
const CoordinatePlane: React.FC<CoordinatePlaneProps> = ({ const CoordinatePlane: React.FC<CoordinatePlaneProps> = ({
@ -16,7 +21,7 @@ const CoordinatePlane: React.FC<CoordinatePlaneProps> = ({
point, point,
onPointClick, onPointClick,
showDistance = false, showDistance = false,
mode = 'view' mode = "view",
}) => { }) => {
const svgRef = useRef<SVGSVGElement>(null); const svgRef = useRef<SVGSVGElement>(null);
const [hoverPoint, setHoverPoint] = useState<Point | null>(null); const [hoverPoint, setHoverPoint] = useState<Point | null>(null);
@ -39,7 +44,7 @@ const CoordinatePlane: React.FC<CoordinatePlaneProps> = ({
const rPx = toX(circle.r) - toX(0); const rPx = toX(circle.r) - toX(0);
const handleMouseMove = (e: React.MouseEvent) => { const handleMouseMove = (e: React.MouseEvent) => {
if (mode !== 'place_point' || !svgRef.current) return; if (mode !== "place_point" || !svgRef.current) return;
const rect = svgRef.current.getBoundingClientRect(); const rect = svgRef.current.getBoundingClientRect();
const rawX = e.clientX - rect.left; const rawX = e.clientX - rect.left;
const rawY = e.clientY - rect.top; const rawY = e.clientY - rect.top;
@ -52,7 +57,7 @@ const CoordinatePlane: React.FC<CoordinatePlaneProps> = ({
}; };
const handleClick = () => { const handleClick = () => {
if (mode === 'place_point' && hoverPoint && onPointClick) { if (mode === "place_point" && hoverPoint && onPointClick) {
onPointClick(hoverPoint); onPointClick(hoverPoint);
} }
}; };
@ -64,11 +69,13 @@ const CoordinatePlane: React.FC<CoordinatePlaneProps> = ({
ticks.push(i); ticks.push(i);
} }
const dSquared = point ? calculateDistanceSquared(point.x, point.y, circle.h, circle.k) : 0; const dSquared = point
? calculateDistanceSquared(point.x, point.y, circle.h, circle.k)
: 0;
const isInside = dSquared < circle.r * circle.r; const isInside = dSquared < circle.r * circle.r;
const isOn = Math.abs(dSquared - circle.r * circle.r) < 0.01; const isOn = Math.abs(dSquared - circle.r * circle.r) < 0.01;
const pointColor = isOn ? 'text-yellow-600' : isInside ? 'text-green-600' : 'text-red-600';
const pointFill = isOn ? '#ca8a04' : isInside ? '#16a34a' : '#dc2626'; const pointFill = isOn ? "#ca8a04" : isInside ? "#16a34a" : "#dc2626";
return ( return (
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
@ -80,19 +87,47 @@ const CoordinatePlane: React.FC<CoordinatePlaneProps> = ({
onMouseMove={handleMouseMove} onMouseMove={handleMouseMove}
onMouseLeave={() => setHoverPoint(null)} onMouseLeave={() => setHoverPoint(null)}
onClick={handleClick} onClick={handleClick}
className={`${mode === 'place_point' ? 'cursor-crosshair' : 'cursor-default'}`} className={`${mode === "place_point" ? "cursor-crosshair" : "cursor-default"}`}
> >
{/* Grid Background */} {/* Grid Background */}
{ticks.map(t => ( {ticks.map((t) => (
<React.Fragment key={t}> <React.Fragment key={t}>
<line x1={toX(t)} y1={0} x2={toX(t)} y2={height} stroke="#e2e8f0" strokeWidth="1" /> <line
<line x1={0} y1={toY(t)} x2={width} y2={toY(t)} stroke="#e2e8f0" strokeWidth="1" /> x1={toX(t)}
y1={0}
x2={toX(t)}
y2={height}
stroke="#e2e8f0"
strokeWidth="1"
/>
<line
x1={0}
y1={toY(t)}
x2={width}
y2={toY(t)}
stroke="#e2e8f0"
strokeWidth="1"
/>
</React.Fragment> </React.Fragment>
))} ))}
{/* Axes */} {/* Axes */}
<line x1={toX(0)} y1={0} x2={toX(0)} y2={height} stroke="#64748b" strokeWidth="2" /> <line
<line x1={0} y1={toY(0)} x2={width} y2={toY(0)} stroke="#64748b" strokeWidth="2" /> x1={toX(0)}
y1={0}
x2={toX(0)}
y2={height}
stroke="#64748b"
strokeWidth="2"
/>
<line
x1={0}
y1={toY(0)}
x2={width}
y2={toY(0)}
stroke="#64748b"
strokeWidth="2"
/>
{/* Circle */} {/* Circle */}
<circle <circle
@ -107,7 +142,15 @@ const CoordinatePlane: React.FC<CoordinatePlaneProps> = ({
{/* Center Point */} {/* Center Point */}
<circle cx={cx} cy={cy} r={4} fill="#4f46e5" /> <circle cx={cx} cy={cy} r={4} fill="#4f46e5" />
<text x={cx + 8} y={cy - 8} fontSize="12" fill="#4f46e5" fontWeight="bold">Center ({circle.h}, {circle.k})</text> <text
x={cx + 8}
y={cy - 8}
fontSize="12"
fill="#4f46e5"
fontWeight="bold"
>
Center ({circle.h}, {circle.k})
</text>
{/* Radius Line (only if distance line is not active to avoid clutter) */} {/* Radius Line (only if distance line is not active to avoid clutter) */}
{!point && ( {!point && (
@ -122,7 +165,9 @@ const CoordinatePlane: React.FC<CoordinatePlaneProps> = ({
/> />
)} )}
{!point && ( {!point && (
<text x={cx + rPx/2} y={cy - 5} fontSize="12" fill="#4f46e5">r = {circle.r}</text> <text x={cx + rPx / 2} y={cy - 5} fontSize="12" fill="#4f46e5">
r = {circle.r}
</text>
)} )}
{/* Placed Point */} {/* Placed Point */}
@ -137,16 +182,34 @@ const CoordinatePlane: React.FC<CoordinatePlaneProps> = ({
strokeWidth="2" strokeWidth="2"
strokeDasharray="4,4" strokeDasharray="4,4"
/> />
<circle cx={toX(point.x)} cy={toY(point.y)} r={6} fill={pointFill} stroke="white" strokeWidth="2" /> <circle
<text x={toX(point.x) + 8} y={toY(point.y) - 8} fontSize="12" fontWeight="bold" fill={pointFill}> cx={toX(point.x)}
cy={toY(point.y)}
r={6}
fill={pointFill}
stroke="white"
strokeWidth="2"
/>
<text
x={toX(point.x) + 8}
y={toY(point.y) - 8}
fontSize="12"
fontWeight="bold"
fill={pointFill}
>
({point.x}, {point.y}) ({point.x}, {point.y})
</text> </text>
</> </>
)} )}
{/* Hover Ghost Point */} {/* Hover Ghost Point */}
{mode === 'place_point' && hoverPoint && !point && ( {mode === "place_point" && hoverPoint && !point && (
<circle cx={toX(hoverPoint.x)} cy={toY(hoverPoint.y)} r={4} fill="rgba(0,0,0,0.3)" /> <circle
cx={toX(hoverPoint.x)}
cy={toY(hoverPoint.y)}
r={4}
fill="rgba(0,0,0,0.3)"
/>
)} )}
</svg> </svg>
@ -157,25 +220,42 @@ const CoordinatePlane: React.FC<CoordinatePlaneProps> = ({
{/* Info Panel below graph */} {/* Info Panel below graph */}
{point && showDistance && ( {point && showDistance && (
<div className={`mt-4 p-4 rounded-lg border-l-4 w-full max-w-md bg-white shadow-sm transition-colors ${ <div
isOn ? 'border-yellow-500 bg-yellow-50' : className={`mt-4 p-4 rounded-lg border-l-4 w-full max-w-md bg-white shadow-sm transition-colors ${
isInside ? 'border-green-500 bg-green-50' : isOn
'border-red-500 bg-red-50' ? "border-yellow-500 bg-yellow-50"
}`}> : isInside
? "border-green-500 bg-green-50"
: "border-red-500 bg-red-50"
}`}
>
<div className="flex justify-between items-center mb-2"> <div className="flex justify-between items-center mb-2">
<span className="font-bold text-slate-700">Distance Check:</span> <span className="font-bold text-slate-700">Distance Check:</span>
<span className={`px-2 py-0.5 rounded text-sm font-bold uppercase ${ <span
isOn ? 'bg-yellow-200 text-yellow-800' : className={`px-2 py-0.5 rounded text-sm font-bold uppercase ${
isInside ? 'bg-green-200 text-green-800' : isOn
'bg-red-200 text-red-800' ? "bg-yellow-200 text-yellow-800"
}`}> : isInside
{isOn ? 'On Circle' : isInside ? 'Inside' : 'Outside'} ? "bg-green-200 text-green-800"
: "bg-red-200 text-red-800"
}`}
>
{isOn ? "On Circle" : isInside ? "Inside" : "Outside"}
</span> </span>
</div> </div>
<div className="font-mono text-sm space-y-1"> <div className="font-mono text-sm space-y-1">
<p>d² = (x - h)² + (y - k)²</p> <p>d² = (x - h)² + (y - k)²</p>
<p>d² = ({point.x} - {circle.h})² + ({point.y} - {circle.k})²</p> <p>
<p>d² = {round(calculateDistanceSquared(point.x, point.y, circle.h, circle.k))} <span className="mx-2 text-slate-400">vs</span> r² = {circle.r * circle.r}</p> d² = ({point.x} - {circle.h})² + ({point.y} - {circle.k})²
</p>
<p>
d² ={" "}
{round(
calculateDistanceSquared(point.x, point.y, circle.h, circle.k),
)}{" "}
<span className="mx-2 text-slate-400">vs</span> r² ={" "}
{circle.r * circle.r}
</p>
</div> </div>
</div> </div>
)} )}

View File

@ -1,9 +1,9 @@
import React, { useState } from 'react'; import { useState } from "react";
import { CheckCircle2, XCircle, RotateCcw } from 'lucide-react'; import { CheckCircle2, XCircle, RotateCcw } from "lucide-react";
// ── Types ────────────────────────────────────────────────────────────────── // ── Types ──────────────────────────────────────────────────────────────────
export type Verdict = 'supported' | 'contradicted' | 'neither'; export type Verdict = "supported" | "contradicted" | "neither";
export interface ChartSeries { export interface ChartSeries {
name: string; name: string;
@ -11,7 +11,7 @@ export interface ChartSeries {
} }
export interface ChartData { export interface ChartData {
type: 'bar' | 'line'; type: "bar" | "line";
title: string; title: string;
yLabel?: string; yLabel?: string;
xLabel?: string; xLabel?: string;
@ -34,15 +34,24 @@ export interface DataExercise {
// ── Chart palette ────────────────────────────────────────────────────────── // ── Chart palette ──────────────────────────────────────────────────────────
const PALETTE = ['#3b82f6', '#8b5cf6', '#f97316', '#10b981', '#ef4444', '#ec4899']; const PALETTE = [
"#3b82f6",
"#8b5cf6",
"#f97316",
"#10b981",
"#ef4444",
"#ec4899",
];
// ── BarChart ─────────────────────────────────────────────────────────────── // ── BarChart ───────────────────────────────────────────────────────────────
function BarChart({ chart }: { chart: ChartData }) { function BarChart({ chart }: { chart: ChartData }) {
const [hovered, setHovered] = useState<{ si: number; pi: number } | null>(null); const [hovered, setHovered] = useState<{ si: number; pi: number } | null>(
null,
);
const labels = chart.series[0].data.map(d => d.label); const labels = chart.series[0].data.map((d) => d.label);
const allValues = chart.series.flatMap(s => s.data.map(d => d.value)); const allValues = chart.series.flatMap((s) => s.data.map((d) => d.value));
const maxVal = Math.max(...allValues); const maxVal = Math.max(...allValues);
// Round up max to nearest 10 for cleaner y-axis // Round up max to nearest 10 for cleaner y-axis
const yMax = Math.ceil(maxVal / 10) * 10; const yMax = Math.ceil(maxVal / 10) * 10;
@ -52,22 +61,36 @@ function BarChart({ chart }: { chart: ChartData }) {
return ( return (
<div className="px-2"> <div className="px-2">
<p className="text-xs font-semibold text-gray-600 text-center mb-4">{chart.title}</p> <p className="text-xs font-semibold text-gray-600 text-center mb-4">
{chart.title}
</p>
<div className="flex gap-2"> <div className="flex gap-2">
{/* Y-axis */} {/* Y-axis */}
<div className="flex flex-col-reverse justify-between items-end pr-1" style={{ height: chartH, minWidth: 32 }}> <div
{yTicks.map(t => ( className="flex flex-col-reverse justify-between items-end pr-1"
<span key={t} className="text-[10px] text-gray-400 leading-none">{t}{chart.unit ?? ''}</span> style={{ height: chartH, minWidth: 32 }}
>
{yTicks.map((t) => (
<span key={t} className="text-[10px] text-gray-400 leading-none">
{t}
{chart.unit ?? ""}
</span>
))} ))}
</div> </div>
{/* Bar groups */} {/* Bar groups */}
<div className="flex-1 flex items-end gap-2 border-b border-l border-gray-300" style={{ height: chartH }}> <div
{labels.map((label, pi) => ( className="flex-1 flex items-end gap-2 border-b border-l border-gray-300"
style={{ height: chartH }}
>
{labels.map((_, pi) => (
<div key={pi} className="flex-1 flex flex-col items-center gap-0"> <div key={pi} className="flex-1 flex flex-col items-center gap-0">
{/* Bar group */} {/* Bar group */}
<div className="w-full flex items-end gap-0.5" style={{ height: chartH - 2 }}> <div
className="w-full flex items-end gap-0.5"
style={{ height: chartH - 2 }}
>
{chart.series.map((s, si) => { {chart.series.map((s, si) => {
const val = s.data[pi].value; const val = s.data[pi].value;
const heightPct = (val / yMax) * 100; const heightPct = (val / yMax) * 100;
@ -79,9 +102,11 @@ function BarChart({ chart }: { chart: ChartData }) {
style={{ style={{
height: `${heightPct}%`, height: `${heightPct}%`,
backgroundColor: isHov backgroundColor: isHov
? PALETTE[si % PALETTE.length] + 'dd' ? PALETTE[si % PALETTE.length] + "dd"
: PALETTE[si % PALETTE.length] + 'cc', : PALETTE[si % PALETTE.length] + "cc",
outline: isHov ? `2px solid ${PALETTE[si % PALETTE.length]}` : 'none', outline: isHov
? `2px solid ${PALETTE[si % PALETTE.length]}`
: "none",
}} }}
onMouseEnter={() => setHovered({ si, pi })} onMouseEnter={() => setHovered({ si, pi })}
onMouseLeave={() => setHovered(null)} onMouseLeave={() => setHovered(null)}
@ -90,9 +115,12 @@ function BarChart({ chart }: { chart: ChartData }) {
{isHov && ( {isHov && (
<div <div
className="absolute bottom-full left-1/2 -translate-x-1/2 mb-1 px-1.5 py-0.5 rounded text-[10px] font-bold text-white whitespace-nowrap z-10 pointer-events-none" className="absolute bottom-full left-1/2 -translate-x-1/2 mb-1 px-1.5 py-0.5 rounded text-[10px] font-bold text-white whitespace-nowrap z-10 pointer-events-none"
style={{ backgroundColor: PALETTE[si % PALETTE.length] }} style={{
backgroundColor: PALETTE[si % PALETTE.length],
}}
> >
{val}{chart.unit ?? ''} {val}
{chart.unit ?? ""}
</div> </div>
)} )}
</div> </div>
@ -107,17 +135,32 @@ function BarChart({ chart }: { chart: ChartData }) {
{/* X-axis labels */} {/* X-axis labels */}
<div className="flex gap-2 ml-10 mt-1"> <div className="flex gap-2 ml-10 mt-1">
{labels.map((label, i) => ( {labels.map((label, i) => (
<div key={i} className="flex-1 text-center text-[10px] text-gray-500 leading-tight">{label}</div> <div
key={i}
className="flex-1 text-center text-[10px] text-gray-500 leading-tight"
>
{label}
</div>
))} ))}
</div> </div>
{chart.xLabel && <p className="text-[10px] text-gray-400 text-center mt-1">{chart.xLabel}</p>} {chart.xLabel && (
<p className="text-[10px] text-gray-400 text-center mt-1">
{chart.xLabel}
</p>
)}
{/* Legend */} {/* Legend */}
{chart.series.length > 1 && ( {chart.series.length > 1 && (
<div className="flex flex-wrap gap-3 mt-3 justify-center"> <div className="flex flex-wrap gap-3 mt-3 justify-center">
{chart.series.map((s, si) => ( {chart.series.map((s, si) => (
<div key={si} className="flex items-center gap-1.5 text-xs text-gray-600"> <div
<div className="w-3 h-3 rounded-sm" style={{ backgroundColor: PALETTE[si % PALETTE.length] }} /> key={si}
className="flex items-center gap-1.5 text-xs text-gray-600"
>
<div
className="w-3 h-3 rounded-sm"
style={{ backgroundColor: PALETTE[si % PALETTE.length] }}
/>
{s.name} {s.name}
</div> </div>
))} ))}
@ -127,17 +170,26 @@ function BarChart({ chart }: { chart: ChartData }) {
{/* Hover info bar */} {/* Hover info bar */}
{hovered && ( {hovered && (
<div className="mt-3 text-xs text-center text-gray-600 bg-gray-50 rounded-lg py-1.5 px-3"> <div className="mt-3 text-xs text-center text-gray-600 bg-gray-50 rounded-lg py-1.5 px-3">
<span className="font-semibold" style={{ color: PALETTE[hovered.si % PALETTE.length] }}> <span
className="font-semibold"
style={{ color: PALETTE[hovered.si % PALETTE.length] }}
>
{chart.series[hovered.si].name} {chart.series[hovered.si].name}
</span> </span>
{''} {""}
{chart.series[0].data[hovered.pi].label}: <span className="font-semibold"> {chart.series[0].data[hovered.pi].label}:{" "}
{chart.series[hovered.si].data[hovered.pi].value}{chart.unit ?? ''} <span className="font-semibold">
{chart.series[hovered.si].data[hovered.pi].value}
{chart.unit ?? ""}
</span> </span>
</div> </div>
)} )}
{chart.source && <p className="text-[10px] text-gray-400 text-center mt-2">Source: {chart.source}</p>} {chart.source && (
<p className="text-[10px] text-gray-400 text-center mt-2">
Source: {chart.source}
</p>
)}
</div> </div>
); );
} }
@ -145,14 +197,17 @@ function BarChart({ chart }: { chart: ChartData }) {
// ── LineChart ────────────────────────────────────────────────────────────── // ── LineChart ──────────────────────────────────────────────────────────────
function LineChart({ chart }: { chart: ChartData }) { function LineChart({ chart }: { chart: ChartData }) {
const [hovered, setHovered] = useState<{ si: number; pi: number } | null>(null); const [hovered, setHovered] = useState<{ si: number; pi: number } | null>(
null,
);
const W = 480, H = 200; const W = 480,
H = 200;
const PAD = { top: 20, right: 20, bottom: 36, left: 48 }; const PAD = { top: 20, right: 20, bottom: 36, left: 48 };
const cW = W - PAD.left - PAD.right; const cW = W - PAD.left - PAD.right;
const cH = H - PAD.top - PAD.bottom; const cH = H - PAD.top - PAD.bottom;
const allValues = chart.series.flatMap(s => s.data.map(d => d.value)); const allValues = chart.series.flatMap((s) => s.data.map((d) => d.value));
const minVal = Math.min(...allValues); const minVal = Math.min(...allValues);
const maxVal = Math.max(...allValues); const maxVal = Math.max(...allValues);
const spread = maxVal - minVal || 1; const spread = maxVal - minVal || 1;
@ -163,28 +218,51 @@ function LineChart({ chart }: { chart: ChartData }) {
const yMax = maxVal + yPad; const yMax = maxVal + yPad;
const yRange = yMax - yMin; const yRange = yMax - yMin;
const labels = chart.series[0].data.map(d => d.label); const labels = chart.series[0].data.map((d) => d.label);
const xStep = cW / (labels.length - 1); const xStep = cW / (labels.length - 1);
const xPos = (i: number) => PAD.left + i * xStep; const xPos = (i: number) => PAD.left + i * xStep;
const yPos = (v: number) => PAD.top + cH - ((v - yMin) / yRange) * cH; const yPos = (v: number) => PAD.top + cH - ((v - yMin) / yRange) * cH;
// Y-axis ticks: 5 evenly spaced // Y-axis ticks: 5 evenly spaced
const yTicks = Array.from({ length: 5 }, (_, i) => minVal + ((maxVal - minVal) / 4) * i); const yTicks = Array.from(
{ length: 5 },
(_, i) => minVal + ((maxVal - minVal) / 4) * i,
);
return ( return (
<div> <div>
<p className="text-xs font-semibold text-gray-600 text-center mb-2">{chart.title}</p> <p className="text-xs font-semibold text-gray-600 text-center mb-2">
{chart.title}
</p>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<svg viewBox={`0 0 ${W} ${H}`} className="w-full" style={{ maxHeight: 220 }}> <svg
viewBox={`0 0 ${W} ${H}`}
className="w-full"
style={{ maxHeight: 220 }}
>
{/* Grid lines */} {/* Grid lines */}
{yTicks.map((t, i) => { {yTicks.map((t, i) => {
const y = yPos(t); const y = yPos(t);
return ( return (
<g key={i}> <g key={i}>
<line x1={PAD.left} x2={W - PAD.right} y1={y} y2={y} stroke="#e5e7eb" strokeWidth="1" /> <line
<text x={PAD.left - 4} y={y + 3.5} textAnchor="end" fontSize="9" fill="#9ca3af"> x1={PAD.left}
{t % 1 === 0 ? t : t.toFixed(2)}{chart.unit ?? ''} x2={W - PAD.right}
y1={y}
y2={y}
stroke="#e5e7eb"
strokeWidth="1"
/>
<text
x={PAD.left - 4}
y={y + 3.5}
textAnchor="end"
fontSize="9"
fill="#9ca3af"
>
{t % 1 === 0 ? t : t.toFixed(2)}
{chart.unit ?? ""}
</text> </text>
</g> </g>
); );
@ -193,10 +271,18 @@ function LineChart({ chart }: { chart: ChartData }) {
{/* Lines + dots */} {/* Lines + dots */}
{chart.series.map((s, si) => { {chart.series.map((s, si) => {
const color = PALETTE[si % PALETTE.length]; const color = PALETTE[si % PALETTE.length];
const pts = s.data.map((d, i) => `${xPos(i)},${yPos(d.value)}`).join(' '); const pts = s.data
.map((d, i) => `${xPos(i)},${yPos(d.value)}`)
.join(" ");
return ( return (
<g key={si}> <g key={si}>
<polyline points={pts} fill="none" stroke={color} strokeWidth="2.5" strokeLinejoin="round" /> <polyline
points={pts}
fill="none"
stroke={color}
strokeWidth="2.5"
strokeLinejoin="round"
/>
{s.data.map((d, pi) => { {s.data.map((d, pi) => {
const isHov = hovered?.si === si && hovered?.pi === pi; const isHov = hovered?.si === si && hovered?.pi === pi;
const cx = xPos(pi); const cx = xPos(pi);
@ -204,20 +290,36 @@ function LineChart({ chart }: { chart: ChartData }) {
return ( return (
<g key={pi}> <g key={pi}>
<circle <circle
cx={cx} cy={cy} r={isHov ? 7 : 5} cx={cx}
fill={color} stroke="white" strokeWidth="2" cy={cy}
style={{ cursor: 'pointer', transition: 'r 0.1s' }} r={isHov ? 7 : 5}
fill={color}
stroke="white"
strokeWidth="2"
style={{ cursor: "pointer", transition: "r 0.1s" }}
onMouseEnter={() => setHovered({ si, pi })} onMouseEnter={() => setHovered({ si, pi })}
onMouseLeave={() => setHovered(null)} onMouseLeave={() => setHovered(null)}
/> />
{isHov && ( {isHov && (
<> <>
<rect <rect
x={cx - 28} y={cy - 26} width="56" height="18" x={cx - 28}
rx="4" fill="#1f2937" y={cy - 26}
width="56"
height="18"
rx="4"
fill="#1f2937"
/> />
<text x={cx} y={cy - 13} textAnchor="middle" fontSize="10" fill="white" fontWeight="bold"> <text
{d.value}{chart.unit ?? ''} x={cx}
y={cy - 13}
textAnchor="middle"
fontSize="10"
fill="white"
fontWeight="bold"
>
{d.value}
{chart.unit ?? ""}
</text> </text>
</> </>
)} )}
@ -230,21 +332,45 @@ function LineChart({ chart }: { chart: ChartData }) {
{/* X-axis labels */} {/* X-axis labels */}
{labels.map((label, i) => ( {labels.map((label, i) => (
<text key={i} x={xPos(i)} y={H - PAD.bottom + 14} textAnchor="middle" fontSize="9.5" fill="#6b7280"> <text
key={i}
x={xPos(i)}
y={H - PAD.bottom + 14}
textAnchor="middle"
fontSize="9.5"
fill="#6b7280"
>
{label} {label}
</text> </text>
))} ))}
{/* Axes */} {/* Axes */}
<line x1={PAD.left} x2={PAD.left} y1={PAD.top} y2={H - PAD.bottom} stroke="#d1d5db" strokeWidth="1.5" /> <line
<line x1={PAD.left} x2={W - PAD.right} y1={H - PAD.bottom} y2={H - PAD.bottom} stroke="#d1d5db" strokeWidth="1.5" /> x1={PAD.left}
x2={PAD.left}
y1={PAD.top}
y2={H - PAD.bottom}
stroke="#d1d5db"
strokeWidth="1.5"
/>
<line
x1={PAD.left}
x2={W - PAD.right}
y1={H - PAD.bottom}
y2={H - PAD.bottom}
stroke="#d1d5db"
strokeWidth="1.5"
/>
{/* Y-axis label */} {/* Y-axis label */}
{chart.yLabel && ( {chart.yLabel && (
<text <text
x={12} y={H / 2} x={12}
y={H / 2}
transform={`rotate(-90, 12, ${H / 2})`} transform={`rotate(-90, 12, ${H / 2})`}
textAnchor="middle" fontSize="9" fill="#9ca3af" textAnchor="middle"
fontSize="9"
fill="#9ca3af"
> >
{chart.yLabel} {chart.yLabel}
</text> </text>
@ -256,8 +382,14 @@ function LineChart({ chart }: { chart: ChartData }) {
{chart.series.length > 1 && ( {chart.series.length > 1 && (
<div className="flex flex-wrap gap-3 mt-1 justify-center"> <div className="flex flex-wrap gap-3 mt-1 justify-center">
{chart.series.map((s, si) => ( {chart.series.map((s, si) => (
<div key={si} className="flex items-center gap-1.5 text-xs text-gray-600"> <div
<div className="w-5 h-0.5" style={{ backgroundColor: PALETTE[si % PALETTE.length] }} /> key={si}
className="flex items-center gap-1.5 text-xs text-gray-600"
>
<div
className="w-5 h-0.5"
style={{ backgroundColor: PALETTE[si % PALETTE.length] }}
/>
{s.name} {s.name}
</div> </div>
))} ))}
@ -267,17 +399,26 @@ function LineChart({ chart }: { chart: ChartData }) {
{/* Hover tooltip */} {/* Hover tooltip */}
{hovered && ( {hovered && (
<div className="mt-2 text-xs text-center text-gray-600 bg-gray-50 rounded-lg py-1.5 px-3"> <div className="mt-2 text-xs text-center text-gray-600 bg-gray-50 rounded-lg py-1.5 px-3">
<span className="font-semibold" style={{ color: PALETTE[hovered.si % PALETTE.length] }}> <span
className="font-semibold"
style={{ color: PALETTE[hovered.si % PALETTE.length] }}
>
{chart.series[hovered.si].name} {chart.series[hovered.si].name}
</span> </span>
{' · '} {" · "}
{chart.series[0].data[hovered.pi].label}: <span className="font-semibold"> {chart.series[0].data[hovered.pi].label}:{" "}
{chart.series[hovered.si].data[hovered.pi].value}{chart.unit ?? ''} <span className="font-semibold">
{chart.series[hovered.si].data[hovered.pi].value}
{chart.unit ?? ""}
</span> </span>
</div> </div>
)} )}
{chart.source && <p className="text-[10px] text-gray-400 text-center mt-2">Source: {chart.source}</p>} {chart.source && (
<p className="text-[10px] text-gray-400 text-center mt-2">
Source: {chart.source}
</p>
)}
</div> </div>
); );
} }
@ -285,9 +426,9 @@ function LineChart({ chart }: { chart: ChartData }) {
// ── Main widget ──────────────────────────────────────────────────────────── // ── Main widget ────────────────────────────────────────────────────────────
const VERDICT_LABELS: Record<Verdict, string> = { const VERDICT_LABELS: Record<Verdict, string> = {
supported: 'Supported by data', supported: "Supported by data",
contradicted: 'Contradicted by data', contradicted: "Contradicted by data",
neither: 'Neither proven nor disproven', neither: "Neither proven nor disproven",
}; };
interface DataClaimWidgetProps { interface DataClaimWidgetProps {
@ -296,25 +437,60 @@ interface DataClaimWidgetProps {
} }
// Pre-resolved accent classes to avoid Tailwind purge issues // Pre-resolved accent classes to avoid Tailwind purge issues
const ACCENT_CLASSES: Record<string, { tab: string; header: string; label: string; btn: string }> = { const ACCENT_CLASSES: Record<
amber: { tab: 'border-b-2 border-amber-600 text-amber-700', header: 'bg-amber-50', label: 'text-amber-600', btn: 'bg-amber-600 hover:bg-amber-700' }, string,
teal: { tab: 'border-b-2 border-teal-600 text-teal-700', header: 'bg-teal-50', label: 'text-teal-600', btn: 'bg-teal-600 hover:bg-teal-700' }, { tab: string; header: string; label: string; btn: string }
purple: { tab: 'border-b-2 border-purple-600 text-purple-700', header: 'bg-purple-50', label: 'text-purple-600', btn: 'bg-purple-600 hover:bg-purple-700' }, > = {
fuchsia: { tab: 'border-b-2 border-fuchsia-600 text-fuchsia-700', header: 'bg-fuchsia-50', label: 'text-fuchsia-600', btn: 'bg-fuchsia-600 hover:bg-fuchsia-700' }, amber: {
tab: "border-b-2 border-amber-600 text-amber-700",
header: "bg-amber-50",
label: "text-amber-600",
btn: "bg-amber-600 hover:bg-amber-700",
},
teal: {
tab: "border-b-2 border-teal-600 text-teal-700",
header: "bg-teal-50",
label: "text-teal-600",
btn: "bg-teal-600 hover:bg-teal-700",
},
purple: {
tab: "border-b-2 border-purple-600 text-purple-700",
header: "bg-purple-50",
label: "text-purple-600",
btn: "bg-purple-600 hover:bg-purple-700",
},
fuchsia: {
tab: "border-b-2 border-fuchsia-600 text-fuchsia-700",
header: "bg-fuchsia-50",
label: "text-fuchsia-600",
btn: "bg-fuchsia-600 hover:bg-fuchsia-700",
},
}; };
export default function DataClaimWidget({ exercises, accentColor = 'amber' }: DataClaimWidgetProps) { export default function DataClaimWidget({
exercises,
accentColor = "amber",
}: DataClaimWidgetProps) {
const [activeEx, setActiveEx] = useState(0); const [activeEx, setActiveEx] = useState(0);
const [answers, setAnswers] = useState<Record<number, Verdict>>({}); const [answers, setAnswers] = useState<Record<number, Verdict>>({});
const [submitted, setSubmitted] = useState(false); const [submitted, setSubmitted] = useState(false);
const exercise = exercises[activeEx]; const exercise = exercises[activeEx];
const allAnswered = exercise.claims.every((_, i) => answers[i] !== undefined); const allAnswered = exercise.claims.every((_, i) => answers[i] !== undefined);
const score = submitted ? exercise.claims.filter((c, i) => answers[i] === c.verdict).length : 0; const score = submitted
? exercise.claims.filter((c, i) => answers[i] === c.verdict).length
: 0;
const c = ACCENT_CLASSES[accentColor] ?? ACCENT_CLASSES.amber; const c = ACCENT_CLASSES[accentColor] ?? ACCENT_CLASSES.amber;
const reset = () => { setAnswers({}); setSubmitted(false); }; const reset = () => {
const switchEx = (i: number) => { setActiveEx(i); setAnswers({}); setSubmitted(false); }; setAnswers({});
setSubmitted(false);
};
const switchEx = (i: number) => {
setActiveEx(i);
setAnswers({});
setSubmitted(false);
};
return ( return (
<div className="rounded-2xl border border-gray-200 bg-white overflow-hidden shadow-sm"> <div className="rounded-2xl border border-gray-200 bg-white overflow-hidden shadow-sm">
@ -326,7 +502,7 @@ export default function DataClaimWidget({ exercises, accentColor = 'amber' }: Da
key={i} key={i}
onClick={() => switchEx(i)} onClick={() => switchEx(i)}
className={`px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-colors bg-white ${ className={`px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-colors bg-white ${
i === activeEx ? c.tab : 'text-gray-500 hover:text-gray-700' i === activeEx ? c.tab : "text-gray-500 hover:text-gray-700"
}`} }`}
> >
{ex.title} {ex.title}
@ -337,73 +513,100 @@ export default function DataClaimWidget({ exercises, accentColor = 'amber' }: Da
{/* Chart */} {/* Chart */}
<div className={`px-5 pt-5 pb-4 border-b border-gray-200 ${c.header}`}> <div className={`px-5 pt-5 pb-4 border-b border-gray-200 ${c.header}`}>
<p className={`text-xs font-bold uppercase tracking-wider mb-4 ${c.label}`}>Data Source</p> <p
{exercise.chart.type === 'bar' className={`text-xs font-bold uppercase tracking-wider mb-4 ${c.label}`}
? <BarChart chart={exercise.chart} /> >
: <LineChart chart={exercise.chart} /> Data Source
} </p>
{exercise.chart.type === "bar" ? (
<BarChart chart={exercise.chart} />
) : (
<LineChart chart={exercise.chart} />
)}
</div> </div>
{/* Claims */} {/* Claims */}
<div className="px-5 py-4"> <div className="px-5 py-4">
<p className="text-sm text-gray-600 mb-4"> <p className="text-sm text-gray-600 mb-4">
For each claim, decide if the data{' '} For each claim, decide if the data{" "}
<strong className="text-green-700">supports</strong>,{' '} <strong className="text-green-700">supports</strong>,{" "}
<strong className="text-red-600">contradicts</strong>, or{' '} <strong className="text-red-600">contradicts</strong>, or{" "}
<strong className="text-gray-600">neither proves nor disproves</strong> it: <strong className="text-gray-600">
neither proves nor disproves
</strong>{" "}
it:
</p> </p>
<div className="space-y-4"> <div className="space-y-4">
{exercise.claims.map((claim, i) => { {exercise.claims.map((claim, i) => {
const userAnswer = answers[i]; const userAnswer = answers[i];
const isCorrect = submitted && userAnswer === claim.verdict; const isCorrect = submitted && userAnswer === claim.verdict;
const isWrong = submitted && userAnswer !== undefined && userAnswer !== claim.verdict; const isWrong =
submitted &&
userAnswer !== undefined &&
userAnswer !== claim.verdict;
return ( return (
<div <div
key={i} key={i}
className={`rounded-xl border p-4 transition-all ${ className={`rounded-xl border p-4 transition-all ${
submitted submitted
? isCorrect ? 'border-green-300 bg-green-50' ? isCorrect
: isWrong ? 'border-red-200 bg-red-50' ? "border-green-300 bg-green-50"
: 'border-gray-200' : isWrong
: 'border-gray-200' ? "border-red-200 bg-red-50"
: "border-gray-200"
: "border-gray-200"
}`} }`}
> >
<p className="text-sm text-gray-800 mb-3"> <p className="text-sm text-gray-800 mb-3">
<span className="font-bold text-gray-400 mr-2">Claim {i + 1}:</span> <span className="font-bold text-gray-400 mr-2">
Claim {i + 1}:
</span>
{claim.text} {claim.text}
</p> </p>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{(['supported', 'contradicted', 'neither'] as Verdict[]).map(v => { {(["supported", "contradicted", "neither"] as Verdict[]).map(
(v) => {
const isSelected = userAnswer === v; const isSelected = userAnswer === v;
const isCorrectOpt = submitted && v === claim.verdict; const isCorrectOpt = submitted && v === claim.verdict;
let cls = 'border-gray-200 text-gray-600 hover:border-gray-400 hover:bg-gray-50'; let cls =
if (isSelected && !submitted) cls = `border-amber-500 bg-amber-50 text-amber-800 font-semibold`; "border-gray-200 text-gray-600 hover:border-gray-400 hover:bg-gray-50";
if (isSelected && !submitted)
cls = `border-amber-500 bg-amber-50 text-amber-800 font-semibold`;
if (submitted) { if (submitted) {
if (isCorrectOpt) cls = 'border-green-400 bg-green-100 text-green-800 font-semibold'; if (isCorrectOpt)
else if (isSelected) cls = 'border-red-300 bg-red-100 text-red-700'; cls =
else cls = 'border-gray-100 text-gray-400'; "border-green-400 bg-green-100 text-green-800 font-semibold";
else if (isSelected)
cls = "border-red-300 bg-red-100 text-red-700";
else cls = "border-gray-100 text-gray-400";
} }
return ( return (
<button <button
key={v} key={v}
disabled={submitted} disabled={submitted}
onClick={() => setAnswers(prev => ({ ...prev, [i]: v }))} onClick={() =>
setAnswers((prev) => ({ ...prev, [i]: v }))
}
className={`px-3 py-1.5 rounded-full border text-xs transition-all ${cls}`} className={`px-3 py-1.5 rounded-full border text-xs transition-all ${cls}`}
> >
{VERDICT_LABELS[v]} {VERDICT_LABELS[v]}
</button> </button>
); );
})} },
)}
</div> </div>
{submitted && ( {submitted && (
<div className="mt-3 pt-2 border-t border-gray-100 flex gap-2"> <div className="mt-3 pt-2 border-t border-gray-100 flex gap-2">
{isCorrect {isCorrect ? (
? <CheckCircle2 className="w-4 h-4 text-green-600 shrink-0 mt-0.5" /> <CheckCircle2 className="w-4 h-4 text-green-600 shrink-0 mt-0.5" />
: <XCircle className="w-4 h-4 text-red-500 shrink-0 mt-0.5" /> ) : (
} <XCircle className="w-4 h-4 text-red-500 shrink-0 mt-0.5" />
)}
<p className="text-xs text-gray-600 leading-relaxed"> <p className="text-xs text-gray-600 leading-relaxed">
{!isCorrect && ( {!isCorrect && (
<span className="font-semibold text-red-700">Answer: {VERDICT_LABELS[claim.verdict]}. </span> <span className="font-semibold text-red-700">
Answer: {VERDICT_LABELS[claim.verdict]}.{" "}
</span>
)} )}
{claim.explanation} {claim.explanation}
</p> </p>
@ -422,7 +625,9 @@ export default function DataClaimWidget({ exercises, accentColor = 'amber' }: Da
disabled={!allAnswered} disabled={!allAnswered}
onClick={() => setSubmitted(true)} onClick={() => setSubmitted(true)}
className={`px-5 py-2.5 rounded-full text-sm font-bold text-white transition-colors ${ className={`px-5 py-2.5 rounded-full text-sm font-bold text-white transition-colors ${
allAnswered ? c.btn : 'bg-gray-200 text-gray-400 cursor-not-allowed' allAnswered
? c.btn
: "bg-gray-200 text-gray-400 cursor-not-allowed"
}`} }`}
> >
Check all answers Check all answers
@ -432,7 +637,10 @@ export default function DataClaimWidget({ exercises, accentColor = 'amber' }: Da
<p className="text-sm font-semibold text-gray-700"> <p className="text-sm font-semibold text-gray-700">
{score}/{exercise.claims.length} correct {score}/{exercise.claims.length} correct
</p> </p>
<button onClick={reset} className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700 transition-colors"> <button
onClick={reset}
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700 transition-colors"
>
<RotateCcw className="w-3.5 h-3.5" /> Try again <RotateCcw className="w-3.5 h-3.5" /> Try again
</button> </button>
</div> </div>

View File

@ -1,16 +1,22 @@
import React, { useState } from 'react'; import React, { useState } from "react";
import { ChevronRight, RotateCcw, CheckCircle2, AlertTriangle, Info } from 'lucide-react'; import {
ChevronRight,
RotateCcw,
CheckCircle2,
AlertTriangle,
Info,
} from "lucide-react";
export interface TreeNode { export interface TreeNode {
id: string; id: string;
question: string; question?: string;
hint?: string; hint?: string;
yesLabel?: string; yesLabel?: string;
noLabel?: string; noLabel?: string;
yes?: TreeNode; yes?: TreeNode;
no?: TreeNode; no?: TreeNode;
result?: string; result?: string;
resultType?: 'correct' | 'warning' | 'info'; resultType?: "correct" | "warning" | "info";
ruleRef?: string; ruleRef?: string;
} }
@ -25,59 +31,66 @@ interface DecisionTreeWidgetProps {
accentColor?: string; accentColor?: string;
} }
type Answers = Record<string, 'yes' | 'no'>; type Answers = Record<string, "yes" | "no">;
/** Walk the tree following answers, return ordered list of [node, answer|null] pairs traversed */ /** Walk the tree following answers, return ordered list of [node, answer|null] pairs traversed */
function getPath(root: TreeNode, answers: Answers): Array<{ node: TreeNode; answer: 'yes' | 'no' | null }> { function getPath(
const path: Array<{ node: TreeNode; answer: 'yes' | 'no' | null }> = []; root: TreeNode,
answers: Answers,
): Array<{ node: TreeNode; answer: "yes" | "no" | null }> {
const path: Array<{ node: TreeNode; answer: "yes" | "no" | null }> = [];
let current: TreeNode | undefined = root; let current: TreeNode | undefined = root;
while (current) { while (current) {
// @ts-ignore
const ans = answers[current.id] ?? null; const ans = answers[current.id] ?? null;
path.push({ node: current, answer: ans }); path.push({ node: current, answer: ans });
if (ans === null) break; // not answered yet — this is the active node if (ans === null) break; // not answered yet — this is the active node
if (current.result !== undefined) break; // leaf if (current.result !== undefined) break; // leaf
current = ans === 'yes' ? current.yes : current.no; current = ans === "yes" ? current.yes : current.no;
} }
return path; return path;
} }
const RESULT_STYLES = { const RESULT_STYLES = {
correct: { correct: {
bg: 'bg-green-50', bg: "bg-green-50",
border: 'border-green-300', border: "border-green-300",
text: 'text-green-800', text: "text-green-800",
icon: <CheckCircle2 className="w-5 h-5 text-green-600 shrink-0 mt-0.5" />, icon: <CheckCircle2 className="w-5 h-5 text-green-600 shrink-0 mt-0.5" />,
}, },
warning: { warning: {
bg: 'bg-amber-50', bg: "bg-amber-50",
border: 'border-amber-300', border: "border-amber-300",
text: 'text-amber-800', text: "text-amber-800",
icon: <AlertTriangle className="w-5 h-5 text-amber-600 shrink-0 mt-0.5" />, icon: <AlertTriangle className="w-5 h-5 text-amber-600 shrink-0 mt-0.5" />,
}, },
info: { info: {
bg: 'bg-blue-50', bg: "bg-blue-50",
border: 'border-blue-300', border: "border-blue-300",
text: 'text-blue-800', text: "text-blue-800",
icon: <Info className="w-5 h-5 text-blue-600 shrink-0 mt-0.5" />, icon: <Info className="w-5 h-5 text-blue-600 shrink-0 mt-0.5" />,
}, },
}; };
export default function DecisionTreeWidget({ scenarios, accentColor = 'purple' }: DecisionTreeWidgetProps) { export default function DecisionTreeWidget({
scenarios,
accentColor = "purple",
}: DecisionTreeWidgetProps) {
const [activeScenario, setActiveScenario] = useState(0); const [activeScenario, setActiveScenario] = useState(0);
const [answers, setAnswers] = useState<Answers>({}); const [answers, setAnswers] = useState<Answers>({});
const scenario = scenarios[activeScenario]; const scenario = scenarios[activeScenario];
const path = getPath(scenario.tree, answers); const path = getPath(scenario.tree, answers);
const lastStep = path[path.length - 1]; const lastStep = path[path.length - 1];
const isLeaf = lastStep.node.result !== undefined;
const isComplete = isLeaf && lastStep.answer === null; // reached leaf, no more choices needed // reached leaf, no more choices needed
// Actually leaf nodes don't have yes/no — they just show result when we arrive // Actually leaf nodes don't have yes/no — they just show result when we arrive
const atLeaf = lastStep.node.result !== undefined; const atLeaf = lastStep.node.result !== undefined;
const handleAnswer = (nodeId: string, ans: 'yes' | 'no') => { const handleAnswer = (nodeId: string, ans: "yes" | "no") => {
setAnswers(prev => { setAnswers((prev) => {
// Remove all answers for nodes that come AFTER this one in the current path // Remove all answers for nodes that come AFTER this one in the current path
const pathIds = path.map(p => p.node.id); const pathIds = path.map((p) => p.node.id);
const idx = pathIds.indexOf(nodeId); const idx = pathIds.indexOf(nodeId);
const newAnswers: Answers = {}; const newAnswers: Answers = {};
for (let i = 0; i < idx; i++) { for (let i = 0; i < idx; i++) {
@ -107,7 +120,7 @@ export default function DecisionTreeWidget({ scenarios, accentColor = 'purple' }
className={`px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-colors ${ className={`px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-colors ${
i === activeScenario i === activeScenario
? `bg-white border-b-2 border-${accentColor}-600 text-${accentColor}-700` ? `bg-white border-b-2 border-${accentColor}-600 text-${accentColor}-700`
: 'text-gray-500 hover:text-gray-700' : "text-gray-500 hover:text-gray-700"
}`} }`}
> >
{sc.label} {sc.label}
@ -117,9 +130,17 @@ export default function DecisionTreeWidget({ scenarios, accentColor = 'purple' }
)} )}
{/* Sentence under analysis */} {/* Sentence under analysis */}
<div className={`px-5 py-4 border-b border-gray-100 bg-${accentColor}-50`}> <div
<p className={`text-xs font-semibold uppercase tracking-wider text-${accentColor}-500 mb-1`}>Analyze this sentence</p> className={`px-5 py-4 border-b border-gray-100 bg-${accentColor}-50`}
<p className="text-gray-800 font-medium italic leading-relaxed">"{scenario.sentence}"</p> >
<p
className={`text-xs font-semibold uppercase tracking-wider text-${accentColor}-500 mb-1`}
>
Analyze this sentence
</p>
<p className="text-gray-800 font-medium italic leading-relaxed">
"{scenario.sentence}"
</p>
</div> </div>
{/* Breadcrumb path */} {/* Breadcrumb path */}
@ -133,22 +154,36 @@ export default function DecisionTreeWidget({ scenarios, accentColor = 'purple' }
<button <button
onClick={() => { onClick={() => {
// Reset from this node forward // Reset from this node forward
const pathIds = path.map(p => p.node.id); const pathIds = path.map((p) => p.node.id);
const idx = pathIds.indexOf(step.node.id); const idx = pathIds.indexOf(step.node.id);
setAnswers(prev => { setAnswers((prev) => {
const newAnswers: Answers = {}; const newAnswers: Answers = {};
for (let j = 0; j < idx; j++) newAnswers[pathIds[j]] = prev[pathIds[j]]!; for (let j = 0; j < idx; j++)
newAnswers[pathIds[j]] = prev[pathIds[j]]!;
return newAnswers; return newAnswers;
}); });
}} }}
className={`px-2 py-0.5 rounded transition-colors ${ className={`px-2 py-0.5 rounded transition-colors ${
isAnswered ? 'text-gray-600 hover:text-gray-900 hover:bg-gray-200' : 'text-gray-400' isAnswered
? "text-gray-600 hover:text-gray-900 hover:bg-gray-200"
: "text-gray-400"
}`} }`}
> >
{step.node.question.length > 40 ? step.node.question.slice(0, 40) + '…' : step.node.question} {
// @ts-ignore
step.node.question.length > 40
? // @ts-ignore
step.node.question.slice(0, 40) + "…"
: step.node.question
}
{step.answer && ( {step.answer && (
<span className={`ml-1 font-semibold ${step.answer === 'yes' ? 'text-green-600' : 'text-red-500'}`}> <span
{step.answer === 'yes' ? (step.node.yesLabel ?? 'Yes') : (step.node.noLabel ?? 'No')} className={`ml-1 font-semibold ${step.answer === "yes" ? "text-green-600" : "text-red-500"}`}
>
{" "}
{step.answer === "yes"
? (step.node.yesLabel ?? "Yes")
: (step.node.noLabel ?? "No")}
</span> </span>
)} )}
</button> </button>
@ -161,20 +196,24 @@ export default function DecisionTreeWidget({ scenarios, accentColor = 'purple' }
{/* Active node */} {/* Active node */}
<div className="px-5 py-5"> <div className="px-5 py-5">
{atLeaf ? ( {atLeaf
/* Leaf result */ ? /* Leaf result */
(() => { (() => {
const node = lastStep.node; const node = lastStep.node;
const rType = node.resultType ?? 'correct'; const rType = node.resultType ?? "correct";
const s = RESULT_STYLES[rType]; const s = RESULT_STYLES[rType];
return ( return (
<div className={`rounded-xl border p-4 ${s.bg} ${s.border}`}> <div className={`rounded-xl border p-4 ${s.bg} ${s.border}`}>
<div className="flex gap-3"> <div className="flex gap-3">
{s.icon} {s.icon}
<div> <div>
<p className={`font-semibold ${s.text} leading-snug`}>{node.result}</p> <p className={`font-semibold ${s.text} leading-snug`}>
{node.result}
</p>
{node.ruleRef && ( {node.ruleRef && (
<p className={`mt-2 text-sm font-mono ${s.text} opacity-80 bg-white/60 rounded px-2 py-1 inline-block`}> <p
className={`mt-2 text-sm font-mono ${s.text} opacity-80 bg-white/60 rounded px-2 py-1 inline-block`}
>
{node.ruleRef} {node.ruleRef}
</p> </p>
)} )}
@ -183,33 +222,35 @@ export default function DecisionTreeWidget({ scenarios, accentColor = 'purple' }
</div> </div>
); );
})() })()
) : ( : /* Decision question */
/* Decision question */
(() => { (() => {
const node = lastStep.node; const node = lastStep.node;
return ( return (
<div> <div>
<p className="font-semibold text-gray-800 text-base leading-snug mb-1">{node.question}</p> <p className="font-semibold text-gray-800 text-base leading-snug mb-1">
{node.hint && <p className="text-sm text-gray-500 mb-4">{node.hint}</p>} {node.question}
</p>
{node.hint && (
<p className="text-sm text-gray-500 mb-4">{node.hint}</p>
)}
{!node.hint && <div className="mb-4" />} {!node.hint && <div className="mb-4" />}
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
<button <button
onClick={() => handleAnswer(node.id, 'yes')} onClick={() => handleAnswer(node.id, "yes")}
className="flex-1 min-w-[140px] px-4 py-3 rounded-xl border-2 border-green-300 bg-green-50 text-green-800 font-semibold text-sm hover:bg-green-100 transition-colors" className="flex-1 min-w-35 px-4 py-3 rounded-xl border-2 border-green-300 bg-green-50 text-green-800 font-semibold text-sm hover:bg-green-100 transition-colors"
> >
{node.yesLabel ?? 'Yes'} {node.yesLabel ?? "Yes"}
</button> </button>
<button <button
onClick={() => handleAnswer(node.id, 'no')} onClick={() => handleAnswer(node.id, "no")}
className="flex-1 min-w-[140px] px-4 py-3 rounded-xl border-2 border-red-200 bg-red-50 text-red-700 font-semibold text-sm hover:bg-red-100 transition-colors" className="flex-1 min-w-35 px-4 py-3 rounded-xl border-2 border-red-200 bg-red-50 text-red-700 font-semibold text-sm hover:bg-red-100 transition-colors"
> >
{node.noLabel ?? 'No'} {node.noLabel ?? "No"}
</button> </button>
</div> </div>
</div> </div>
); );
})() })()}
)}
</div> </div>
{/* Footer */} {/* Footer */}
@ -221,7 +262,9 @@ export default function DecisionTreeWidget({ scenarios, accentColor = 'purple' }
<RotateCcw className="w-3.5 h-3.5" /> <RotateCcw className="w-3.5 h-3.5" />
Try again Try again
</button> </button>
{atLeaf && scenarios.length > 1 && activeScenario < scenarios.length - 1 && ( {atLeaf &&
scenarios.length > 1 &&
activeScenario < scenarios.length - 1 && (
<button <button
onClick={() => switchScenario(activeScenario + 1)} onClick={() => switchScenario(activeScenario + 1)}
className={`ml-auto flex items-center gap-1.5 text-sm font-semibold text-${accentColor}-700 hover:text-${accentColor}-900 transition-colors`} className={`ml-auto flex items-center gap-1.5 text-sm font-semibold text-${accentColor}-700 hover:text-${accentColor}-900 transition-colors`}

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React, { useState } from "react";
const ExponentialExplorer: React.FC = () => { const ExponentialExplorer: React.FC = () => {
const [a, setA] = useState(2); // Initial Value const [a, setA] = useState(2); // Initial Value
@ -6,7 +6,6 @@ const ExponentialExplorer: React.FC = () => {
const [k, setK] = useState(0); // Horizontal Asymptote shift const [k, setK] = useState(0); // Horizontal Asymptote shift
const width = 300; const width = 300;
const height = 300;
const range = 5; // x range -5 to 5 const range = 5; // x range -5 to 5
// Mapping // Mapping
@ -33,9 +32,14 @@ const ExponentialExplorer: React.FC = () => {
<div className="flex flex-col md:flex-row gap-8"> <div className="flex flex-col md:flex-row gap-8">
<div className="w-full md:w-1/3 space-y-6"> <div className="w-full md:w-1/3 space-y-6">
<div className="p-4 bg-violet-50 rounded-xl border border-violet-100 text-center"> <div className="p-4 bg-violet-50 rounded-xl border border-violet-100 text-center">
<div className="text-xs font-bold text-violet-400 uppercase mb-1">Standard Form</div> <div className="text-xs font-bold text-violet-400 uppercase mb-1">
Standard Form
</div>
<div className="text-xl font-mono font-bold text-violet-900"> <div className="text-xl font-mono font-bold text-violet-900">
y = <span className="text-indigo-600">{a}</span> · <span className="text-emerald-600">{b}</span><sup>x</sup> {k >= 0 ? '+' : ''} <span className="text-rose-600">{k}</span> y = <span className="text-indigo-600">{a}</span> ·{" "}
<span className="text-emerald-600">{b}</span>
<sup>x</sup> {k >= 0 ? "+" : ""}{" "}
<span className="text-rose-600">{k}</span>
</div> </div>
</div> </div>
@ -44,20 +48,46 @@ const ExponentialExplorer: React.FC = () => {
<label className="text-xs font-bold text-indigo-600 uppercase flex justify-between"> <label className="text-xs font-bold text-indigo-600 uppercase flex justify-between">
Initial Value (a) <span>{a}</span> Initial Value (a) <span>{a}</span>
</label> </label>
<input type="range" min="0.5" max="5" step="0.5" value={a} onChange={e => setA(parseFloat(e.target.value))} className="w-full h-2 bg-indigo-100 rounded-lg appearance-none cursor-pointer accent-indigo-600"/> <input
type="range"
min="0.5"
max="5"
step="0.5"
value={a}
onChange={(e) => setA(parseFloat(e.target.value))}
className="w-full h-2 bg-indigo-100 rounded-lg appearance-none cursor-pointer accent-indigo-600"
/>
</div> </div>
<div> <div>
<label className="text-xs font-bold text-emerald-600 uppercase flex justify-between"> <label className="text-xs font-bold text-emerald-600 uppercase flex justify-between">
Growth Factor (b) <span>{b}</span> Growth Factor (b) <span>{b}</span>
</label> </label>
<input type="range" min="0.1" max="3" step="0.1" value={b} onChange={e => setB(parseFloat(e.target.value))} className="w-full h-2 bg-emerald-100 rounded-lg appearance-none cursor-pointer accent-emerald-600"/> <input
<p className="text-xs text-slate-400 mt-1">{b > 1 ? "Growth (b > 1)" : "Decay (0 < b < 1)"}</p> type="range"
min="0.1"
max="3"
step="0.1"
value={b}
onChange={(e) => setB(parseFloat(e.target.value))}
className="w-full h-2 bg-emerald-100 rounded-lg appearance-none cursor-pointer accent-emerald-600"
/>
<p className="text-xs text-slate-400 mt-1">
{b > 1 ? "Growth (b > 1)" : "Decay (0 < b < 1)"}
</p>
</div> </div>
<div> <div>
<label className="text-xs font-bold text-rose-600 uppercase flex justify-between"> <label className="text-xs font-bold text-rose-600 uppercase flex justify-between">
Vertical Shift (k) <span>{k}</span> Vertical Shift (k) <span>{k}</span>
</label> </label>
<input type="range" min="-3" max="3" step="1" value={k} onChange={e => setK(parseFloat(e.target.value))} className="w-full h-2 bg-rose-100 rounded-lg appearance-none cursor-pointer accent-rose-600"/> <input
type="range"
min="-3"
max="3"
step="1"
value={k}
onChange={(e) => setK(parseFloat(e.target.value))}
className="w-full h-2 bg-rose-100 rounded-lg appearance-none cursor-pointer accent-rose-600"
/>
</div> </div>
</div> </div>
</div> </div>
@ -65,18 +95,58 @@ const ExponentialExplorer: React.FC = () => {
<div className="flex-1 flex justify-center"> <div className="flex-1 flex justify-center">
<div className="relative w-[300px] h-[300px] bg-white border border-slate-200 rounded-xl overflow-hidden"> <div className="relative w-[300px] h-[300px] bg-white border border-slate-200 rounded-xl overflow-hidden">
<svg width="100%" height="100%" viewBox="0 0 300 300"> <svg width="100%" height="100%" viewBox="0 0 300 300">
<line x1="0" y1="150" x2="300" y2="150" stroke="#cbd5e1" strokeWidth="2" /> <line
<line x1="150" y1="0" x2="150" y2="300" stroke="#cbd5e1" strokeWidth="2" /> x1="0"
y1="150"
x2="300"
y2="150"
stroke="#cbd5e1"
strokeWidth="2"
/>
<line
x1="150"
y1="0"
x2="150"
y2="300"
stroke="#cbd5e1"
strokeWidth="2"
/>
{/* Asymptote */} {/* Asymptote */}
<line x1="0" y1={toPx(k, true)} x2="300" y2={toPx(k, true)} stroke="#e11d48" strokeWidth="1" strokeDasharray="4,4" /> <line
<text x="10" y={toPx(k, true) - 5} className="text-xs font-bold fill-rose-500">y = {k}</text> x1="0"
y1={toPx(k, true)}
x2="300"
y2={toPx(k, true)}
stroke="#e11d48"
strokeWidth="1"
strokeDasharray="4,4"
/>
<text
x="10"
y={toPx(k, true) - 5}
className="text-xs font-bold fill-rose-500"
>
y = {k}
</text>
{/* Function */} {/* Function */}
<path d={generatePath()} fill="none" stroke="#8b5cf6" strokeWidth="3" /> <path
d={generatePath()}
fill="none"
stroke="#8b5cf6"
strokeWidth="3"
/>
{/* Intercept */} {/* Intercept */}
<circle cx={toPx(0)} cy={toPx(a+k, true)} r="4" fill="#4f46e5" stroke="white" strokeWidth="2" /> <circle
cx={toPx(0)}
cy={toPx(a + k, true)}
r="4"
fill="#4f46e5"
stroke="white"
strokeWidth="2"
/>
</svg> </svg>
</div> </div>
</div> </div>

View File

@ -1,19 +1,19 @@
import React, { useState } from 'react'; import React, { useState } from "react";
const HistogramBuilderWidget: React.FC = () => { const HistogramBuilderWidget: React.FC = () => {
const [mode, setMode] = useState<'count' | 'percent'>('count'); const [mode, setMode] = useState<"count" | "percent">("count");
// Data: [60, 70), [70, 80), [80, 90), [90, 100) // Data: [60, 70), [70, 80), [80, 90), [90, 100)
const data = [ const data = [
{ bin: '60-70', count: 4, label: '60s' }, { bin: "60-70", count: 4, label: "60s" },
{ bin: '70-80', count: 9, label: '70s' }, { bin: "70-80", count: 9, label: "70s" },
{ bin: '80-90', count: 6, label: '80s' }, { bin: "80-90", count: 6, label: "80s" },
{ bin: '90-100', count: 1, label: '90s' }, { bin: "90-100", count: 1, label: "90s" },
]; ];
const total = data.reduce((acc, curr) => acc + curr.count, 0); // 20 const total = data.reduce((acc, curr) => acc + curr.count, 0); // 20
const maxCount = Math.max(...data.map(d => d.count)); const maxCount = Math.max(...data.map((d) => d.count));
const maxPercent = maxCount / total; // 0.45 const maxPercent = maxCount / total; // 0.45
return ( return (
@ -22,14 +22,14 @@ const HistogramBuilderWidget: React.FC = () => {
<h3 className="font-bold text-slate-700">Test Scores Distribution</h3> <h3 className="font-bold text-slate-700">Test Scores Distribution</h3>
<div className="flex bg-slate-100 p-1 rounded-lg"> <div className="flex bg-slate-100 p-1 rounded-lg">
<button <button
onClick={() => setMode('count')} onClick={() => setMode("count")}
className={`px-4 py-1.5 text-sm font-bold rounded-md transition-all ${mode === 'count' ? 'bg-white shadow-sm text-indigo-600' : 'text-slate-500 hover:text-slate-700'}`} className={`px-4 py-1.5 text-sm font-bold rounded-md transition-all ${mode === "count" ? "bg-white shadow-sm text-indigo-600" : "text-slate-500 hover:text-slate-700"}`}
> >
Frequency (Count) Frequency (Count)
</button> </button>
<button <button
onClick={() => setMode('percent')} onClick={() => setMode("percent")}
className={`px-4 py-1.5 text-sm font-bold rounded-md transition-all ${mode === 'percent' ? 'bg-white shadow-sm text-rose-600' : 'text-slate-500 hover:text-slate-700'}`} className={`px-4 py-1.5 text-sm font-bold rounded-md transition-all ${mode === "percent" ? "bg-white shadow-sm text-rose-600" : "text-slate-500 hover:text-slate-700"}`}
> >
Relative Freq (%) Relative Freq (%)
</button> </button>
@ -39,28 +39,42 @@ const HistogramBuilderWidget: React.FC = () => {
<div className="relative h-64 border-b-2 border-slate-200 flex items-end px-8 gap-1"> <div className="relative h-64 border-b-2 border-slate-200 flex items-end px-8 gap-1">
{/* Y Axis Labels */} {/* Y Axis Labels */}
<div className="absolute left-0 top-0 bottom-0 flex flex-col justify-between text-xs font-mono text-slate-400 py-2"> <div className="absolute left-0 top-0 bottom-0 flex flex-col justify-between text-xs font-mono text-slate-400 py-2">
<span>{mode === 'count' ? maxCount + 1 : ((maxPercent + 0.05)*100).toFixed(0) + '%'}</span> <span>
<span>{mode === 'count' ? Math.round((maxCount+1)/2) : (((maxPercent + 0.05)/2)*100).toFixed(0) + '%'}</span> {mode === "count"
? maxCount + 1
: ((maxPercent + 0.05) * 100).toFixed(0) + "%"}
</span>
<span>
{mode === "count"
? Math.round((maxCount + 1) / 2)
: (((maxPercent + 0.05) / 2) * 100).toFixed(0) + "%"}
</span>
<span>0</span> <span>0</span>
</div> </div>
{data.map((d, i) => { {data.map((d, i) => {
const heightRatio = d.count / maxCount; // Normalize to max height of graph area roughly // Normalize to max height of graph area roughly
// Actually map 0 to maxScale // Actually map 0 to maxScale
const maxScale = mode === 'count' ? maxCount + 1 : (maxPercent + 0.05); const maxScale = mode === "count" ? maxCount + 1 : maxPercent + 0.05;
const val = mode === 'count' ? d.count : d.count / total; const val = mode === "count" ? d.count : d.count / total;
const hPercent = (val / maxScale) * 100; const hPercent = (val / maxScale) * 100;
return ( return (
<div key={i} className="flex-1 flex flex-col justify-end group relative h-full"> <div
key={i}
className="flex-1 flex flex-col justify-end group relative h-full"
>
{/* Tooltip */} {/* Tooltip */}
<div className="opacity-0 group-hover:opacity-100 absolute -top-10 left-1/2 -translate-x-1/2 bg-slate-800 text-white text-xs py-1 px-2 rounded pointer-events-none transition-opacity z-10 whitespace-nowrap"> <div className="opacity-0 group-hover:opacity-100 absolute -top-10 left-1/2 -translate-x-1/2 bg-slate-800 text-white text-xs py-1 px-2 rounded pointer-events-none transition-opacity z-10 whitespace-nowrap">
{d.bin}: {mode === 'count' ? d.count : `${(d.count/total*100).toFixed(0)}%`} {d.bin}:{" "}
{mode === "count"
? d.count
: `${((d.count / total) * 100).toFixed(0)}%`}
</div> </div>
{/* Bar */} {/* Bar */}
<div <div
className={`w-full transition-all duration-500 rounded-t ${mode === 'count' ? 'bg-indigo-500 group-hover:bg-indigo-600' : 'bg-rose-500 group-hover:bg-rose-600'}`} className={`w-full transition-all duration-500 rounded-t ${mode === "count" ? "bg-indigo-500 group-hover:bg-indigo-600" : "bg-rose-500 group-hover:bg-rose-600"}`}
style={{ height: `${hPercent}%` }} style={{ height: `${hPercent}%` }}
></div> ></div>
@ -75,8 +89,11 @@ const HistogramBuilderWidget: React.FC = () => {
<div className="mt-8 p-4 bg-slate-50 rounded-xl border border-slate-200"> <div className="mt-8 p-4 bg-slate-50 rounded-xl border border-slate-200">
<p className="text-sm text-slate-600"> <p className="text-sm text-slate-600">
<strong>Key Takeaway:</strong> Notice that the <span className="font-bold text-slate-800">shape</span> of the distribution stays exactly the same. <strong>Key Takeaway:</strong> Notice that the{" "}
Only the <span className="font-bold text-slate-800">Y-axis scale</span> changes. <span className="font-bold text-slate-800">shape</span> of the
distribution stays exactly the same. Only the{" "}
<span className="font-bold text-slate-800">Y-axis scale</span>{" "}
changes.
</p> </p>
</div> </div>
</div> </div>

View File

@ -25,7 +25,8 @@ const PALETTES = {
activeBg: "bg-blue-600", activeBg: "bg-blue-600",
activeText: "text-blue-900", activeText: "text-blue-900",
pastBg: "bg-blue-400", pastBg: "bg-blue-400",
sidebarActive: "bg-white/80 shadow-md border border-blue-100", sidebarActive:
"bg-white/80 shadow-md border border-blue-100 lg:bg-transparent lg:shadow-none lg:border-transparent",
dotBg: "bg-blue-100", dotBg: "bg-blue-100",
dotText: "text-blue-500", dotText: "text-blue-500",
glassClass: "glass-blue", glassClass: "glass-blue",
@ -34,7 +35,8 @@ const PALETTES = {
activeBg: "bg-violet-600", activeBg: "bg-violet-600",
activeText: "text-violet-900", activeText: "text-violet-900",
pastBg: "bg-violet-400", pastBg: "bg-violet-400",
sidebarActive: "bg-white/80 shadow-md border border-violet-100", sidebarActive:
"bg-white/80 shadow-md border border-violet-100 lg:bg-transparent lg:shadow-none lg:border-transparent",
dotBg: "bg-violet-100", dotBg: "bg-violet-100",
dotText: "text-violet-500", dotText: "text-violet-500",
glassClass: "glass-violet", glassClass: "glass-violet",
@ -43,7 +45,8 @@ const PALETTES = {
activeBg: "bg-amber-600", activeBg: "bg-amber-600",
activeText: "text-amber-900", activeText: "text-amber-900",
pastBg: "bg-amber-400", pastBg: "bg-amber-400",
sidebarActive: "bg-white/80 shadow-md border border-amber-100", sidebarActive:
"bg-white/80 shadow-md border border-amber-100 lg:bg-transparent lg:shadow-none lg:border-transparent",
dotBg: "bg-amber-100", dotBg: "bg-amber-100",
dotText: "text-amber-500", dotText: "text-amber-500",
glassClass: "glass-amber", glassClass: "glass-amber",
@ -52,7 +55,8 @@ const PALETTES = {
activeBg: "bg-emerald-600", activeBg: "bg-emerald-600",
activeText: "text-emerald-900", activeText: "text-emerald-900",
pastBg: "bg-emerald-400", pastBg: "bg-emerald-400",
sidebarActive: "bg-white/80 shadow-md border border-emerald-100", sidebarActive:
"bg-white/80 shadow-md border border-emerald-100 lg:bg-transparent lg:shadow-none lg:border-transparent",
dotBg: "bg-emerald-100", dotBg: "bg-emerald-100",
dotText: "text-emerald-500", dotText: "text-emerald-500",
glassClass: "glass-emerald", glassClass: "glass-emerald",
@ -60,7 +64,6 @@ const PALETTES = {
}; };
export default function LessonShell({ export default function LessonShell({
title,
sections, sections,
color, color,
onFinish, onFinish,
@ -105,7 +108,7 @@ export default function LessonShell({
const childArray = React.Children.toArray(children); const childArray = React.Children.toArray(children);
return ( return (
<div className="flex flex-col lg:flex-row min-h-screen lesson-bg"> <div className="flex flex-col lg:flex-row min-h-screen">
{/* ── Mobile toggle ── */} {/* ── Mobile toggle ── */}
<button <button
onClick={() => setSidebarOpen(!sidebarOpen)} onClick={() => setSidebarOpen(!sidebarOpen)}
@ -134,7 +137,7 @@ export default function LessonShell({
<p className="text-[10px] font-bold uppercase tracking-[0.2em] text-slate-400 mb-3 px-1 hidden lg:block"> <p className="text-[10px] font-bold uppercase tracking-[0.2em] text-slate-400 mb-3 px-1 hidden lg:block">
Sections Sections
</p> </p>
<nav className="space-y-1.5 bg-white"> <nav className="space-y-1.5 bg-white lg:bg-transparent">
{sections.map((sec, i) => { {sections.map((sec, i) => {
const isActive = activeSection === i; const isActive = activeSection === i;
const isPast = activeSection > i; const isPast = activeSection > i;
@ -144,7 +147,9 @@ export default function LessonShell({
key={i} key={i}
onClick={() => scrollToSection(i)} onClick={() => scrollToSection(i)}
className={`flex items-center gap-3 p-2.5 w-full rounded-xl transition-all text-left ${ className={`flex items-center gap-3 p-2.5 w-full rounded-xl transition-all text-left ${
isActive ? palette.sidebarActive : "hover:bg-white/50" isActive
? palette.sidebarActive
: "hover:bg-white/50 lg:hover:bg-transparent"
}`} }`}
> >
<div <div
@ -174,7 +179,7 @@ export default function LessonShell({
</aside> </aside>
{/* ── Main content ── */} {/* ── Main content ── */}
<div className="flex-1 max-w-4xl mx-auto w-full"> <div className="flex-1 lg:ml-64 mx-auto md:p-12 max-w-full">
{childArray.map((child, i) => ( {childArray.map((child, i) => (
<section <section
key={i} key={i}

View File

@ -1,10 +1,10 @@
import React, { useState } from 'react'; import React, { useState } from "react";
const LinearTransformationWidget: React.FC = () => { const LinearTransformationWidget: React.FC = () => {
const [h, setH] = useState(0); // Horizontal shift (x - h) const [h, setH] = useState(0); // Horizontal shift (x - h)
const [k, setK] = useState(0); // Vertical shift + k const [k, setK] = useState(0); // Vertical shift + k
const [reflectX, setReflectX] = useState(false); // -f(x) const [reflectX, setReflectX] = useState(false); // -f(x)
const [stretch, setStretch] = useState(1); // a * f(x) const stretch = 1; // a * f(x)
// Base function f(x) = 0.5x // Base function f(x) = 0.5x
// Transformed g(x) = a * f(x - h) + k // Transformed g(x) = a * f(x - h) + k
@ -21,12 +21,14 @@ const LinearTransformationWidget: React.FC = () => {
const size = 300; const size = 300;
const center = size / 2; const center = size / 2;
const toPx = (v: number, isY = false) => isY ? center - v * scale : center + v * scale; const toPx = (v: number, isY = false) =>
isY ? center - v * scale : center + v * scale;
// Base: y = 0.5x (to make it distinct from diagonals) // Base: y = 0.5x (to make it distinct from diagonals)
const getBasePath = () => { const getBasePath = () => {
const m = 0.5; const m = 0.5;
const x1 = -range, x2 = range; const x1 = -range,
x2 = range;
const y1 = m * x1; const y1 = m * x1;
const y2 = m * x2; const y2 = m * x2;
return `M ${toPx(x1)} ${toPx(y1, true)} L ${toPx(x2)} ${toPx(y2, true)}`; return `M ${toPx(x1)} ${toPx(y1, true)} L ${toPx(x2)} ${toPx(y2, true)}`;
@ -35,7 +37,8 @@ const LinearTransformationWidget: React.FC = () => {
const getTransformedPath = () => { const getTransformedPath = () => {
// f(x) = 0.5x // f(x) = 0.5x
// g(x) = effectiveStretch * (0.5 * (x - h)) + k // g(x) = effectiveStretch * (0.5 * (x - h)) + k
const x1 = -range, x2 = range; const x1 = -range,
x2 = range;
const y1 = effectiveStretch * (0.5 * (x1 - h)) + k; const y1 = effectiveStretch * (0.5 * (x1 - h)) + k;
const y2 = effectiveStretch * (0.5 * (x2 - h)) + k; const y2 = effectiveStretch * (0.5 * (x2 - h)) + k;
return `M ${toPx(x1)} ${toPx(y1, true)} L ${toPx(x2)} ${toPx(y2, true)}`; return `M ${toPx(x1)} ${toPx(y1, true)} L ${toPx(x2)} ${toPx(y2, true)}`;
@ -46,9 +49,14 @@ const LinearTransformationWidget: React.FC = () => {
<div className="flex flex-col md:flex-row gap-8"> <div className="flex flex-col md:flex-row gap-8">
<div className="w-full md:w-1/3 space-y-6"> <div className="w-full md:w-1/3 space-y-6">
<div className="p-4 bg-slate-50 border border-slate-200 rounded-xl font-mono text-sm"> <div className="p-4 bg-slate-50 border border-slate-200 rounded-xl font-mono text-sm">
<p className="text-slate-400 mb-2">Base: <span className="text-slate-600 font-bold">f(x) = 0.5x</span></p> <p className="text-slate-400 mb-2">
Base:{" "}
<span className="text-slate-600 font-bold">f(x) = 0.5x</span>
</p>
<p className="text-indigo-900 font-bold text-lg"> <p className="text-indigo-900 font-bold text-lg">
g(x) = {reflectX ? '-' : ''}{stretch !== 1 ? stretch : ''}f(x {h > 0 ? '-' : '+'} {Math.abs(h)}) {k >= 0 ? '+' : '-'} {Math.abs(k)} g(x) = {reflectX ? "-" : ""}
{stretch !== 1 ? stretch : ""}f(x {h > 0 ? "-" : "+"}{" "}
{Math.abs(h)}) {k >= 0 ? "+" : "-"} {Math.abs(k)}
</p> </p>
</div> </div>
@ -58,8 +66,12 @@ const LinearTransformationWidget: React.FC = () => {
Horizontal Shift (h) <span>{h}</span> Horizontal Shift (h) <span>{h}</span>
</label> </label>
<input <input
type="range" min="-5" max="5" step="1" type="range"
value={h} onChange={e => setH(parseInt(e.target.value))} min="-5"
max="5"
step="1"
value={h}
onChange={(e) => setH(parseInt(e.target.value))}
className="w-full h-2 bg-indigo-100 rounded-lg appearance-none cursor-pointer accent-indigo-600 mt-1" className="w-full h-2 bg-indigo-100 rounded-lg appearance-none cursor-pointer accent-indigo-600 mt-1"
/> />
<div className="flex justify-between text-[10px] text-slate-400"> <div className="flex justify-between text-[10px] text-slate-400">
@ -73,15 +85,24 @@ const LinearTransformationWidget: React.FC = () => {
Vertical Shift (k) <span>{k}</span> Vertical Shift (k) <span>{k}</span>
</label> </label>
<input <input
type="range" min="-5" max="5" step="1" type="range"
value={k} onChange={e => setK(parseInt(e.target.value))} min="-5"
max="5"
step="1"
value={k}
onChange={(e) => setK(parseInt(e.target.value))}
className="w-full h-2 bg-emerald-100 rounded-lg appearance-none cursor-pointer accent-emerald-600 mt-1" className="w-full h-2 bg-emerald-100 rounded-lg appearance-none cursor-pointer accent-emerald-600 mt-1"
/> />
</div> </div>
<div className="flex items-center gap-4 pt-2"> <div className="flex items-center gap-4 pt-2">
<label className="flex items-center gap-2 text-sm font-bold text-slate-700 cursor-pointer"> <label className="flex items-center gap-2 text-sm font-bold text-slate-700 cursor-pointer">
<input type="checkbox" checked={reflectX} onChange={e => setReflectX(e.target.checked)} className="accent-rose-600 w-4 h-4"/> <input
type="checkbox"
checked={reflectX}
onChange={(e) => setReflectX(e.target.checked)}
className="accent-rose-600 w-4 h-4"
/>
Reflect (-f(x)) Reflect (-f(x))
</label> </label>
</div> </div>
@ -92,23 +113,60 @@ const LinearTransformationWidget: React.FC = () => {
<div className="relative w-[300px] h-[300px] border border-slate-200 rounded-xl overflow-hidden bg-white"> <div className="relative w-[300px] h-[300px] border border-slate-200 rounded-xl overflow-hidden bg-white">
<svg width="300" height="300" viewBox="0 0 300 300"> <svg width="300" height="300" viewBox="0 0 300 300">
<defs> <defs>
<pattern id="grid-t" width="20" height="20" patternUnits="userSpaceOnUse"> <pattern
<path d="M 20 0 L 0 0 0 20" fill="none" stroke="#f1f5f9" strokeWidth="1"/> id="grid-t"
width="20"
height="20"
patternUnits="userSpaceOnUse"
>
<path
d="M 20 0 L 0 0 0 20"
fill="none"
stroke="#f1f5f9"
strokeWidth="1"
/>
</pattern> </pattern>
</defs> </defs>
<rect width="100%" height="100%" fill="url(#grid-t)" /> <rect width="100%" height="100%" fill="url(#grid-t)" />
{/* Axes */} {/* Axes */}
<line x1="0" y1={center} x2={size} y2={center} stroke="#cbd5e1" strokeWidth="2" /> <line
<line x1={center} y1="0" x2={center} y2={size} stroke="#cbd5e1" strokeWidth="2" /> x1="0"
y1={center}
x2={size}
y2={center}
stroke="#cbd5e1"
strokeWidth="2"
/>
<line
x1={center}
y1="0"
x2={center}
y2={size}
stroke="#cbd5e1"
strokeWidth="2"
/>
{/* Base Function (Ghost) */} {/* Base Function (Ghost) */}
<path d={getBasePath()} stroke="#94a3b8" strokeWidth="2" strokeDasharray="4,4" /> <path
<text x="260" y={toPx(0.5*8, true) - 5} className="text-xs fill-slate-400 font-bold">f(x)</text> d={getBasePath()}
stroke="#94a3b8"
strokeWidth="2"
strokeDasharray="4,4"
/>
<text
x="260"
y={toPx(0.5 * 8, true) - 5}
className="text-xs fill-slate-400 font-bold"
>
f(x)
</text>
{/* Transformed Function */} {/* Transformed Function */}
<path d={getTransformedPath()} stroke="#4f46e5" strokeWidth="3" /> <path d={getTransformedPath()} stroke="#4f46e5" strokeWidth="3" />
<text x="20" y="20" className="text-xs fill-indigo-600 font-bold">g(x)</text> <text x="20" y="20" className="text-xs fill-indigo-600 font-bold">
g(x)
</text>
</svg> </svg>
</div> </div>
</div> </div>

View File

@ -1,8 +1,7 @@
import React, { useState } from 'react'; import React, { useState } from "react";
import { ArrowRight } from 'lucide-react';
const MultiStepPercentWidget: React.FC = () => { const MultiStepPercentWidget: React.FC = () => {
const [start, setStart] = useState(100); const start = 100;
const [change1, setChange1] = useState(40); // +40% const [change1, setChange1] = useState(40); // +40%
const [change2, setChange2] = useState(-25); // -25% const [change2, setChange2] = useState(-25); // -25%
@ -21,25 +20,43 @@ const MultiStepPercentWidget: React.FC = () => {
<div className="flex flex-col md:flex-row gap-8 mb-8"> <div className="flex flex-col md:flex-row gap-8 mb-8">
<div className="w-full md:w-1/3 space-y-6"> <div className="w-full md:w-1/3 space-y-6">
<div> <div>
<label className="text-xs font-bold text-slate-400 uppercase">Change 1 (Markup)</label> <label className="text-xs font-bold text-slate-400 uppercase">
Change 1 (Markup)
</label>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<input <input
type="range" min="-50" max="100" step="5" type="range"
value={change1} onChange={e => setChange1(parseInt(e.target.value))} min="-50"
max="100"
step="5"
value={change1}
onChange={(e) => setChange1(parseInt(e.target.value))}
className="flex-1 accent-indigo-600" className="flex-1 accent-indigo-600"
/> />
<span className="font-bold text-indigo-600 w-12 text-right">{change1 > 0 ? '+' : ''}{change1}%</span> <span className="font-bold text-indigo-600 w-12 text-right">
{change1 > 0 ? "+" : ""}
{change1}%
</span>
</div> </div>
</div> </div>
<div> <div>
<label className="text-xs font-bold text-slate-400 uppercase">Change 2 (Discount)</label> <label className="text-xs font-bold text-slate-400 uppercase">
Change 2 (Discount)
</label>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<input <input
type="range" min="-50" max="50" step="5" type="range"
value={change2} onChange={e => setChange2(parseInt(e.target.value))} min="-50"
max="50"
step="5"
value={change2}
onChange={(e) => setChange2(parseInt(e.target.value))}
className="flex-1 accent-rose-600" className="flex-1 accent-rose-600"
/> />
<span className="font-bold text-rose-600 w-12 text-right">{change2 > 0 ? '+' : ''}{change2}%</span> <span className="font-bold text-rose-600 w-12 text-right">
{change2 > 0 ? "+" : ""}
{change2}%
</span>
</div> </div>
</div> </div>
</div> </div>
@ -51,28 +68,49 @@ const MultiStepPercentWidget: React.FC = () => {
<span>Start</span> <span>Start</span>
<span>${start}</span> <span>${start}</span>
</div> </div>
<div className="h-8 bg-slate-200 rounded-md" style={{ width: `${getWidth(start)}%` }}></div> <div
className="h-8 bg-slate-200 rounded-md"
style={{ width: `${getWidth(start)}%` }}
></div>
</div> </div>
{/* Step 1 */} {/* Step 1 */}
<div className="relative"> <div className="relative">
<div className="flex justify-between text-xs font-bold text-indigo-500 mb-1"> <div className="flex justify-between text-xs font-bold text-indigo-500 mb-1">
<span>After {change1 > 0 ? '+' : ''}{change1}%</span> <span>
After {change1 > 0 ? "+" : ""}
{change1}%
</span>
<span>${step1Val.toFixed(2)}</span> <span>${step1Val.toFixed(2)}</span>
</div> </div>
<div className="h-8 bg-indigo-100 rounded-md transition-all duration-500" style={{ width: `${getWidth(step1Val)}%` }}> <div
<div className="h-full bg-indigo-500 rounded-l-md" style={{ width: `${(start/step1Val)*100}%` }}></div> className="h-8 bg-indigo-100 rounded-md transition-all duration-500"
style={{ width: `${getWidth(step1Val)}%` }}
>
<div
className="h-full bg-indigo-500 rounded-l-md"
style={{ width: `${(start / step1Val) * 100}%` }}
></div>
</div> </div>
</div> </div>
{/* Step 2 */} {/* Step 2 */}
<div className="relative"> <div className="relative">
<div className="flex justify-between text-xs font-bold text-rose-500 mb-1"> <div className="flex justify-between text-xs font-bold text-rose-500 mb-1">
<span>After {change2 > 0 ? '+' : ''}{change2}%</span> <span>
After {change2 > 0 ? "+" : ""}
{change2}%
</span>
<span>${finalVal.toFixed(2)}</span> <span>${finalVal.toFixed(2)}</span>
</div> </div>
<div className="h-8 bg-rose-100 rounded-md transition-all duration-500" style={{ width: `${getWidth(finalVal)}%` }}> <div
<div className="h-full bg-rose-500 rounded-l-md" style={{ width: `${(step1Val/finalVal)*100}%` }}></div> className="h-8 bg-rose-100 rounded-md transition-all duration-500"
style={{ width: `${getWidth(finalVal)}%` }}
>
<div
className="h-full bg-rose-500 rounded-l-md"
style={{ width: `${(step1Val / finalVal) * 100}%` }}
></div>
</div> </div>
</div> </div>
</div> </div>
@ -80,19 +118,28 @@ const MultiStepPercentWidget: React.FC = () => {
<div className="bg-slate-50 p-4 rounded-xl border border-slate-200 grid grid-cols-2 gap-4 text-center"> <div className="bg-slate-50 p-4 rounded-xl border border-slate-200 grid grid-cols-2 gap-4 text-center">
<div> <div>
<div className="text-xs font-bold text-slate-400 uppercase mb-1">The Trap (Additive)</div> <div className="text-xs font-bold text-slate-400 uppercase mb-1">
<div className="text-lg font-bold text-slate-400 line-through decoration-red-500 decoration-2"> The Trap (Additive)
{naiveChange > 0 ? '+' : ''}{naiveChange}% </div>
<div className="text-lg font-bold text-slate-400 line-through decoration-red-500 decoration-2">
{naiveChange > 0 ? "+" : ""}
{naiveChange}%
</div>
<div className="text-[10px] text-slate-400">
({change1} + {change2})
</div> </div>
<div className="text-[10px] text-slate-400">({change1} + {change2})</div>
</div> </div>
<div> <div>
<div className="text-xs font-bold text-emerald-600 uppercase mb-1">Actual Change</div> <div className="text-xs font-bold text-emerald-600 uppercase mb-1">
Actual Change
</div>
<div className="text-2xl font-bold text-emerald-600"> <div className="text-2xl font-bold text-emerald-600">
{overallChange > 0 ? '+' : ''}{overallChange.toFixed(2)}% {overallChange > 0 ? "+" : ""}
{overallChange.toFixed(2)}%
</div> </div>
<div className="text-[10px] text-emerald-600 font-mono"> <div className="text-[10px] text-emerald-600 font-mono">
1.{change1} × {1 + change2/100} = {(1 + change1/100) * (1 + change2/100)} 1.{change1} × {1 + change2 / 100} ={" "}
{(1 + change1 / 100) * (1 + change2 / 100)}
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'; import React, { useState } from "react";
const PolygonWidget: React.FC = () => { const PolygonWidget: React.FC = () => {
const [n, setN] = useState(5); const [n, setN] = useState(5);
@ -15,21 +15,25 @@ const PolygonWidget: React.FC = () => {
const cy = height / 2; const cy = height / 2;
const r = 80; const r = 80;
// Generate points // @ts-ignore
const points = []; const points = [];
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
const angle = (i * 2 * Math.PI) / n - Math.PI / 2; // Start at top const angle = (i * 2 * Math.PI) / n - Math.PI / 2; // Start at top
points.push({ points.push({
x: cx + r * Math.cos(angle), x: cx + r * Math.cos(angle),
y: cy + r * Math.sin(angle) y: cy + r * Math.sin(angle),
}); });
} }
// Generate path string // Generate path string
const pathD = points.map((p, i) => (i === 0 ? `M ${p.x} ${p.y}` : `L ${p.x} ${p.y}`)).join(' ') + ' Z'; const pathD =
points
.map((p, i) => (i === 0 ? `M ${p.x} ${p.y}` : `L ${p.x} ${p.y}`))
.join(" ") + " Z";
// Generate exterior lines (extensions) // Generate exterior lines (extensions)
const exteriorLines = points.map((p, i) => { const exteriorLines = points.map((p, i) => {
// @ts-ignore
const nextP = points[(i + 1) % n]; const nextP = points[(i + 1) % n];
// Vector from p to nextP // Vector from p to nextP
const dx = nextP.x - p.x; const dx = nextP.x - p.x;
@ -45,27 +49,49 @@ const PolygonWidget: React.FC = () => {
return ( return (
<div className="bg-white p-6 rounded-xl shadow-sm border border-slate-200 flex flex-col md:flex-row gap-8 items-center"> <div className="bg-white p-6 rounded-xl shadow-sm border border-slate-200 flex flex-col md:flex-row gap-8 items-center">
<div className="flex-1 w-full max-w-xs"> <div className="flex-1 w-full max-w-xs">
<label className="block text-sm font-bold text-slate-500 uppercase mb-2">Number of Sides (n): <span className="text-slate-900 text-lg">{n}</span></label> <label className="block text-sm font-bold text-slate-500 uppercase mb-2">
Number of Sides (n):{" "}
<span className="text-slate-900 text-lg">{n}</span>
</label>
<input <input
type="range" min="3" max="10" step="1" type="range"
value={n} onChange={(e) => setN(parseInt(e.target.value))} min="3"
max="10"
step="1"
value={n}
onChange={(e) => setN(parseInt(e.target.value))}
className="w-full h-2 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-emerald-600 mb-6" className="w-full h-2 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-emerald-600 mb-6"
/> />
<div className="space-y-3 font-mono text-sm"> <div className="space-y-3 font-mono text-sm">
<div className="p-3 bg-slate-50 rounded border border-slate-200"> <div className="p-3 bg-slate-50 rounded border border-slate-200">
<div className="text-xs text-slate-500 font-bold uppercase">Interior Sum</div> <div className="text-xs text-slate-500 font-bold uppercase">
<div className="text-slate-800">(n - 2) × 180° = <strong className="text-emerald-600">{interiorSum}°</strong></div> Interior Sum
</div>
<div className="text-slate-800">
(n - 2) × 180° ={" "}
<strong className="text-emerald-600">{interiorSum}°</strong>
</div>
</div> </div>
<div className="p-3 bg-slate-50 rounded border border-slate-200"> <div className="p-3 bg-slate-50 rounded border border-slate-200">
<div className="text-xs text-slate-500 font-bold uppercase">Each Interior Angle</div> <div className="text-xs text-slate-500 font-bold uppercase">
<div className="text-slate-800">{interiorSum} / {n} = <strong className="text-emerald-600">{eachInterior}°</strong></div> Each Interior Angle
</div>
<div className="text-slate-800">
{interiorSum} / {n} ={" "}
<strong className="text-emerald-600">{eachInterior}°</strong>
</div>
</div> </div>
<div className="p-3 bg-slate-50 rounded border border-slate-200"> <div className="p-3 bg-slate-50 rounded border border-slate-200">
<div className="text-xs text-slate-500 font-bold uppercase">Each Exterior Angle</div> <div className="text-xs text-slate-500 font-bold uppercase">
<div className="text-slate-800">360 / {n} = <strong className="text-rose-600">{eachExterior}°</strong></div> Each Exterior Angle
</div>
<div className="text-slate-800">
360 / {n} ={" "}
<strong className="text-rose-600">{eachExterior}°</strong>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -74,11 +100,25 @@ const PolygonWidget: React.FC = () => {
<svg width={width} height={height}> <svg width={width} height={height}>
{/* Extensions for exterior angles */} {/* Extensions for exterior angles */}
{exteriorLines.map((line, i) => ( {exteriorLines.map((line, i) => (
<line key={i} x1={line.x1} y1={line.y1} x2={line.x2} y2={line.y2} stroke="#cbd5e1" strokeWidth="2" strokeDasharray="4,4" /> <line
key={i}
x1={line.x1}
y1={line.y1}
x2={line.x2}
y2={line.y2}
stroke="#cbd5e1"
strokeWidth="2"
strokeDasharray="4,4"
/>
))} ))}
{/* Polygon */} {/* Polygon */}
<path d={pathD} fill="rgba(16, 185, 129, 0.1)" stroke="#059669" strokeWidth="3" /> <path
d={pathD}
fill="rgba(16, 185, 129, 0.1)"
stroke="#059669"
strokeWidth="3"
/>
{/* Vertices */} {/* Vertices */}
{points.map((p, i) => ( {points.map((p, i) => (
@ -86,7 +126,16 @@ const PolygonWidget: React.FC = () => {
))} ))}
{/* Center text */} {/* Center text */}
<text x={cx} y={cy} textAnchor="middle" dominantBaseline="middle" fill="#059669" fontSize="24" fontWeight="bold" opacity="0.2"> <text
x={cx}
y={cy}
textAnchor="middle"
dominantBaseline="middle"
fill="#059669"
fontSize="24"
fontWeight="bold"
opacity="0.2"
>
{n}-gon {n}-gon
</text> </text>
</svg> </svg>

View File

@ -1,12 +1,8 @@
import React, { useState } from 'react'; import React, { useState } from "react";
const PolynomialBehaviorWidget: React.FC = () => { const PolynomialBehaviorWidget: React.FC = () => {
const [degreeType, setDegreeType] = useState<'even' | 'odd'>('odd'); const [degreeType, setDegreeType] = useState<"even" | "odd">("odd");
const [lcSign, setLcSign] = useState<'pos' | 'neg'>('pos'); const [lcSign, setLcSign] = useState<"pos" | "neg">("pos");
// Visualization
const width = 300;
const height = 200;
const getPath = () => { const getPath = () => {
// Create schematic shapes // Create schematic shapes
@ -15,8 +11,12 @@ const PolynomialBehaviorWidget: React.FC = () => {
// Even +: High Left -> High Right // Even +: High Left -> High Right
// Even -: Low Left -> Low Right // Even -: Low Left -> Low Right
const startY = (degreeType === 'odd' && lcSign === 'pos') || (degreeType === 'even' && lcSign === 'neg') ? 180 : 20; const startY =
const endY = (lcSign === 'pos') ? 20 : 180; (degreeType === "odd" && lcSign === "pos") ||
(degreeType === "even" && lcSign === "neg")
? 180
: 20;
const endY = lcSign === "pos" ? 20 : 180;
// Control points for curvy polynomial look // Control points for curvy polynomial look
const cp1Y = startY === 20 ? 150 : 50; const cp1Y = startY === 20 ? 150 : 50;
@ -29,46 +29,115 @@ const PolynomialBehaviorWidget: React.FC = () => {
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200"> <div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
<div className="grid grid-cols-2 gap-4 mb-6"> <div className="grid grid-cols-2 gap-4 mb-6">
<div className="space-y-2"> <div className="space-y-2">
<p className="text-xs font-bold text-slate-400 uppercase">Degree (Highest Power)</p> <p className="text-xs font-bold text-slate-400 uppercase">
Degree (Highest Power)
</p>
<div className="flex gap-2"> <div className="flex gap-2">
<button onClick={() => setDegreeType('even')} className={`px-4 py-2 rounded-lg font-bold text-sm border transition-all ${degreeType === 'even' ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-slate-600 border-slate-200'}`}>Even (x², x)</button> <button
<button onClick={() => setDegreeType('odd')} className={`px-4 py-2 rounded-lg font-bold text-sm border transition-all ${degreeType === 'odd' ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-slate-600 border-slate-200'}`}>Odd (x, x³)</button> onClick={() => setDegreeType("even")}
className={`px-4 py-2 rounded-lg font-bold text-sm border transition-all ${degreeType === "even" ? "bg-indigo-600 text-white border-indigo-600" : "bg-white text-slate-600 border-slate-200"}`}
>
Even (x², x)
</button>
<button
onClick={() => setDegreeType("odd")}
className={`px-4 py-2 rounded-lg font-bold text-sm border transition-all ${degreeType === "odd" ? "bg-indigo-600 text-white border-indigo-600" : "bg-white text-slate-600 border-slate-200"}`}
>
Odd (x, x³)
</button>
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<p className="text-xs font-bold text-slate-400 uppercase">Leading Coefficient</p> <p className="text-xs font-bold text-slate-400 uppercase">
Leading Coefficient
</p>
<div className="flex gap-2"> <div className="flex gap-2">
<button onClick={() => setLcSign('pos')} className={`px-4 py-2 rounded-lg font-bold text-sm border transition-all ${lcSign === 'pos' ? 'bg-emerald-600 text-white border-emerald-600' : 'bg-white text-slate-600 border-slate-200'}`}>Positive (+)</button> <button
<button onClick={() => setLcSign('neg')} className={`px-4 py-2 rounded-lg font-bold text-sm border transition-all ${lcSign === 'neg' ? 'bg-rose-600 text-white border-rose-600' : 'bg-white text-slate-600 border-slate-200'}`}>Negative (-)</button> onClick={() => setLcSign("pos")}
className={`px-4 py-2 rounded-lg font-bold text-sm border transition-all ${lcSign === "pos" ? "bg-emerald-600 text-white border-emerald-600" : "bg-white text-slate-600 border-slate-200"}`}
>
Positive (+)
</button>
<button
onClick={() => setLcSign("neg")}
className={`px-4 py-2 rounded-lg font-bold text-sm border transition-all ${lcSign === "neg" ? "bg-rose-600 text-white border-rose-600" : "bg-white text-slate-600 border-slate-200"}`}
>
Negative (-)
</button>
</div> </div>
</div> </div>
</div> </div>
<div className="relative h-48 bg-slate-50 border border-slate-200 rounded-xl overflow-hidden flex items-center justify-center"> <div className="relative h-48 bg-slate-50 border border-slate-200 rounded-xl overflow-hidden flex items-center justify-center">
<svg width="300" height="200"> <svg width="300" height="200">
<line x1="150" y1="20" x2="150" y2="180" stroke="#cbd5e1" strokeWidth="2" /> <line
<line x1="20" y1="100" x2="280" y2="100" stroke="#cbd5e1" strokeWidth="2" /> x1="150"
y1="20"
x2="150"
y2="180"
stroke="#cbd5e1"
strokeWidth="2"
/>
<line
x1="20"
y1="100"
x2="280"
y2="100"
stroke="#cbd5e1"
strokeWidth="2"
/>
<path d={getPath()} stroke="#8b5cf6" strokeWidth="4" fill="none" markerEnd="url(#arrow)" markerStart="url(#arrow-start)" /> <path
d={getPath()}
stroke="#8b5cf6"
strokeWidth="4"
fill="none"
markerEnd="url(#arrow)"
markerStart="url(#arrow-start)"
/>
<defs> <defs>
<marker id="arrow" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto"> <marker
id="arrow"
markerWidth="10"
markerHeight="10"
refX="8"
refY="3"
orient="auto"
>
<path d="M0,0 L0,6 L9,3 z" fill="#8b5cf6" /> <path d="M0,0 L0,6 L9,3 z" fill="#8b5cf6" />
</marker> </marker>
<marker id="arrow-start" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto-start-reverse"> <marker
id="arrow-start"
markerWidth="10"
markerHeight="10"
refX="8"
refY="3"
orient="auto-start-reverse"
>
<path d="M0,0 L0,6 L9,3 z" fill="#8b5cf6" /> <path d="M0,0 L0,6 L9,3 z" fill="#8b5cf6" />
</marker> </marker>
</defs> </defs>
</svg> </svg>
<div className="absolute top-2 left-2 text-xs font-bold text-slate-400">End Behavior</div> <div className="absolute top-2 left-2 text-xs font-bold text-slate-400">
End Behavior
</div>
</div> </div>
<div className="mt-4 p-3 bg-indigo-50 border border-indigo-100 rounded-lg text-sm text-indigo-900 text-center"> <div className="mt-4 p-3 bg-indigo-50 border border-indigo-100 rounded-lg text-sm text-indigo-900 text-center">
{degreeType === 'even' && lcSign === 'pos' && "Ends go in the SAME direction (UP)."} {degreeType === "even" &&
{degreeType === 'even' && lcSign === 'neg' && "Ends go in the SAME direction (DOWN)."} lcSign === "pos" &&
{degreeType === 'odd' && lcSign === 'pos' && "Ends go in OPPOSITE directions (Down Left, Up Right)."} "Ends go in the SAME direction (UP)."}
{degreeType === 'odd' && lcSign === 'neg' && "Ends go in OPPOSITE directions (Up Left, Down Right)."} {degreeType === "even" &&
lcSign === "neg" &&
"Ends go in the SAME direction (DOWN)."}
{degreeType === "odd" &&
lcSign === "pos" &&
"Ends go in OPPOSITE directions (Down Left, Up Right)."}
{degreeType === "odd" &&
lcSign === "neg" &&
"Ends go in OPPOSITE directions (Up Left, Down Right)."}
</div> </div>
</div> </div>
); );

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React, { useState } from "react";
const ProbabilityTreeWidget: React.FC = () => { const ProbabilityTreeWidget: React.FC = () => {
const [replacement, setReplacement] = useState(false); const [replacement, setReplacement] = useState(false);
@ -39,25 +39,39 @@ const ProbabilityTreeWidget: React.FC = () => {
); );
}; };
const getPathColor = (path: string, segment: 'top' | 'bottom' | 'top-top' | 'top-bottom' | 'bottom-top' | 'bottom-bottom') => { const getPathColor = (
segment:
| "top"
| "bottom"
| "top-top"
| "top-bottom"
| "bottom-top"
| "bottom-bottom",
) => {
const defaultColor = "#cbd5e1"; // Slate 300 const defaultColor = "#cbd5e1"; // Slate 300
if (!hoverPath) { if (!hoverPath) {
// Default coloring based on branch type // Default coloring based on branch type
if (segment.includes('top')) return "#f43f5e"; // Red branches if (segment.includes("top")) return "#f43f5e"; // Red branches
if (segment.includes('bottom')) return "#3b82f6"; // Blue branches if (segment.includes("bottom")) return "#3b82f6"; // Blue branches
return defaultColor; return defaultColor;
} }
// Highlighting logic based on hoverPath // Highlighting logic based on hoverPath
if (segment === 'top') return (hoverPath === 'RR' || hoverPath === 'RB') ? "#f43f5e" : "#f1f5f9"; if (segment === "top")
if (segment === 'bottom') return (hoverPath === 'BR' || hoverPath === 'BB') ? "#3b82f6" : "#f1f5f9"; return hoverPath === "RR" || hoverPath === "RB" ? "#f43f5e" : "#f1f5f9";
if (segment === "bottom")
return hoverPath === "BR" || hoverPath === "BB" ? "#3b82f6" : "#f1f5f9";
if (segment === 'top-top') return hoverPath === 'RR' ? "#f43f5e" : "#f1f5f9"; if (segment === "top-top")
if (segment === 'top-bottom') return hoverPath === 'RB' ? "#3b82f6" : "#f1f5f9"; return hoverPath === "RR" ? "#f43f5e" : "#f1f5f9";
if (segment === "top-bottom")
return hoverPath === "RB" ? "#3b82f6" : "#f1f5f9";
if (segment === 'bottom-top') return hoverPath === 'BR' ? "#f43f5e" : "#f1f5f9"; if (segment === "bottom-top")
if (segment === 'bottom-bottom') return hoverPath === 'BB' ? "#3b82f6" : "#f1f5f9"; return hoverPath === "BR" ? "#f43f5e" : "#f1f5f9";
if (segment === "bottom-bottom")
return hoverPath === "BB" ? "#3b82f6" : "#f1f5f9";
return defaultColor; return defaultColor;
}; };
@ -65,38 +79,63 @@ const ProbabilityTreeWidget: React.FC = () => {
const getStrokeWidth = (segment: string) => { const getStrokeWidth = (segment: string) => {
if (!hoverPath) return 2; if (!hoverPath) return 2;
if (segment === 'top') return (hoverPath === 'RR' || hoverPath === 'RB') ? 4 : 1; if (segment === "top")
if (segment === 'bottom') return (hoverPath === 'BR' || hoverPath === 'BB') ? 4 : 1; return hoverPath === "RR" || hoverPath === "RB" ? 4 : 1;
if (segment === "bottom")
return hoverPath === "BR" || hoverPath === "BB" ? 4 : 1;
if (segment === 'top-top') return hoverPath === 'RR' ? 4 : 1; if (segment === "top-top") return hoverPath === "RR" ? 4 : 1;
if (segment === 'top-bottom') return hoverPath === 'RB' ? 4 : 1; if (segment === "top-bottom") return hoverPath === "RB" ? 4 : 1;
if (segment === 'bottom-top') return hoverPath === 'BR' ? 4 : 1; if (segment === "bottom-top") return hoverPath === "BR" ? 4 : 1;
if (segment === 'bottom-bottom') return hoverPath === 'BB' ? 4 : 1; if (segment === "bottom-bottom") return hoverPath === "BB" ? 4 : 1;
return 2; return 2;
} };
return ( return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200"> <div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
{/* Controls */} {/* Controls */}
<div className="flex flex-wrap justify-between items-center mb-6 gap-4"> <div className="flex flex-wrap justify-between items-center mb-6 gap-4">
<div className="flex gap-4"> <div className="flex gap-4">
<div className="flex flex-col"> <div className="flex flex-col">
<label className="text-xs font-bold text-rose-600 uppercase mb-1">Red Items</label> <label className="text-xs font-bold text-rose-600 uppercase mb-1">
Red Items
</label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button onClick={() => setInitR(Math.max(1, initR-1))} className="w-6 h-6 bg-rose-100 text-rose-700 rounded hover:bg-rose-200">-</button> <button
onClick={() => setInitR(Math.max(1, initR - 1))}
className="w-6 h-6 bg-rose-100 text-rose-700 rounded hover:bg-rose-200"
>
-
</button>
<span className="font-bold w-4 text-center">{initR}</span> <span className="font-bold w-4 text-center">{initR}</span>
<button onClick={() => setInitR(Math.min(10, initR+1))} className="w-6 h-6 bg-rose-100 text-rose-700 rounded hover:bg-rose-200">+</button> <button
onClick={() => setInitR(Math.min(10, initR + 1))}
className="w-6 h-6 bg-rose-100 text-rose-700 rounded hover:bg-rose-200"
>
+
</button>
</div> </div>
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col">
<label className="text-xs font-bold text-blue-600 uppercase mb-1">Blue Items</label> <label className="text-xs font-bold text-blue-600 uppercase mb-1">
Blue Items
</label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button onClick={() => setInitB(Math.max(1, initB-1))} className="w-6 h-6 bg-blue-100 text-blue-700 rounded hover:bg-blue-200">-</button> <button
onClick={() => setInitB(Math.max(1, initB - 1))}
className="w-6 h-6 bg-blue-100 text-blue-700 rounded hover:bg-blue-200"
>
-
</button>
<span className="font-bold w-4 text-center">{initB}</span> <span className="font-bold w-4 text-center">{initB}</span>
<button onClick={() => setInitB(Math.min(10, initB+1))} className="w-6 h-6 bg-blue-100 text-blue-700 rounded hover:bg-blue-200">+</button> <button
onClick={() => setInitB(Math.min(10, initB + 1))}
className="w-6 h-6 bg-blue-100 text-blue-700 rounded hover:bg-blue-200"
>
+
</button>
</div> </div>
</div> </div>
</div> </div>
@ -104,13 +143,13 @@ const ProbabilityTreeWidget: React.FC = () => {
<div className="flex bg-slate-100 p-1 rounded-lg"> <div className="flex bg-slate-100 p-1 rounded-lg">
<button <button
onClick={() => setReplacement(true)} onClick={() => setReplacement(true)}
className={`px-3 py-1 text-xs font-bold rounded transition-all ${replacement ? 'bg-white shadow text-indigo-600' : 'text-slate-500'}`} className={`px-3 py-1 text-xs font-bold rounded transition-all ${replacement ? "bg-white shadow text-indigo-600" : "text-slate-500"}`}
> >
With Replacement With Replacement
</button> </button>
<button <button
onClick={() => setReplacement(false)} onClick={() => setReplacement(false)}
className={`px-3 py-1 text-xs font-bold rounded transition-all ${!replacement ? 'bg-white shadow text-indigo-600' : 'text-slate-500'}`} className={`px-3 py-1 text-xs font-bold rounded transition-all ${!replacement ? "bg-white shadow text-indigo-600" : "text-slate-500"}`}
> >
Without Replacement Without Replacement
</button> </button>
@ -123,124 +162,264 @@ const ProbabilityTreeWidget: React.FC = () => {
<circle cx="20" cy="128" r="6" fill="#64748b" /> <circle cx="20" cy="128" r="6" fill="#64748b" />
{/* Level 1 Branches */} {/* Level 1 Branches */}
<path d="M 20 128 C 50 128, 50 64, 150 64" fill="none" stroke={getPathColor('R', 'top')} strokeWidth={getStrokeWidth('top')} className="transition-all duration-300" /> <path
<path d="M 20 128 C 50 128, 50 192, 150 192" fill="none" stroke={getPathColor('B', 'bottom')} strokeWidth={getStrokeWidth('bottom')} className="transition-all duration-300" /> d="M 20 128 C 50 128, 50 64, 150 64"
fill="none"
stroke={getPathColor("top")}
strokeWidth={getStrokeWidth("top")}
className="transition-all duration-300"
/>
<path
d="M 20 128 C 50 128, 50 192, 150 192"
fill="none"
stroke={getPathColor("bottom")}
strokeWidth={getStrokeWidth("bottom")}
className="transition-all duration-300"
/>
{/* Level 1 Labels */} {/* Level 1 Labels */}
<foreignObject x="60" y="70" width="60" height="30"> <foreignObject x="60" y="70" width="60" height="30">
<div className={`text-center font-bold text-xs ${hoverPath && (hoverPath[0]!=='R') ? 'text-slate-300' : 'text-rose-600'}`}>{initR}/{total}</div> <div
className={`text-center font-bold text-xs ${hoverPath && hoverPath[0] !== "R" ? "text-slate-300" : "text-rose-600"}`}
>
{initR}/{total}
</div>
</foreignObject> </foreignObject>
<foreignObject x="60" y="150" width="60" height="30"> <foreignObject x="60" y="150" width="60" height="30">
<div className={`text-center font-bold text-xs ${hoverPath && (hoverPath[0]!=='B') ? 'text-slate-300' : 'text-blue-600'}`}>{initB}/{total}</div> <div
className={`text-center font-bold text-xs ${hoverPath && hoverPath[0] !== "B" ? "text-slate-300" : "text-blue-600"}`}
>
{initB}/{total}
</div>
</foreignObject> </foreignObject>
{/* Level 1 Nodes */} {/* Level 1 Nodes */}
<circle cx="150" cy="64" r="18" fill="#f43f5e" className={`transition-all ${hoverPath && hoverPath[0] !== 'R' ? 'opacity-20' : 'opacity-100 shadow-md'}`} /> <circle
<text x="150" y="68" textAnchor="middle" fill="white" className={`text-xs font-bold pointer-events-none ${hoverPath && hoverPath[0] !== 'R' ? 'opacity-20' : ''}`}>R</text> cx="150"
cy="64"
r="18"
fill="#f43f5e"
className={`transition-all ${hoverPath && hoverPath[0] !== "R" ? "opacity-20" : "opacity-100 shadow-md"}`}
/>
<text
x="150"
y="68"
textAnchor="middle"
fill="white"
className={`text-xs font-bold pointer-events-none ${hoverPath && hoverPath[0] !== "R" ? "opacity-20" : ""}`}
>
R
</text>
<circle cx="150" cy="192" r="18" fill="#3b82f6" className={`transition-all ${hoverPath && hoverPath[0] !== 'B' ? 'opacity-20' : 'opacity-100 shadow-md'}`} /> <circle
<text x="150" y="196" textAnchor="middle" fill="white" className={`text-xs font-bold pointer-events-none ${hoverPath && hoverPath[0] !== 'B' ? 'opacity-20' : ''}`}>B</text> cx="150"
cy="192"
r="18"
fill="#3b82f6"
className={`transition-all ${hoverPath && hoverPath[0] !== "B" ? "opacity-20" : "opacity-100 shadow-md"}`}
/>
<text
x="150"
y="196"
textAnchor="middle"
fill="white"
className={`text-xs font-bold pointer-events-none ${hoverPath && hoverPath[0] !== "B" ? "opacity-20" : ""}`}
>
B
</text>
{/* Level 2 Branches (Top) */} {/* Level 2 Branches (Top) */}
<path d="M 168 64 L 280 32" fill="none" stroke={getPathColor('RR', 'top-top')} strokeWidth={getStrokeWidth('top-top')} strokeDasharray="4,2" className="transition-all duration-300" /> <path
<path d="M 168 64 L 280 96" fill="none" stroke={getPathColor('RB', 'top-bottom')} strokeWidth={getStrokeWidth('top-bottom')} strokeDasharray="4,2" className="transition-all duration-300" /> d="M 168 64 L 280 32"
fill="none"
stroke={getPathColor("top-top")}
strokeWidth={getStrokeWidth("top-top")}
strokeDasharray="4,2"
className="transition-all duration-300"
/>
<path
d="M 168 64 L 280 96"
fill="none"
stroke={getPathColor("top-bottom")}
strokeWidth={getStrokeWidth("top-bottom")}
strokeDasharray="4,2"
className="transition-all duration-300"
/>
{/* Level 2 Top Labels */} {/* Level 2 Top Labels */}
<foreignObject x="190" y="25" width="60" height="30"> <foreignObject x="190" y="25" width="60" height="30">
<div className={`text-center font-bold text-xs ${hoverPath === 'RR' ? 'text-rose-600 scale-110' : 'text-slate-400'}`}>{r_R}/{r_Total}</div> <div
className={`text-center font-bold text-xs ${hoverPath === "RR" ? "text-rose-600 scale-110" : "text-slate-400"}`}
>
{r_R}/{r_Total}
</div>
</foreignObject> </foreignObject>
<foreignObject x="190" y="80" width="60" height="30"> <foreignObject x="190" y="80" width="60" height="30">
<div className={`text-center font-bold text-xs ${hoverPath === 'RB' ? 'text-blue-600 scale-110' : 'text-slate-400'}`}>{initB}/{r_Total}</div> <div
className={`text-center font-bold text-xs ${hoverPath === "RB" ? "text-blue-600 scale-110" : "text-slate-400"}`}
>
{initB}/{r_Total}
</div>
</foreignObject> </foreignObject>
{/* Level 2 Branches (Bottom) */} {/* Level 2 Branches (Bottom) */}
<path d="M 168 192 L 280 160" fill="none" stroke={getPathColor('BR', 'bottom-top')} strokeWidth={getStrokeWidth('bottom-top')} strokeDasharray="4,2" className="transition-all duration-300" /> <path
<path d="M 168 192 L 280 224" fill="none" stroke={getPathColor('BB', 'bottom-bottom')} strokeWidth={getStrokeWidth('bottom-bottom')} strokeDasharray="4,2" className="transition-all duration-300" /> d="M 168 192 L 280 160"
fill="none"
stroke={getPathColor("bottom-top")}
strokeWidth={getStrokeWidth("bottom-top")}
strokeDasharray="4,2"
className="transition-all duration-300"
/>
<path
d="M 168 192 L 280 224"
fill="none"
stroke={getPathColor("bottom-bottom")}
strokeWidth={getStrokeWidth("bottom-bottom")}
strokeDasharray="4,2"
className="transition-all duration-300"
/>
{/* Level 2 Bottom Labels */} {/* Level 2 Bottom Labels */}
<foreignObject x="190" y="150" width="60" height="30"> <foreignObject x="190" y="150" width="60" height="30">
<div className={`text-center font-bold text-xs ${hoverPath === 'BR' ? 'text-rose-600 scale-110' : 'text-slate-400'}`}>{initR}/{b_Total}</div> <div
className={`text-center font-bold text-xs ${hoverPath === "BR" ? "text-rose-600 scale-110" : "text-slate-400"}`}
>
{initR}/{b_Total}
</div>
</foreignObject> </foreignObject>
<foreignObject x="190" y="210" width="60" height="30"> <foreignObject x="190" y="210" width="60" height="30">
<div className={`text-center font-bold text-xs ${hoverPath === 'BB' ? 'text-blue-600 scale-110' : 'text-slate-400'}`}>{b_B}/{b_Total}</div> <div
className={`text-center font-bold text-xs ${hoverPath === "BB" ? "text-blue-600 scale-110" : "text-slate-400"}`}
>
{b_B}/{b_Total}
</div>
</foreignObject> </foreignObject>
{/* Outcomes (Interactive Targets) */} {/* Outcomes (Interactive Targets) */}
<g <g
className="cursor-pointer" className="cursor-pointer"
onMouseEnter={() => setHoverPath('RR')} onMouseEnter={() => setHoverPath("RR")}
onMouseLeave={() => setHoverPath(null)} onMouseLeave={() => setHoverPath(null)}
> >
<text x="300" y="36" className={`text-xs font-bold transition-all ${hoverPath === 'RR' ? 'fill-rose-600 text-base' : 'fill-slate-500'}`}>RR: {(pRR * 100).toFixed(1)}%</text> <text
x="300"
y="36"
className={`text-xs font-bold transition-all ${hoverPath === "RR" ? "fill-rose-600 text-base" : "fill-slate-500"}`}
>
RR: {(pRR * 100).toFixed(1)}%
</text>
<rect x="290" y="20" width="80" height="20" fill="transparent" /> <rect x="290" y="20" width="80" height="20" fill="transparent" />
</g> </g>
<g <g
className="cursor-pointer" className="cursor-pointer"
onMouseEnter={() => setHoverPath('RB')} onMouseEnter={() => setHoverPath("RB")}
onMouseLeave={() => setHoverPath(null)} onMouseLeave={() => setHoverPath(null)}
> >
<text x="300" y="100" className={`text-xs font-bold transition-all ${hoverPath === 'RB' ? 'fill-indigo-600 text-base' : 'fill-slate-500'}`}>RB: {(pRB * 100).toFixed(1)}%</text> <text
x="300"
y="100"
className={`text-xs font-bold transition-all ${hoverPath === "RB" ? "fill-indigo-600 text-base" : "fill-slate-500"}`}
>
RB: {(pRB * 100).toFixed(1)}%
</text>
<rect x="290" y="85" width="80" height="20" fill="transparent" /> <rect x="290" y="85" width="80" height="20" fill="transparent" />
</g> </g>
<g <g
className="cursor-pointer" className="cursor-pointer"
onMouseEnter={() => setHoverPath('BR')} onMouseEnter={() => setHoverPath("BR")}
onMouseLeave={() => setHoverPath(null)} onMouseLeave={() => setHoverPath(null)}
> >
<text x="300" y="164" className={`text-xs font-bold transition-all ${hoverPath === 'BR' ? 'fill-indigo-600 text-base' : 'fill-slate-500'}`}>BR: {(pBR * 100).toFixed(1)}%</text> <text
x="300"
y="164"
className={`text-xs font-bold transition-all ${hoverPath === "BR" ? "fill-indigo-600 text-base" : "fill-slate-500"}`}
>
BR: {(pBR * 100).toFixed(1)}%
</text>
<rect x="290" y="150" width="80" height="20" fill="transparent" /> <rect x="290" y="150" width="80" height="20" fill="transparent" />
</g> </g>
<g <g
className="cursor-pointer" className="cursor-pointer"
onMouseEnter={() => setHoverPath('BB')} onMouseEnter={() => setHoverPath("BB")}
onMouseLeave={() => setHoverPath(null)} onMouseLeave={() => setHoverPath(null)}
> >
<text x="300" y="228" className={`text-xs font-bold transition-all ${hoverPath === 'BB' ? 'fill-blue-600 text-base' : 'fill-slate-500'}`}>BB: {(pBB * 100).toFixed(1)}%</text> <text
x="300"
y="228"
className={`text-xs font-bold transition-all ${hoverPath === "BB" ? "fill-blue-600 text-base" : "fill-slate-500"}`}
>
BB: {(pBB * 100).toFixed(1)}%
</text>
<rect x="290" y="215" width="80" height="20" fill="transparent" /> <rect x="290" y="215" width="80" height="20" fill="transparent" />
</g> </g>
</svg> </svg>
</div> </div>
{/* Calculation Panel */} {/* Calculation Panel */}
<div className={`p-4 rounded-lg border text-sm mt-4 transition-colors ${hoverPath ? 'bg-amber-50 border-amber-200 text-amber-900' : 'bg-slate-50 border-slate-100 text-slate-400'}`}> <div
className={`p-4 rounded-lg border text-sm mt-4 transition-colors ${hoverPath ? "bg-amber-50 border-amber-200 text-amber-900" : "bg-slate-50 border-slate-100 text-slate-400"}`}
>
{!hoverPath ? ( {!hoverPath ? (
<p className="text-center italic">Hover over an outcome (e.g., RR) to see the calculation.</p> <p className="text-center italic">
Hover over an outcome (e.g., RR) to see the calculation.
</p>
) : ( ) : (
<> <>
<p className="font-bold mb-1"> <p className="font-bold mb-1">
Calculation for <span className="font-mono bg-white px-1 rounded border border-amber-200">{hoverPath}</span> Calculation for{" "}
({hoverPath[0] === 'R' ? 'Red' : 'Blue'} then {hoverPath[1] === 'R' ? 'Red' : 'Blue'}): <span className="font-mono bg-white px-1 rounded border border-amber-200">
{hoverPath}
</span>
({hoverPath[0] === "R" ? "Red" : "Blue"} then{" "}
{hoverPath[1] === "R" ? "Red" : "Blue"}):
</p> </p>
<div className="flex flex-wrap items-center gap-2 font-mono text-lg mt-2 justify-center sm:justify-start"> <div className="flex flex-wrap items-center gap-2 font-mono text-lg mt-2 justify-center sm:justify-start">
{/* First Draw */} {/* First Draw */}
<span>P({hoverPath[0]})</span> <span>P({hoverPath[0]})</span>
<span>×</span> <span>×</span>
<span>P({hoverPath[1]} | {hoverPath[0]})</span> <span>
P({hoverPath[1]} | {hoverPath[0]})
</span>
<span>=</span> <span>=</span>
{/* Numbers */} {/* Numbers */}
{fraction(hoverPath[0] === 'R' ? initR : initB, total)} {fraction(hoverPath[0] === "R" ? initR : initB, total)}
<span>×</span> <span>×</span>
{fraction( {fraction(
hoverPath === 'RR' ? r_R : hoverPath === 'RB' ? initB : hoverPath === 'BR' ? initR : b_B, hoverPath === "RR"
hoverPath[0] === 'R' ? r_Total : b_Total ? r_R
: hoverPath === "RB"
? initB
: hoverPath === "BR"
? initR
: b_B,
hoverPath[0] === "R" ? r_Total : b_Total,
)} )}
<span>=</span> <span>=</span>
{/* Result */} {/* Result */}
<strong className="text-amber-700"> <strong className="text-amber-700">
{fraction( {fraction(
(hoverPath[0] === 'R' ? initR : initB) * (hoverPath === 'RR' ? r_R : hoverPath === 'RB' ? initB : hoverPath === 'BR' ? initR : b_B), (hoverPath[0] === "R" ? initR : initB) *
total * (hoverPath[0] === 'R' ? r_Total : b_Total) (hoverPath === "RR"
? r_R
: hoverPath === "RB"
? initB
: hoverPath === "BR"
? initR
: b_B),
total * (hoverPath[0] === "R" ? r_Total : b_Total),
)} )}
</strong> </strong>
</div> </div>
{!replacement && hoverPath[0] === hoverPath[1] && ( {!replacement && hoverPath[0] === hoverPath[1] && (
<p className="text-xs mt-3 text-rose-600 font-bold bg-white p-2 rounded inline-block border border-rose-100"> <p className="text-xs mt-3 text-rose-600 font-bold bg-white p-2 rounded inline-block border border-rose-100">
Notice: The numerator decreased because we kept the first {hoverPath[0] === 'R' ? 'Red' : 'Blue'} item! Notice: The numerator decreased because we kept the first{" "}
{hoverPath[0] === "R" ? "Red" : "Blue"} item!
</p> </p>
)} )}
</> </>

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React, { useState } from "react";
const RadicalSolutionWidget: React.FC = () => { const RadicalSolutionWidget: React.FC = () => {
// Equation: sqrt(x) = x - k // Equation: sqrt(x) = x - k
@ -16,32 +16,41 @@ const RadicalSolutionWidget: React.FC = () => {
if (disc >= 0) { if (disc >= 0) {
const x1 = (-b + Math.sqrt(disc)) / (2 * a); const x1 = (-b + Math.sqrt(disc)) / (2 * a);
const x2 = (-b - Math.sqrt(disc)) / (2 * a); const x2 = (-b - Math.sqrt(disc)) / (2 * a);
solutions = [x1, x2].filter(val => val >= 0); // Domain x>=0 solutions = [x1, x2].filter((val) => val >= 0); // Domain x>=0
} }
// Check validity against original equation sqrt(x) = x - k // Check validity against original equation sqrt(x) = x - k
const validSolutions = solutions.filter(x => Math.abs(Math.sqrt(x) - (x - k)) < 0.01); const validSolutions = solutions.filter(
const extraneousSolutions = solutions.filter(x => Math.abs(Math.sqrt(x) - (x - k)) >= 0.01); (x) => Math.abs(Math.sqrt(x) - (x - k)) < 0.01,
);
const extraneousSolutions = solutions.filter(
(x) => Math.abs(Math.sqrt(x) - (x - k)) >= 0.01,
);
// Vis // Vis
const width = 300;
const height = 300; const height = 300;
const range = 10; const range = 10;
const scale = 25; const scale = 25;
const toPx = (v: number, isY = false) => isY ? height - v * scale - 20 : v * scale + 20; const toPx = (v: number, isY = false) =>
isY ? height - v * scale - 20 : v * scale + 20;
const pathSqrt = () => { const pathSqrt = () => {
let d = ""; let d = "";
for (let x = 0; x <= range; x += 0.1) { for (let x = 0; x <= range; x += 0.1) {
d += d ? ` L ${toPx(x)} ${toPx(Math.sqrt(x), true)}` : `M ${toPx(x)} ${toPx(Math.sqrt(x), true)}`; d += d
? ` L ${toPx(x)} ${toPx(Math.sqrt(x), true)}`
: `M ${toPx(x)} ${toPx(Math.sqrt(x), true)}`;
} }
return d; return d;
}; };
const pathLine = () => { const pathLine = () => {
// y = x - k // y = x - k
const x1 = 0; const y1 = -k; const x1 = 0;
const x2 = range; const y2 = range - k; const y1 = -k;
const x2 = range;
const y2 = range - k;
return `M ${toPx(x1)} ${toPx(y1, true)} L ${toPx(x2)} ${toPx(y2, true)}`; return `M ${toPx(x1)} ${toPx(y1, true)} L ${toPx(x2)} ${toPx(y2, true)}`;
}; };
@ -50,7 +59,9 @@ const RadicalSolutionWidget: React.FC = () => {
const pathPhantom = () => { const pathPhantom = () => {
let d = ""; let d = "";
for (let x = 0; x <= range; x += 0.1) { for (let x = 0; x <= range; x += 0.1) {
d += d ? ` L ${toPx(x)} ${toPx(-Math.sqrt(x), true)}` : `M ${toPx(x)} ${toPx(-Math.sqrt(x), true)}`; d += d
? ` L ${toPx(x)} ${toPx(-Math.sqrt(x), true)}`
: `M ${toPx(x)} ${toPx(-Math.sqrt(x), true)}`;
} }
return d; return d;
}; };
@ -60,34 +71,58 @@ const RadicalSolutionWidget: React.FC = () => {
<div className="flex flex-col md:flex-row gap-8"> <div className="flex flex-col md:flex-row gap-8">
<div className="w-full md:w-1/3 space-y-6"> <div className="w-full md:w-1/3 space-y-6">
<div className="bg-slate-50 p-4 rounded-xl border border-slate-200"> <div className="bg-slate-50 p-4 rounded-xl border border-slate-200">
<div className="text-xs font-bold text-slate-400 uppercase mb-2">Equation</div> <div className="text-xs font-bold text-slate-400 uppercase mb-2">
Equation
</div>
<div className="font-mono text-lg font-bold text-slate-800"> <div className="font-mono text-lg font-bold text-slate-800">
x = x - {k} x = x - {k}
</div> </div>
</div> </div>
<div> <div>
<label className="text-xs font-bold text-slate-500 uppercase">Shift Line (k) = {k}</label> <label className="text-xs font-bold text-slate-500 uppercase">
<input type="range" min="0" max="6" step="0.5" value={k} onChange={e => setK(parseFloat(e.target.value))} className="w-full h-2 bg-slate-200 rounded-lg accent-indigo-600 mt-2"/> Shift Line (k) = {k}
</label>
<input
type="range"
min="0"
max="6"
step="0.5"
value={k}
onChange={(e) => setK(parseFloat(e.target.value))}
className="w-full h-2 bg-slate-200 rounded-lg accent-indigo-600 mt-2"
/>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
<div className="p-3 bg-emerald-50 rounded border border-emerald-100"> <div className="p-3 bg-emerald-50 rounded border border-emerald-100">
<div className="text-xs font-bold text-emerald-700 uppercase mb-1">Valid Solutions</div> <div className="text-xs font-bold text-emerald-700 uppercase mb-1">
Valid Solutions
</div>
<div className="font-mono text-sm font-bold text-emerald-900"> <div className="font-mono text-sm font-bold text-emerald-900">
{validSolutions.length > 0 ? validSolutions.map(n => `x = ${n.toFixed(2)}`).join(', ') : "None"} {validSolutions.length > 0
? validSolutions.map((n) => `x = ${n.toFixed(2)}`).join(", ")
: "None"}
</div> </div>
</div> </div>
<div className="p-3 bg-rose-50 rounded border border-rose-100"> <div className="p-3 bg-rose-50 rounded border border-rose-100">
<div className="text-xs font-bold text-rose-700 uppercase mb-1">Extraneous Solutions</div> <div className="text-xs font-bold text-rose-700 uppercase mb-1">
Extraneous Solutions
</div>
<div className="font-mono text-sm font-bold text-rose-900"> <div className="font-mono text-sm font-bold text-rose-900">
{extraneousSolutions.length > 0 ? extraneousSolutions.map(n => `x = ${n.toFixed(2)}`).join(', ') : "None"} {extraneousSolutions.length > 0
? extraneousSolutions
.map((n) => `x = ${n.toFixed(2)}`)
.join(", ")
: "None"}
</div> </div>
</div> </div>
</div> </div>
<p className="text-xs text-slate-400 leading-relaxed"> <p className="text-xs text-slate-400 leading-relaxed">
The <span className="text-rose-400 font-bold">extraneous</span> solution is a real intersection for the <em>squared</em> equation (the phantom curve), but not for the original radical. The <span className="text-rose-400 font-bold">extraneous</span>{" "}
solution is a real intersection for the <em>squared</em> equation
(the phantom curve), but not for the original radical.
</p> </p>
</div> </div>
@ -96,35 +131,95 @@ const RadicalSolutionWidget: React.FC = () => {
<svg width="100%" height="100%" viewBox="0 0 300 300"> <svg width="100%" height="100%" viewBox="0 0 300 300">
{/* Grid */} {/* Grid */}
<defs> <defs>
<pattern id="grid-rad" width="25" height="25" patternUnits="userSpaceOnUse"> <pattern
<path d="M 25 0 L 0 0 0 25" fill="none" stroke="#f8fafc" strokeWidth="1"/> id="grid-rad"
width="25"
height="25"
patternUnits="userSpaceOnUse"
>
<path
d="M 25 0 L 0 0 0 25"
fill="none"
stroke="#f8fafc"
strokeWidth="1"
/>
</pattern> </pattern>
</defs> </defs>
<rect width="100%" height="100%" fill="url(#grid-rad)" /> <rect width="100%" height="100%" fill="url(#grid-rad)" />
{/* Axes */} {/* Axes */}
<line x1="20" y1="0" x2="20" y2="300" stroke="#cbd5e1" strokeWidth="2" /> <line
<line x1="0" y1={toPx(0, true)} x2="300" y2={toPx(0, true)} stroke="#cbd5e1" strokeWidth="2" /> x1="20"
y1="0"
x2="20"
y2="300"
stroke="#cbd5e1"
strokeWidth="2"
/>
<line
x1="0"
y1={toPx(0, true)}
x2="300"
y2={toPx(0, true)}
stroke="#cbd5e1"
strokeWidth="2"
/>
{/* Phantom -sqrt(x) */} {/* Phantom -sqrt(x) */}
<path d={pathPhantom()} fill="none" stroke="#cbd5e1" strokeWidth="2" strokeDasharray="4,4" /> <path
d={pathPhantom()}
fill="none"
stroke="#cbd5e1"
strokeWidth="2"
strokeDasharray="4,4"
/>
{/* Real sqrt(x) */} {/* Real sqrt(x) */}
<path d={pathSqrt()} fill="none" stroke="#4f46e5" strokeWidth="3" /> <path
d={pathSqrt()}
fill="none"
stroke="#4f46e5"
strokeWidth="3"
/>
{/* Line x-k */} {/* Line x-k */}
<path d={pathLine()} fill="none" stroke="#64748b" strokeWidth="2" /> <path
d={pathLine()}
fill="none"
stroke="#64748b"
strokeWidth="2"
/>
{/* Points */} {/* Points */}
{validSolutions.map(x => ( {validSolutions.map((x) => (
<circle key={`v-${x}`} cx={toPx(x)} cy={toPx(Math.sqrt(x), true)} r="5" fill="#10b981" stroke="white" strokeWidth="2" /> <circle
key={`v-${x}`}
cx={toPx(x)}
cy={toPx(Math.sqrt(x), true)}
r="5"
fill="#10b981"
stroke="white"
strokeWidth="2"
/>
))} ))}
{extraneousSolutions.map(x => ( {extraneousSolutions.map((x) => (
<circle key={`e-${x}`} cx={toPx(x)} cy={toPx(-(Math.sqrt(x)), true)} r="5" fill="#f43f5e" stroke="white" strokeWidth="2" /> <circle
key={`e-${x}`}
cx={toPx(x)}
cy={toPx(-Math.sqrt(x), true)}
r="5"
fill="#f43f5e"
stroke="white"
strokeWidth="2"
/>
))} ))}
</svg> </svg>
<div className="absolute top-2 right-2 text-xs font-bold text-indigo-600">y = x</div> <div className="absolute top-2 right-2 text-xs font-bold text-indigo-600">
<div className="absolute bottom-10 right-2 text-xs font-bold text-slate-500">y = x - {k}</div> y = x
</div>
<div className="absolute bottom-10 right-2 text-xs font-bold text-slate-500">
y = x - {k}
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -0,0 +1,114 @@
import React, { useState, useEffect, useRef } from 'react';
export interface RevealCard {
label: string;
sublabel?: string;
content: string;
}
const COLORS: Record<string, {
text: string; activeBg: string; activeBorder: string;
hoverBorder: string; btn: string;
}> = {
fuchsia: { text: 'text-fuchsia-700', activeBg: 'bg-fuchsia-50', activeBorder: 'border-fuchsia-300', hoverBorder: 'hover:border-fuchsia-300', btn: 'text-fuchsia-600' },
teal: { text: 'text-teal-700', activeBg: 'bg-teal-50', activeBorder: 'border-teal-300', hoverBorder: 'hover:border-teal-300', btn: 'text-teal-600' },
purple: { text: 'text-purple-700', activeBg: 'bg-purple-50', activeBorder: 'border-purple-300', hoverBorder: 'hover:border-purple-300', btn: 'text-purple-600' },
rose: { text: 'text-rose-700', activeBg: 'bg-rose-50', activeBorder: 'border-rose-300', hoverBorder: 'hover:border-rose-300', btn: 'text-rose-600' },
indigo: { text: 'text-indigo-700', activeBg: 'bg-indigo-50', activeBorder: 'border-indigo-300', hoverBorder: 'hover:border-indigo-300', btn: 'text-indigo-600' },
amber: { text: 'text-amber-700', activeBg: 'bg-amber-50', activeBorder: 'border-amber-300', hoverBorder: 'hover:border-amber-300', btn: 'text-amber-600' },
};
interface Props {
cards: RevealCard[];
columns?: 2 | 3 | 4 | 5;
accentColor?: string;
}
const RevealCardGrid: React.FC<Props> = ({ cards, columns = 3, accentColor = 'fuchsia' }) => {
const [revealed, setRevealed] = useState<Set<number>>(new Set());
const [visible, setVisible] = useState(false);
const [entered, setEntered] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const c = COLORS[accentColor] || COLORS.fuchsia;
/* Entrance trigger — animate cards when grid scrolls into view */
useEffect(() => {
const el = ref.current;
if (!el) return;
const obs = new IntersectionObserver(
entries => { if (entries[0]?.isIntersecting) { setVisible(true); obs.disconnect(); } },
{ threshold: 0.1 },
);
obs.observe(el);
return () => obs.disconnect();
}, []);
/* Clear stagger delays after entrance finishes so tap interactions are instant */
useEffect(() => {
if (!visible) return;
const t = setTimeout(() => setEntered(true), Math.min(cards.length * 50, 600) + 400);
return () => clearTimeout(t);
}, [visible, cards.length]);
const toggle = (i: number) =>
setRevealed(prev => { const s = new Set(prev); s.has(i) ? s.delete(i) : s.add(i); return s; });
const allDone = revealed.size === cards.length;
const colCls =
columns === 2 ? 'grid-cols-1 sm:grid-cols-2'
: columns === 4 ? 'grid-cols-2 sm:grid-cols-4'
: columns === 5 ? 'grid-cols-2 sm:grid-cols-5'
: 'grid-cols-2 sm:grid-cols-3';
return (
<div ref={ref} className="space-y-3">
<div className="flex items-center justify-between">
<p className="text-xs text-slate-400 font-medium">
{revealed.size}/{cards.length} revealed
</p>
<button
onClick={() => setRevealed(allDone ? new Set() : new Set(cards.map((_, i) => i)))}
className={`text-xs font-bold ${c.btn} hover:underline transition-colors`}
>
{allDone ? 'Hide All' : 'Reveal All'}
</button>
</div>
<div className={`grid ${colCls} gap-2`}>
{cards.map((card, i) => {
const open = revealed.has(i);
return (
<button
key={i}
onClick={() => toggle(i)}
aria-expanded={open}
className={[
'text-left rounded-xl p-3 border transition-all duration-300 ease-out',
visible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-3',
open
? `${c.activeBg} ${c.activeBorder} shadow-sm`
: `bg-slate-50 border-slate-200 ${c.hoverBorder} hover:shadow-sm`,
].join(' ')}
style={{ transitionDelay: !entered ? `${Math.min(i * 50, 600)}ms` : '0ms' }}
>
<p className={`font-bold ${c.text} text-xs mb-0.5`}>{card.label}</p>
{card.sublabel && (
<p className="text-[10px] text-slate-500 font-medium mb-1">{card.sublabel}</p>
)}
<div className={`transition-opacity duration-200 ${open ? 'opacity-100' : 'opacity-50'}`}>
{open ? (
<p className="text-xs text-slate-600 leading-relaxed">{card.content}</p>
) : (
<p className="text-xs text-slate-400 italic">tap to reveal</p>
)}
</div>
</button>
);
})}
</div>
</div>
);
};
export default RevealCardGrid;

View File

@ -1,9 +1,9 @@
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef } from "react";
type Mode = 'AA' | 'SAS' | 'SSS'; type Mode = "AA" | "SAS" | "SSS";
const SimilarityTestsWidget: React.FC = () => { const SimilarityTestsWidget: React.FC = () => {
const [mode, setMode] = useState<Mode>('AA'); const [mode, setMode] = useState<Mode>("AA");
const [scale, setScale] = useState(1.5); const [scale, setScale] = useState(1.5);
// Store Vertex B's position relative to A (x offset, y height) // Store Vertex B's position relative to A (x offset, y height)
// A is at (40, 220). SVG Y is down. // A is at (40, 220). SVG Y is down.
@ -20,13 +20,16 @@ const SimilarityTestsWidget: React.FC = () => {
const B = { x: A.x + vertexB.x, y: A.y - vertexB.y }; const B = { x: A.x + vertexB.x, y: A.y - vertexB.y };
// Calculate lengths and angles for T1 // Calculate lengths and angles for T1
const dist = (p1: {x:number, y:number}, p2: {x:number, y:number}) => Math.sqrt((p1.x - p2.x)**2 + (p1.y - p2.y)**2); const dist = (p1: { x: number; y: number }, p2: { x: number; y: number }) =>
Math.sqrt((p1.x - p2.x) ** 2 + (p1.y - p2.y) ** 2);
const c1 = dist(A, B); // side c (opp C) - Side AB const c1 = dist(A, B); // side c (opp C) - Side AB
const a1 = dist(B, C); // side a (opp A) - Side BC const a1 = dist(B, C); // side a (opp A) - Side BC
const b1 = dist(A, C); // side b (opp B) - Side AC (Base) const b1 = dist(A, C); // side b (opp B) - Side AC (Base)
const getAngle = (a: number, b: number, c: number) => { const getAngle = (a: number, b: number, c: number) => {
return Math.acos((b**2 + c**2 - a**2) / (2 * b * c)) * (180 / Math.PI); return (
Math.acos((b ** 2 + c ** 2 - a ** 2) / (2 * b * c)) * (180 / Math.PI)
);
}; };
const angleA = getAngle(a1, b1, c1); const angleA = getAngle(a1, b1, c1);
@ -45,7 +48,7 @@ const SimilarityTestsWidget: React.FC = () => {
const vecAB = { x: B.x - A.x, y: B.y - A.y }; const vecAB = { x: B.x - A.x, y: B.y - A.y };
const E = { const E = {
x: D.x + vecAB.x * scale, x: D.x + vecAB.x * scale,
y: D.y + vecAB.y * scale y: D.y + vecAB.y * scale,
}; };
// Interaction // Interaction
@ -72,18 +75,22 @@ const SimilarityTestsWidget: React.FC = () => {
const sideColor = "#059669"; // Emerald const sideColor = "#059669"; // Emerald
// Helper: draw filled angle wedge + labelled badge at a vertex // Helper: draw filled angle wedge + labelled badge at a vertex
const angleC = 180 - angleA - angleB;
const renderAngle = ( const renderAngle = (
vx: number, vy: number, vx: number,
p1x: number, p1y: number, vy: number,
p2x: number, p2y: number, p1x: number,
p1y: number,
p2x: number,
p2y: number,
deg: number, deg: number,
r = 28 r = 28,
) => { ) => {
const d1 = Math.atan2(p1y - vy, p1x - vx); const d1 = Math.atan2(p1y - vy, p1x - vx);
const d2 = Math.atan2(p2y - vy, p2x - vx); const d2 = Math.atan2(p2y - vy, p2x - vx);
const sx = vx + r * Math.cos(d1), sy = vy + r * Math.sin(d1); const sx = vx + r * Math.cos(d1),
const ex = vx + r * Math.cos(d2), ey = vy + r * Math.sin(d2); sy = vy + r * Math.sin(d1);
const ex = vx + r * Math.cos(d2),
ey = vy + r * Math.sin(d2);
const cross = (p1x - vx) * (p2y - vy) - (p1y - vy) * (p2x - vx); const cross = (p1x - vx) * (p2y - vy) - (p1y - vy) * (p2x - vx);
const sweep = cross > 0 ? 1 : 0; const sweep = cross > 0 ? 1 : 0;
let diff = d2 - d1; let diff = d2 - d1;
@ -91,13 +98,40 @@ const SimilarityTestsWidget: React.FC = () => {
while (diff < -Math.PI) diff += 2 * Math.PI; while (diff < -Math.PI) diff += 2 * Math.PI;
const mid = d1 + diff / 2; const mid = d1 + diff / 2;
const lr = r + 18; const lr = r + 18;
const lx = vx + lr * Math.cos(mid), ly = vy + lr * Math.sin(mid); const lx = vx + lr * Math.cos(mid),
ly = vy + lr * Math.sin(mid);
const txt = `${Math.round(deg)}°`; const txt = `${Math.round(deg)}°`;
return ( return (
<g> <g>
<path d={`M ${vx} ${vy} L ${sx} ${sy} A ${r} ${r} 0 0 ${sweep} ${ex} ${ey} Z`} fill={angleColor} fillOpacity={0.12} stroke={angleColor} strokeWidth={2} /> <path
<rect x={lx - 18} y={ly - 10} width={36} height={20} rx={5} fill="white" fillOpacity={0.92} stroke={angleColor} strokeWidth={0.8} /> d={`M ${vx} ${vy} L ${sx} ${sy} A ${r} ${r} 0 0 ${sweep} ${ex} ${ey} Z`}
<text x={lx} y={ly + 5} textAnchor="middle" fill={angleColor} fontSize="13" fontWeight="bold" fontFamily="system-ui">{txt}</text> fill={angleColor}
fillOpacity={0.12}
stroke={angleColor}
strokeWidth={2}
/>
<rect
x={lx - 18}
y={ly - 10}
width={36}
height={20}
rx={5}
fill="white"
fillOpacity={0.92}
stroke={angleColor}
strokeWidth={0.8}
/>
<text
x={lx}
y={ly + 5}
textAnchor="middle"
fill={angleColor}
fontSize="13"
fontWeight="bold"
fontFamily="system-ui"
>
{txt}
</text>
</g> </g>
); );
}; };
@ -106,14 +140,14 @@ const SimilarityTestsWidget: React.FC = () => {
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200"> <div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
<div className="flex flex-col sm:flex-row gap-4 justify-between items-center mb-6"> <div className="flex flex-col sm:flex-row gap-4 justify-between items-center mb-6">
<div className="flex bg-slate-100 p-1 rounded-lg overflow-x-auto max-w-full"> <div className="flex bg-slate-100 p-1 rounded-lg overflow-x-auto max-w-full">
{(['AA', 'SAS', 'SSS'] as Mode[]).map(m => ( {(["AA", "SAS", "SSS"] as Mode[]).map((m) => (
<button <button
key={m} key={m}
onClick={() => setMode(m)} onClick={() => setMode(m)}
className={`px-4 py-2 rounded-md text-sm font-bold transition-all whitespace-nowrap ${ className={`px-4 py-2 rounded-md text-sm font-bold transition-all whitespace-nowrap ${
mode === m mode === m
? 'bg-white text-rose-600 shadow-sm' ? "bg-white text-rose-600 shadow-sm"
: 'text-slate-500 hover:text-rose-600' : "text-slate-500 hover:text-rose-600"
}`} }`}
> >
{m} {m}
@ -122,61 +156,150 @@ const SimilarityTestsWidget: React.FC = () => {
</div> </div>
<div className="flex items-center gap-3 bg-slate-50 px-4 py-2 rounded-lg border border-slate-200"> <div className="flex items-center gap-3 bg-slate-50 px-4 py-2 rounded-lg border border-slate-200">
<span className="text-xs font-bold text-slate-400 uppercase">Scale (k)</span> <span className="text-xs font-bold text-slate-400 uppercase">
Scale (k)
</span>
<input <input
type="range" min="0.5" max="2.5" step="0.1" type="range"
min="0.5"
max="2.5"
step="0.1"
value={scale} value={scale}
onChange={e => setScale(parseFloat(e.target.value))} onChange={(e) => setScale(parseFloat(e.target.value))}
className="w-24 h-2 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-rose-600" className="w-24 h-2 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-rose-600"
/> />
<span className="font-mono font-bold text-rose-600 text-sm w-12 text-right">{scale.toFixed(1)}x</span> <span className="font-mono font-bold text-rose-600 text-sm w-12 text-right">
{scale.toFixed(1)}x
</span>
</div> </div>
</div> </div>
<div className="relative border border-slate-100 rounded-lg bg-slate-50 mb-6 overflow-hidden flex justify-center"> <div className="relative border border-slate-100 rounded-lg bg-slate-50 mb-6 overflow-hidden flex justify-center">
<svg <svg
ref={svgRef} ref={svgRef}
width="550" height="280" width="550"
height="280"
className="cursor-default select-none" className="cursor-default select-none"
onMouseMove={handleMouseMove} onMouseMove={handleMouseMove}
onMouseUp={() => isDragging.current = false} onMouseUp={() => (isDragging.current = false)}
onMouseLeave={() => isDragging.current = false} onMouseLeave={() => (isDragging.current = false)}
> >
<defs> <defs>
<pattern id="grid" width="20" height="20" patternUnits="userSpaceOnUse"> <pattern
<path d="M 20 0 L 0 0 0 20" fill="none" stroke="#e2e8f0" strokeWidth="0.5"/> id="grid"
width="20"
height="20"
patternUnits="userSpaceOnUse"
>
<path
d="M 20 0 L 0 0 0 20"
fill="none"
stroke="#e2e8f0"
strokeWidth="0.5"
/>
</pattern> </pattern>
</defs> </defs>
<rect width="100%" height="100%" fill="url(#grid)" /> <rect width="100%" height="100%" fill="url(#grid)" />
{/* Triangle 1 (ABC) */} {/* Triangle 1 (ABC) */}
<path d={`M ${A.x} ${A.y} L ${B.x} ${B.y} L ${C.x} ${C.y} Z`} fill="rgba(255, 255, 255, 0.8)" stroke="#334155" strokeWidth="2" /> <path
d={`M ${A.x} ${A.y} L ${B.x} ${B.y} L ${C.x} ${C.y} Z`}
fill="rgba(255, 255, 255, 0.8)"
stroke="#334155"
strokeWidth="2"
/>
{/* Vertices T1 */} {/* Vertices T1 */}
<circle cx={A.x} cy={A.y} r="4" fill="#334155" /> <circle cx={A.x} cy={A.y} r="4" fill="#334155" />
<text x={A.x - 16} y={A.y + 14} fontWeight="bold" fill="#334155" fontSize="14">A</text> <text
x={A.x - 16}
y={A.y + 14}
fontWeight="bold"
fill="#334155"
fontSize="14"
>
A
</text>
<circle cx={C.x} cy={C.y} r="4" fill="#334155" /> <circle cx={C.x} cy={C.y} r="4" fill="#334155" />
<text x={C.x + 8} y={C.y + 14} fontWeight="bold" fill="#334155" fontSize="14">C</text> <text
x={C.x + 8}
y={C.y + 14}
fontWeight="bold"
fill="#334155"
fontSize="14"
>
C
</text>
{/* Draggable B */} {/* Draggable B */}
<g onMouseDown={() => isDragging.current = true} className="cursor-grab active:cursor-grabbing"> <g
<circle cx={B.x} cy={B.y} r="20" fill="transparent" /> {/* Hit area */} onMouseDown={() => (isDragging.current = true)}
<circle cx={B.x} cy={B.y} r="7" fill="#f43f5e" stroke="white" strokeWidth="2" /> className="cursor-grab active:cursor-grabbing"
<text x={B.x} y={B.y - 16} textAnchor="middle" fontWeight="bold" fill="#f43f5e" fontSize="14">B</text> >
<circle cx={B.x} cy={B.y} r="20" fill="transparent" />{" "}
{/* Hit area */}
<circle
cx={B.x}
cy={B.y}
r="7"
fill="#f43f5e"
stroke="white"
strokeWidth="2"
/>
<text
x={B.x}
y={B.y - 16}
textAnchor="middle"
fontWeight="bold"
fill="#f43f5e"
fontSize="14"
>
B
</text>
</g> </g>
{/* Triangle 2 (DEF) */} {/* Triangle 2 (DEF) */}
<path d={`M ${D.x} ${D.y} L ${E.x} ${E.y} L ${F.x} ${F.y} Z`} fill="rgba(255, 255, 255, 0.8)" stroke="#334155" strokeWidth="2" /> <path
d={`M ${D.x} ${D.y} L ${E.x} ${E.y} L ${F.x} ${F.y} Z`}
fill="rgba(255, 255, 255, 0.8)"
stroke="#334155"
strokeWidth="2"
/>
<circle cx={D.x} cy={D.y} r="4" fill="#334155" /> <circle cx={D.x} cy={D.y} r="4" fill="#334155" />
<text x={D.x - 16} y={D.y + 14} fontWeight="bold" fill="#334155" fontSize="14">D</text> <text
x={D.x - 16}
y={D.y + 14}
fontWeight="bold"
fill="#334155"
fontSize="14"
>
D
</text>
<circle cx={F.x} cy={F.y} r="4" fill="#334155" /> <circle cx={F.x} cy={F.y} r="4" fill="#334155" />
<text x={F.x + 8} y={F.y + 14} fontWeight="bold" fill="#334155" fontSize="14">F</text> <text
x={F.x + 8}
y={F.y + 14}
fontWeight="bold"
fill="#334155"
fontSize="14"
>
F
</text>
<circle cx={E.x} cy={E.y} r="4" fill="#334155" /> <circle cx={E.x} cy={E.y} r="4" fill="#334155" />
<text x={E.x} y={E.y - 16} textAnchor="middle" fontWeight="bold" fill="#334155" fontSize="14">E</text> <text
x={E.x}
y={E.y - 16}
textAnchor="middle"
fontWeight="bold"
fill="#334155"
fontSize="14"
>
E
</text>
{/* Visual Overlays based on Mode */} {/* Visual Overlays based on Mode */}
{mode === 'AA' && ( {mode === "AA" && (
<> <>
{/* Angle A and D (base-left) */} {/* Angle A and D (base-left) */}
{renderAngle(A.x, A.y, C.x, C.y, B.x, B.y, angleA)} {renderAngle(A.x, A.y, C.x, C.y, B.x, B.y, angleA)}
@ -187,7 +310,7 @@ const SimilarityTestsWidget: React.FC = () => {
</> </>
)} )}
{mode === 'SAS' && ( {mode === "SAS" && (
<> <>
{/* Included Angle A and D */} {/* Included Angle A and D */}
{renderAngle(A.x, A.y, C.x, C.y, B.x, B.y, angleA)} {renderAngle(A.x, A.y, C.x, C.y, B.x, B.y, angleA)}
@ -195,38 +318,228 @@ const SimilarityTestsWidget: React.FC = () => {
{/* Side labels with background badges */} {/* Side labels with background badges */}
{/* Side AB / DE */} {/* Side AB / DE */}
<rect x={(A.x + B.x)/2 - 24} y={(A.y + B.y)/2 - 12} width={36} height={20} rx={5} fill="white" fillOpacity={0.92} stroke={sideColor} strokeWidth={0.8} /> <rect
<text x={(A.x + B.x)/2 - 6} y={(A.y + B.y)/2 + 3} fill={sideColor} fontSize="13" fontWeight="bold" textAnchor="middle">{Math.round(c1)}</text> x={(A.x + B.x) / 2 - 24}
<rect x={(D.x + E.x)/2 - 24} y={(D.y + E.y)/2 - 12} width={36} height={20} rx={5} fill="white" fillOpacity={0.92} stroke={sideColor} strokeWidth={0.8} /> y={(A.y + B.y) / 2 - 12}
<text x={(D.x + E.x)/2 - 6} y={(D.y + E.y)/2 + 3} fill={sideColor} fontSize="13" fontWeight="bold" textAnchor="middle">{Math.round(c1 * scale)}</text> width={36}
height={20}
rx={5}
fill="white"
fillOpacity={0.92}
stroke={sideColor}
strokeWidth={0.8}
/>
<text
x={(A.x + B.x) / 2 - 6}
y={(A.y + B.y) / 2 + 3}
fill={sideColor}
fontSize="13"
fontWeight="bold"
textAnchor="middle"
>
{Math.round(c1)}
</text>
<rect
x={(D.x + E.x) / 2 - 24}
y={(D.y + E.y) / 2 - 12}
width={36}
height={20}
rx={5}
fill="white"
fillOpacity={0.92}
stroke={sideColor}
strokeWidth={0.8}
/>
<text
x={(D.x + E.x) / 2 - 6}
y={(D.y + E.y) / 2 + 3}
fill={sideColor}
fontSize="13"
fontWeight="bold"
textAnchor="middle"
>
{Math.round(c1 * scale)}
</text>
{/* Side AC / DF */} {/* Side AC / DF */}
<rect x={(A.x + C.x)/2 - 18} y={A.y + 4} width={36} height={20} rx={5} fill="white" fillOpacity={0.92} stroke={sideColor} strokeWidth={0.8} /> <rect
<text x={(A.x + C.x)/2} y={A.y + 18} fill={sideColor} fontSize="13" fontWeight="bold" textAnchor="middle">{Math.round(b1)}</text> x={(A.x + C.x) / 2 - 18}
<rect x={(D.x + F.x)/2 - 18} y={D.y + 4} width={36} height={20} rx={5} fill="white" fillOpacity={0.92} stroke={sideColor} strokeWidth={0.8} /> y={A.y + 4}
<text x={(D.x + F.x)/2} y={D.y + 18} fill={sideColor} fontSize="13" fontWeight="bold" textAnchor="middle">{Math.round(b1 * scale)}</text> width={36}
height={20}
rx={5}
fill="white"
fillOpacity={0.92}
stroke={sideColor}
strokeWidth={0.8}
/>
<text
x={(A.x + C.x) / 2}
y={A.y + 18}
fill={sideColor}
fontSize="13"
fontWeight="bold"
textAnchor="middle"
>
{Math.round(b1)}
</text>
<rect
x={(D.x + F.x) / 2 - 18}
y={D.y + 4}
width={36}
height={20}
rx={5}
fill="white"
fillOpacity={0.92}
stroke={sideColor}
strokeWidth={0.8}
/>
<text
x={(D.x + F.x) / 2}
y={D.y + 18}
fill={sideColor}
fontSize="13"
fontWeight="bold"
textAnchor="middle"
>
{Math.round(b1 * scale)}
</text>
</> </>
)} )}
{mode === 'SSS' && ( {mode === "SSS" && (
<> <>
{/* Side AB / DE */} {/* Side AB / DE */}
<rect x={(A.x + B.x)/2 - 24} y={(A.y + B.y)/2 - 12} width={36} height={20} rx={5} fill="white" fillOpacity={0.92} stroke={sideColor} strokeWidth={0.8} /> <rect
<text x={(A.x + B.x)/2 - 6} y={(A.y + B.y)/2 + 3} fill={sideColor} fontSize="13" fontWeight="bold" textAnchor="middle">{Math.round(c1)}</text> x={(A.x + B.x) / 2 - 24}
<rect x={(D.x + E.x)/2 - 24} y={(D.y + E.y)/2 - 12} width={36} height={20} rx={5} fill="white" fillOpacity={0.92} stroke={sideColor} strokeWidth={0.8} /> y={(A.y + B.y) / 2 - 12}
<text x={(D.x + E.x)/2 - 6} y={(D.y + E.y)/2 + 3} fill={sideColor} fontSize="13" fontWeight="bold" textAnchor="middle">{Math.round(c1 * scale)}</text> width={36}
height={20}
rx={5}
fill="white"
fillOpacity={0.92}
stroke={sideColor}
strokeWidth={0.8}
/>
<text
x={(A.x + B.x) / 2 - 6}
y={(A.y + B.y) / 2 + 3}
fill={sideColor}
fontSize="13"
fontWeight="bold"
textAnchor="middle"
>
{Math.round(c1)}
</text>
<rect
x={(D.x + E.x) / 2 - 24}
y={(D.y + E.y) / 2 - 12}
width={36}
height={20}
rx={5}
fill="white"
fillOpacity={0.92}
stroke={sideColor}
strokeWidth={0.8}
/>
<text
x={(D.x + E.x) / 2 - 6}
y={(D.y + E.y) / 2 + 3}
fill={sideColor}
fontSize="13"
fontWeight="bold"
textAnchor="middle"
>
{Math.round(c1 * scale)}
</text>
{/* Side AC / DF */} {/* Side AC / DF */}
<rect x={(A.x + C.x)/2 - 18} y={A.y + 4} width={36} height={20} rx={5} fill="white" fillOpacity={0.92} stroke={sideColor} strokeWidth={0.8} /> <rect
<text x={(A.x + C.x)/2} y={A.y + 18} fill={sideColor} fontSize="13" fontWeight="bold" textAnchor="middle">{Math.round(b1)}</text> x={(A.x + C.x) / 2 - 18}
<rect x={(D.x + F.x)/2 - 18} y={D.y + 4} width={36} height={20} rx={5} fill="white" fillOpacity={0.92} stroke={sideColor} strokeWidth={0.8} /> y={A.y + 4}
<text x={(D.x + F.x)/2} y={D.y + 18} fill={sideColor} fontSize="13" fontWeight="bold" textAnchor="middle">{Math.round(b1 * scale)}</text> width={36}
height={20}
rx={5}
fill="white"
fillOpacity={0.92}
stroke={sideColor}
strokeWidth={0.8}
/>
<text
x={(A.x + C.x) / 2}
y={A.y + 18}
fill={sideColor}
fontSize="13"
fontWeight="bold"
textAnchor="middle"
>
{Math.round(b1)}
</text>
<rect
x={(D.x + F.x) / 2 - 18}
y={D.y + 4}
width={36}
height={20}
rx={5}
fill="white"
fillOpacity={0.92}
stroke={sideColor}
strokeWidth={0.8}
/>
<text
x={(D.x + F.x) / 2}
y={D.y + 18}
fill={sideColor}
fontSize="13"
fontWeight="bold"
textAnchor="middle"
>
{Math.round(b1 * scale)}
</text>
{/* Side BC / EF */} {/* Side BC / EF */}
<rect x={(B.x + C.x)/2 + 2} y={(B.y + C.y)/2 - 12} width={36} height={20} rx={5} fill="white" fillOpacity={0.92} stroke={sideColor} strokeWidth={0.8} /> <rect
<text x={(B.x + C.x)/2 + 20} y={(B.y + C.y)/2 + 3} fill={sideColor} fontSize="13" fontWeight="bold" textAnchor="middle">{Math.round(a1)}</text> x={(B.x + C.x) / 2 + 2}
<rect x={(E.x + F.x)/2 + 2} y={(E.y + F.y)/2 - 12} width={36} height={20} rx={5} fill="white" fillOpacity={0.92} stroke={sideColor} strokeWidth={0.8} /> y={(B.y + C.y) / 2 - 12}
<text x={(E.x + F.x)/2 + 20} y={(E.y + F.y)/2 + 3} fill={sideColor} fontSize="13" fontWeight="bold" textAnchor="middle">{Math.round(a1 * scale)}</text> width={36}
height={20}
rx={5}
fill="white"
fillOpacity={0.92}
stroke={sideColor}
strokeWidth={0.8}
/>
<text
x={(B.x + C.x) / 2 + 20}
y={(B.y + C.y) / 2 + 3}
fill={sideColor}
fontSize="13"
fontWeight="bold"
textAnchor="middle"
>
{Math.round(a1)}
</text>
<rect
x={(E.x + F.x) / 2 + 2}
y={(E.y + F.y) / 2 - 12}
width={36}
height={20}
rx={5}
fill="white"
fillOpacity={0.92}
stroke={sideColor}
strokeWidth={0.8}
/>
<text
x={(E.x + F.x) / 2 + 20}
y={(E.y + F.y) / 2 + 3}
fill={sideColor}
fontSize="13"
fontWeight="bold"
textAnchor="middle"
>
{Math.round(a1 * scale)}
</text>
</> </>
)} )}
</svg> </svg>
@ -235,46 +548,77 @@ const SimilarityTestsWidget: React.FC = () => {
<div className="bg-rose-50 border border-rose-100 rounded-lg p-4 text-rose-900"> <div className="bg-rose-50 border border-rose-100 rounded-lg p-4 text-rose-900">
<h4 className="font-bold mb-2 flex items-center gap-2 text-lg"> <h4 className="font-bold mb-2 flex items-center gap-2 text-lg">
<span className="w-3 h-3 rounded-full bg-rose-500"></span> <span className="w-3 h-3 rounded-full bg-rose-500"></span>
{mode === 'AA' && "Angle-Angle (AA) Similarity"} {mode === "AA" && "Angle-Angle (AA) Similarity"}
{mode === 'SAS' && "Side-Angle-Side (SAS) Similarity"} {mode === "SAS" && "Side-Angle-Side (SAS) Similarity"}
{mode === 'SSS' && "Side-Side-Side (SSS) Similarity"} {mode === "SSS" && "Side-Side-Side (SSS) Similarity"}
</h4> </h4>
<div className="text-sm font-mono space-y-2"> <div className="text-sm font-mono space-y-2">
{mode === 'AA' && ( {mode === "AA" && (
<> <>
<p className="leading-relaxed">If two angles of one triangle are equal to two angles of another triangle, then the triangles are similar.</p> <p className="leading-relaxed">
If two angles of one triangle are equal to two angles of another
triangle, then the triangles are similar.
</p>
<div className="flex gap-8 mt-2"> <div className="flex gap-8 mt-2">
<div> <div>
<span className="text-xs font-bold text-rose-400 uppercase">First Angle</span> <span className="text-xs font-bold text-rose-400 uppercase">
<p className="font-bold text-lg">A = D = {Math.round(angleA)}°</p> First Angle
</span>
<p className="font-bold text-lg">
A = D = {Math.round(angleA)}°
</p>
</div> </div>
<div> <div>
<span className="text-xs font-bold text-rose-400 uppercase">Second Angle</span> <span className="text-xs font-bold text-rose-400 uppercase">
<p className="font-bold text-lg">B = E = {Math.round(angleB)}°</p> Second Angle
</span>
<p className="font-bold text-lg">
B = E = {Math.round(angleB)}°
</p>
</div> </div>
</div> </div>
</> </>
)} )}
{mode === 'SAS' && ( {mode === "SAS" && (
<> <>
<p className="leading-relaxed">If two sides are proportional and the included angles are equal, the triangles are similar.</p> <p className="leading-relaxed">
If two sides are proportional and the included angles are equal,
the triangles are similar.
</p>
<div className="grid grid-cols-2 gap-4 mt-2"> <div className="grid grid-cols-2 gap-4 mt-2">
<div className="bg-white p-2 rounded border border-rose-100"> <div className="bg-white p-2 rounded border border-rose-100">
<p className="text-xs text-rose-500 font-bold uppercase">Side Ratio (c)</p> <p className="text-xs text-rose-500 font-bold uppercase">
<p>DE / AB = {(c1*scale).toFixed(0)} / {c1.toFixed(0)} = <strong>{scale.toFixed(1)}</strong></p> Side Ratio (c)
</p>
<p>
DE / AB = {(c1 * scale).toFixed(0)} / {c1.toFixed(0)} ={" "}
<strong>{scale.toFixed(1)}</strong>
</p>
</div> </div>
<div className="bg-white p-2 rounded border border-rose-100"> <div className="bg-white p-2 rounded border border-rose-100">
<p className="text-xs text-rose-500 font-bold uppercase">Side Ratio (b)</p> <p className="text-xs text-rose-500 font-bold uppercase">
<p>DF / AC = {(b1*scale).toFixed(0)} / {b1.toFixed(0)} = <strong>{scale.toFixed(1)}</strong></p> Side Ratio (b)
</p>
<p>
DF / AC = {(b1 * scale).toFixed(0)} / {b1.toFixed(0)} ={" "}
<strong>{scale.toFixed(1)}</strong>
</p>
</div> </div>
</div> </div>
<p className="mt-2 font-bold text-rose-800">Included Angle: A = D = {Math.round(angleA)}°</p> <p className="mt-2 font-bold text-rose-800">
Included Angle: A = D = {Math.round(angleA)}°
</p>
</> </>
)} )}
{mode === 'SSS' && ( {mode === "SSS" && (
<> <>
<p className="leading-relaxed">If the corresponding sides of two triangles are proportional, then the triangles are similar.</p> <p className="leading-relaxed">
<p className="bg-white inline-block px-2 py-1 rounded border border-rose-100 font-bold text-rose-600 mb-2">Scale Factor k = {scale.toFixed(1)}</p> If the corresponding sides of two triangles are proportional,
then the triangles are similar.
</p>
<p className="bg-white inline-block px-2 py-1 rounded border border-rose-100 font-bold text-rose-600 mb-2">
Scale Factor k = {scale.toFixed(1)}
</p>
<div className="grid grid-cols-3 gap-2 text-center text-xs"> <div className="grid grid-cols-3 gap-2 text-center text-xs">
<div className="bg-white p-1 rounded"> <div className="bg-white p-1 rounded">
DE/AB = {scale.toFixed(1)} DE/AB = {scale.toFixed(1)}
@ -290,7 +634,8 @@ const SimilarityTestsWidget: React.FC = () => {
)} )}
</div> </div>
<p className="text-xs text-rose-400 mt-4 border-t border-rose-100 pt-2"> <p className="text-xs text-rose-400 mt-4 border-t border-rose-100 pt-2">
Drag vertex <strong>B</strong> on the first triangle to explore different shapes! Drag vertex <strong>B</strong> on the first triangle to explore
different shapes!
</p> </p>
</div> </div>
</div> </div>

View File

@ -1,4 +1,4 @@
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef } from "react";
const SimilarityWidget: React.FC = () => { const SimilarityWidget: React.FC = () => {
const [ratio, setRatio] = useState(0.5); // Position of D along AB (0 to 1) const [ratio, setRatio] = useState(0.5); // Position of D along AB (0 to 1)
@ -13,12 +13,12 @@ const SimilarityWidget: React.FC = () => {
// Calculate D and E based on ratio // Calculate D and E based on ratio
const D = { const D = {
x: A.x + (B.x - A.x) * ratio, x: A.x + (B.x - A.x) * ratio,
y: A.y + (B.y - A.y) * ratio y: A.y + (B.y - A.y) * ratio,
}; };
const E = { const E = {
x: A.x + (C.x - A.x) * ratio, x: A.x + (C.x - A.x) * ratio,
y: A.y + (C.y - A.y) * ratio y: A.y + (C.y - A.y) * ratio,
}; };
const handleInteraction = (clientY: number) => { const handleInteraction = (clientY: number) => {
@ -54,43 +54,123 @@ const SimilarityWidget: React.FC = () => {
className="select-none cursor-ns-resize" className="select-none cursor-ns-resize"
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove} onMouseMove={handleMouseMove}
onMouseUp={() => isDragging.current = false} onMouseUp={() => (isDragging.current = false)}
onMouseLeave={() => isDragging.current = false} onMouseLeave={() => (isDragging.current = false)}
> >
{/* Main Triangle */} {/* Main Triangle */}
<path d={`M ${A.x} ${A.y} L ${B.x} ${B.y} L ${C.x} ${C.y} Z`} fill="none" stroke="#e2e8f0" strokeWidth="2" /> <path
d={`M ${A.x} ${A.y} L ${B.x} ${B.y} L ${C.x} ${C.y} Z`}
fill="none"
stroke="#e2e8f0"
strokeWidth="2"
/>
{/* Filled Top Triangle (Similar) */} {/* Filled Top Triangle (Similar) */}
<path d={`M ${A.x} ${A.y} L ${D.x} ${D.y} L ${E.x} ${E.y} Z`} fill="rgba(244, 63, 94, 0.1)" stroke="none" /> <path
d={`M ${A.x} ${A.y} L ${D.x} ${D.y} L ${E.x} ${E.y} Z`}
fill="rgba(244, 63, 94, 0.1)"
stroke="none"
/>
{/* Parallel Line DE */} {/* Parallel Line DE */}
<line x1={D.x} y1={D.y} x2={E.x} y2={E.y} stroke="#e11d48" strokeWidth="3" /> <line
x1={D.x}
y1={D.y}
x2={E.x}
y2={E.y}
stroke="#e11d48"
strokeWidth="3"
/>
{/* Labels */} {/* Labels */}
<text x={A.x} y={A.y - 10} textAnchor="middle" fontWeight="bold" fill="#64748b">A</text> <text
<text x={B.x - 10} y={B.y} textAnchor="end" fontWeight="bold" fill="#64748b">B</text> x={A.x}
<text x={C.x + 10} y={C.y} textAnchor="start" fontWeight="bold" fill="#64748b">C</text> y={A.y - 10}
<text x={D.x - 10} y={D.y} textAnchor="end" fontWeight="bold" fill="#e11d48">D</text> textAnchor="middle"
<text x={E.x + 10} y={E.y} textAnchor="start" fontWeight="bold" fill="#e11d48">E</text> fontWeight="bold"
fill="#64748b"
>
A
</text>
<text
x={B.x - 10}
y={B.y}
textAnchor="end"
fontWeight="bold"
fill="#64748b"
>
B
</text>
<text
x={C.x + 10}
y={C.y}
textAnchor="start"
fontWeight="bold"
fill="#64748b"
>
C
</text>
<text
x={D.x - 10}
y={D.y}
textAnchor="end"
fontWeight="bold"
fill="#e11d48"
>
D
</text>
<text
x={E.x + 10}
y={E.y}
textAnchor="start"
fontWeight="bold"
fill="#e11d48"
>
E
</text>
{/* Drag Handle */} {/* Drag Handle */}
<circle cx={D.x} cy={D.y} r="6" fill="#e11d48" stroke="white" strokeWidth="2" /> <circle
<circle cx={E.x} cy={E.y} r="6" fill="#e11d48" stroke="white" strokeWidth="2" /> cx={D.x}
cy={D.y}
r="6"
fill="#e11d48"
stroke="white"
strokeWidth="2"
/>
<circle
cx={E.x}
cy={E.y}
r="6"
fill="#e11d48"
stroke="white"
strokeWidth="2"
/>
</svg> </svg>
<div className="flex-1 w-full"> <div className="flex-1 w-full">
<h3 className="text-lg font-bold text-slate-800 mb-4">Triangle Proportionality</h3> <h3 className="text-lg font-bold text-slate-800 mb-4">
<p className="text-sm text-slate-500 mb-6">Drag the red line. Because DE || BC, the small triangle is similar to the large triangle.</p> Triangle Proportionality
</h3>
<p className="text-sm text-slate-500 mb-6">
Drag the red line. Because DE || BC, the small triangle is similar to
the large triangle.
</p>
<div className="space-y-4"> <div className="space-y-4">
<div className="bg-slate-50 p-4 rounded-lg border-l-4 border-rose-500"> <div className="bg-slate-50 p-4 rounded-lg border-l-4 border-rose-500">
<p className="text-xs font-bold text-slate-400 uppercase mb-1">Scale Factor</p> <p className="text-xs font-bold text-slate-400 uppercase mb-1">
<p className="font-mono text-xl text-rose-700">{ratio.toFixed(2)}</p> Scale Factor
</p>
<p className="font-mono text-xl text-rose-700">
{ratio.toFixed(2)}
</p>
</div> </div>
<div className="bg-white border border-slate-200 p-4 rounded-lg shadow-sm"> <div className="bg-white border border-slate-200 p-4 rounded-lg shadow-sm">
<p className="font-mono text-sm mb-2 text-slate-600">Corresponding Sides Ratio:</p> <p className="font-mono text-sm mb-2 text-slate-600">
Corresponding Sides Ratio:
</p>
<div className="flex items-center justify-between font-mono font-bold text-lg"> <div className="flex items-center justify-between font-mono font-bold text-lg">
<div className="text-rose-600">AD / AB</div> <div className="text-rose-600">AD / AB</div>
<div className="text-slate-400">=</div> <div className="text-slate-400">=</div>
@ -101,7 +181,9 @@ const SimilarityWidget: React.FC = () => {
</div> </div>
<div className="bg-white border border-slate-200 p-4 rounded-lg shadow-sm"> <div className="bg-white border border-slate-200 p-4 rounded-lg shadow-sm">
<p className="font-mono text-sm mb-2 text-slate-600">Area Ratio (k²):</p> <p className="font-mono text-sm mb-2 text-slate-600">
Area Ratio (k²):
</p>
<div className="flex items-center justify-between font-mono font-bold text-lg"> <div className="flex items-center justify-between font-mono font-bold text-lg">
<div className="text-rose-600">Area(ADE)</div> <div className="text-rose-600">Area(ADE)</div>
<div className="text-slate-400">/</div> <div className="text-slate-400">/</div>

View File

@ -1,378 +0,0 @@
import React, { useState, useEffect, useRef } from 'react';
import {
ArrowLeft, User, Shield, Clock, BookOpen, Calculator, Award,
TrendingUp, CheckCircle2, Circle, Lock, Eye, EyeOff, AlertCircle,
Check, Sparkles,
} from 'lucide-react';
import { useAuth, UserRecord } from './auth/AuthContext';
import { useProgress } from './progress/ProgressContext';
import { useGoldCoins } from './practice/GoldCoinContext';
import { LESSONS, EBRW_LESSONS } from '../constants';
import Mascot from './Mascot';
// Animated count-up
function useCountUp(target: number, duration = 900) {
const [count, setCount] = useState(0);
const started = useRef(false);
useEffect(() => {
if (started.current) return;
started.current = true;
const startTime = performance.now();
const animate = (now: number) => {
const progress = Math.min((now - startTime) / duration, 1);
const eased = 1 - Math.pow(1 - progress, 2.5);
setCount(Math.round(eased * target));
if (progress < 1) requestAnimationFrame(animate);
};
requestAnimationFrame(animate);
}, [target, duration]);
return count;
}
interface UserDashboardProps {
onExit: () => void;
}
export default function UserDashboard({ onExit }: UserDashboardProps) {
const { username, role, getUserRecord, changePassword, updateDisplayName } = useAuth();
const { getSubjectStats, getLessonStatus } = useProgress();
const { totalCoins, state: coinState } = useGoldCoins();
const user = getUserRecord(username || '');
const mathStats = getSubjectStats('math');
const ebrwStats = getSubjectStats('ebrw');
// Account settings
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [showCurrentPw, setShowCurrentPw] = useState(false);
const [showNewPw, setShowNewPw] = useState(false);
const [pwMsg, setPwMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const [pwLoading, setPwLoading] = useState(false);
const [editName, setEditName] = useState(false);
const [nameInput, setNameInput] = useState(user?.displayName || '');
const [nameSaved, setNameSaved] = useState(false);
const animCoins = useCountUp(totalCoins, 1200);
// Count completed topics across all practice
const topicsAttempted = Object.keys(coinState.topicProgress).length;
// Calculate total accuracy
let totalAttempted = 0;
let totalCorrect = 0;
Object.values(coinState.topicProgress).forEach((tp: any) => {
(['easy', 'medium', 'hard'] as const).forEach(d => {
totalAttempted += tp[d]?.attempted || 0;
totalCorrect += tp[d]?.correct || 0;
});
});
const accuracy = totalAttempted > 0 ? Math.round((totalCorrect / totalAttempted) * 100) : 0;
const handleChangePassword = async (e: React.FormEvent) => {
e.preventDefault();
setPwMsg(null);
if (newPassword !== confirmPassword) {
setPwMsg({ type: 'error', text: 'New passwords do not match.' });
return;
}
setPwLoading(true);
const result = await changePassword(username || '', currentPassword, newPassword);
setPwLoading(false);
if (result.success) {
setPwMsg({ type: 'success', text: 'Password changed successfully!' });
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
} else {
setPwMsg({ type: 'error', text: result.error || 'Failed to change password.' });
}
};
const handleSaveName = () => {
if (username && nameInput.trim()) {
updateDisplayName(username, nameInput.trim());
setEditName(false);
setNameSaved(true);
setTimeout(() => setNameSaved(false), 2000);
}
};
// Progress ring
function ProgressRing({ percent, size = 72, stroke = 6, color }: { percent: number; size?: number; stroke?: number; color: string }) {
const r = (size - stroke) / 2;
const circ = 2 * Math.PI * r;
const offset = circ - (percent / 100) * circ;
return (
<svg width={size} height={size} className="transform -rotate-90">
<circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke="currentColor" strokeWidth={stroke} className="text-slate-100" />
<circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke={color} strokeWidth={stroke}
strokeDasharray={circ} strokeDashoffset={offset} strokeLinecap="round"
className="transition-all duration-1000 ease-out" />
<text x={size / 2} y={size / 2} textAnchor="middle" dominantBaseline="central"
className="text-sm font-bold fill-slate-800 transform rotate-90" style={{ transformOrigin: 'center' }}>
{percent}%
</text>
</svg>
);
}
function StatusIcon({ status }: { status: string }) {
if (status === 'completed') return <CheckCircle2 className="w-4 h-4 text-emerald-500" />;
if (status === 'in_progress') return <Circle className="w-4 h-4 text-blue-400" />;
return <Lock className="w-3.5 h-3.5 text-slate-300" />;
}
return (
<div className="min-h-screen bg-gradient-to-b from-white via-slate-50/50 to-white">
{/* Header */}
<header className="sticky top-0 z-40 glass-nav border-b border-slate-100">
<div className="max-w-5xl mx-auto px-6 h-14 flex items-center justify-between">
<button onClick={onExit} className="flex items-center gap-2 text-sm font-semibold text-slate-500 hover:text-slate-900 transition-colors">
<ArrowLeft className="w-4 h-4" /> Back to Home
</button>
<h1 className="text-sm font-bold text-slate-800">My Dashboard</h1>
<div className="w-20" />
</div>
</header>
<div className="max-w-5xl mx-auto px-6 py-10 space-y-10">
{/* ── Welcome Hero ── */}
<div className="relative bg-gradient-to-br from-cyan-50 via-white to-blue-50 rounded-2xl p-8 border border-cyan-100 overflow-hidden anim-fade-in-up">
<div className="absolute -top-2 -right-2 pointer-events-none select-none opacity-80">
<Mascot pose="waving" height={120} />
</div>
<div className="relative">
<div className="flex items-center gap-3 mb-3">
<div className="w-12 h-12 rounded-xl bg-cyan-100 flex items-center justify-center">
<User className="w-6 h-6 text-cyan-600" />
</div>
<div>
<div className="flex items-center gap-2">
{editName ? (
<div className="flex items-center gap-2">
<input value={nameInput} onChange={e => setNameInput(e.target.value)}
className="text-xl font-bold text-slate-900 bg-white border border-slate-200 rounded-lg px-2 py-0.5 focus:outline-none focus:ring-2 focus:ring-cyan-400 w-48"
autoFocus onKeyDown={e => e.key === 'Enter' && handleSaveName()} />
<button onClick={handleSaveName} className="text-xs font-bold text-cyan-600 hover:text-cyan-800">Save</button>
<button onClick={() => setEditName(false)} className="text-xs text-slate-400 hover:text-slate-600">Cancel</button>
</div>
) : (
<>
<h2 className="text-xl font-bold text-slate-900">{user?.displayName || username}</h2>
<button onClick={() => { setNameInput(user?.displayName || ''); setEditName(true); }}
className="text-xs text-cyan-500 hover:text-cyan-700 font-medium">edit</button>
{nameSaved && <span className="text-xs text-emerald-500 font-medium flex items-center gap-1"><Check className="w-3 h-3" /> Saved</span>}
</>
)}
</div>
<div className="flex items-center gap-2 mt-0.5">
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-widest ${
role === 'admin' ? 'bg-amber-100 text-amber-700' : 'bg-cyan-100 text-cyan-700'
}`}>
{role === 'admin' && <Shield className="w-3 h-3" />}
{role}
</span>
<span className="text-xs text-slate-400">@{username}</span>
</div>
</div>
</div>
{user?.lastLoginAt && (
<p className="text-xs text-slate-400 mt-2 flex items-center gap-1">
<Clock className="w-3 h-3" />
Last login: {new Date(user.lastLoginAt).toLocaleString()}
{user.lastLoginIp && user.lastLoginIp !== 'unknown' && <span className="ml-1">from {user.lastLoginIp}</span>}
</p>
)}
</div>
</div>
{/* ── Stats Overview ── */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 anim-fade-in-up stagger-1">
<div className="bg-white rounded-2xl p-5 border border-slate-200 card-lift text-center">
<p className="text-3xl font-bold text-slate-900 tabular-nums">{mathStats.completed + ebrwStats.completed}</p>
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400 mt-1">Lessons Done</p>
</div>
<div className="bg-white rounded-2xl p-5 border border-slate-200 card-lift text-center">
<p className="text-3xl font-bold text-amber-500 tabular-nums flex items-center justify-center gap-1">
<Award className="w-5 h-5" />{animCoins}
</p>
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400 mt-1">Gold Coins</p>
</div>
<div className="bg-white rounded-2xl p-5 border border-slate-200 card-lift text-center">
<p className="text-3xl font-bold text-emerald-500 tabular-nums">{accuracy}%</p>
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400 mt-1">Accuracy</p>
</div>
<div className="bg-white rounded-2xl p-5 border border-slate-200 card-lift text-center">
<p className="text-3xl font-bold text-blue-500 tabular-nums">{topicsAttempted}</p>
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400 mt-1">Topics Practiced</p>
</div>
</div>
{/* ── Lesson Progress ── */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 anim-fade-in-up stagger-2">
{/* Math */}
<div className="bg-white rounded-2xl p-6 border border-slate-200 card-lift">
<div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-blue-50 flex items-center justify-center">
<Calculator className="w-5 h-5 text-blue-500" />
</div>
<div>
<h3 className="font-bold text-slate-900">Mathematics</h3>
<p className="text-xs text-slate-400">{mathStats.completed}/{mathStats.total} lessons completed</p>
</div>
</div>
<ProgressRing percent={mathStats.percentComplete} color="#3b82f6" />
</div>
<div className="w-full h-2 bg-slate-100 rounded-full overflow-hidden mb-4">
<div className="h-full bg-blue-500 rounded-full transition-all duration-1000" style={{ width: `${mathStats.percentComplete}%` }} />
</div>
<div className="space-y-1 max-h-48 overflow-y-auto pr-1">
{LESSONS.map(l => (
<div key={l.id} className="flex items-center gap-2 py-1 text-xs">
<StatusIcon status={getLessonStatus(l.id, 'math')} />
<span className="text-slate-600 truncate">{l.title}</span>
</div>
))}
</div>
</div>
{/* EBRW */}
<div className="bg-white rounded-2xl p-6 border border-slate-200 card-lift">
<div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-purple-50 flex items-center justify-center">
<BookOpen className="w-5 h-5 text-purple-500" />
</div>
<div>
<h3 className="font-bold text-slate-900">Reading & Writing</h3>
<p className="text-xs text-slate-400">{ebrwStats.completed}/{ebrwStats.total} lessons completed</p>
</div>
</div>
<ProgressRing percent={ebrwStats.percentComplete} color="#a855f7" />
</div>
<div className="w-full h-2 bg-slate-100 rounded-full overflow-hidden mb-4">
<div className="h-full bg-purple-500 rounded-full transition-all duration-1000" style={{ width: `${ebrwStats.percentComplete}%` }} />
</div>
<div className="space-y-1 max-h-48 overflow-y-auto pr-1">
{EBRW_LESSONS.map(l => (
<div key={l.id} className="flex items-center gap-2 py-1 text-xs">
<StatusIcon status={getLessonStatus(l.id, 'ebrw')} />
<span className="text-slate-600 truncate">{l.title}</span>
</div>
))}
</div>
</div>
</div>
{/* ── Practice Performance ── */}
<div className="bg-white rounded-2xl p-6 border border-slate-200 anim-fade-in-up stagger-3">
<div className="flex items-center gap-3 mb-5">
<div className="w-10 h-10 rounded-xl bg-amber-50 flex items-center justify-center">
<TrendingUp className="w-5 h-5 text-amber-500" />
</div>
<div>
<h3 className="font-bold text-slate-900">Practice Performance</h3>
<p className="text-xs text-slate-400">{totalAttempted} questions attempted across {topicsAttempted} topics</p>
</div>
</div>
{topicsAttempted === 0 ? (
<div className="py-8 text-center text-slate-400 text-sm">
<Sparkles className="w-6 h-6 mx-auto mb-2 text-amber-300" />
No practice sessions yet. Start practicing to see your performance!
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{Object.entries(coinState.topicProgress).map(([topicId, tp]: [string, any]) => {
const easy = tp.easy || { attempted: 0, correct: 0 };
const medium = tp.medium || { attempted: 0, correct: 0 };
const hard = tp.hard || { attempted: 0, correct: 0 };
const total = easy.attempted + medium.attempted + hard.attempted;
const correct = easy.correct + medium.correct + hard.correct;
const acc = total > 0 ? Math.round((correct / total) * 100) : 0;
return (
<div key={topicId} className="border border-slate-100 rounded-xl p-3 hover:border-slate-200 transition-colors">
<p className="text-xs font-semibold text-slate-700 truncate mb-2">{topicId}</p>
<div className="flex items-center justify-between text-[10px] text-slate-400 mb-1">
<span>{correct}/{total} correct</span>
<span className={`font-bold ${acc >= 70 ? 'text-emerald-500' : acc >= 40 ? 'text-amber-500' : 'text-rose-500'}`}>{acc}%</span>
</div>
<div className="w-full h-1.5 bg-slate-100 rounded-full overflow-hidden">
<div className={`h-full rounded-full ${acc >= 70 ? 'bg-emerald-400' : acc >= 40 ? 'bg-amber-400' : 'bg-rose-400'}`} style={{ width: `${acc}%` }} />
</div>
<div className="flex gap-3 mt-2 text-[10px] text-slate-400">
<span>E: {easy.correct}/{easy.attempted}</span>
<span>M: {medium.correct}/{medium.attempted}</span>
<span>H: {hard.correct}/{hard.attempted}</span>
</div>
</div>
);
})}
</div>
)}
</div>
{/* ── Account Settings ── */}
<div className="bg-white rounded-2xl p-6 border border-slate-200 anim-fade-in-up stagger-4">
<div className="flex items-center gap-3 mb-5">
<div className="w-10 h-10 rounded-xl bg-slate-100 flex items-center justify-center">
<Lock className="w-5 h-5 text-slate-500" />
</div>
<div>
<h3 className="font-bold text-slate-900">Change Password</h3>
<p className="text-xs text-slate-400">Update your account password</p>
</div>
</div>
<form onSubmit={handleChangePassword} className="max-w-sm space-y-3">
{pwMsg && (
<div className={`flex items-center gap-2 p-3 rounded-xl text-sm ${
pwMsg.type === 'success' ? 'bg-emerald-50 border border-emerald-200 text-emerald-700' : 'bg-rose-50 border border-rose-200 text-rose-700'
}`}>
{pwMsg.type === 'success' ? <Check className="w-4 h-4 shrink-0" /> : <AlertCircle className="w-4 h-4 shrink-0" />}
{pwMsg.text}
</div>
)}
<div className="relative">
<label className="block text-xs font-semibold text-slate-600 mb-1">Current Password</label>
<input type={showCurrentPw ? 'text' : 'password'} value={currentPassword} onChange={e => setCurrentPassword(e.target.value)}
className="w-full px-3 py-2 pr-9 text-sm border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-cyan-400" required />
<button type="button" onClick={() => setShowCurrentPw(!showCurrentPw)} className="absolute right-3 top-7 text-slate-400 hover:text-slate-600">
{showCurrentPw ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
<div className="relative">
<label className="block text-xs font-semibold text-slate-600 mb-1">New Password</label>
<input type={showNewPw ? 'text' : 'password'} value={newPassword} onChange={e => setNewPassword(e.target.value)}
className="w-full px-3 py-2 pr-9 text-sm border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-cyan-400" required minLength={4} />
<button type="button" onClick={() => setShowNewPw(!showNewPw)} className="absolute right-3 top-7 text-slate-400 hover:text-slate-600">
{showNewPw ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
<div>
<label className="block text-xs font-semibold text-slate-600 mb-1">Confirm New Password</label>
<input type="password" value={confirmPassword} onChange={e => setConfirmPassword(e.target.value)}
className="w-full px-3 py-2 text-sm border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-cyan-400" required minLength={4} />
</div>
<button type="submit" disabled={pwLoading}
className="px-5 py-2 bg-slate-900 text-white text-sm font-bold rounded-xl hover:bg-slate-700 transition-all btn-primary disabled:opacity-50">
{pwLoading ? 'Changing...' : 'Change Password'}
</button>
</form>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,36 @@
import { useEffect } from 'react';
/**
* Observes all `.scroll-reveal` elements in the DOM and adds the `revealed`
* class when they scroll into view. Works with the stagger-1 … stagger-10
* utility classes defined in index.css for sequenced entrance animations.
*
* Call once at the top of a lesson component:
* useScrollReveal();
*/
export default function useScrollReveal() {
useEffect(() => {
const sel = [
'.scroll-reveal:not(.revealed)',
'.scroll-reveal-left:not(.revealed)',
'.scroll-reveal-right:not(.revealed)',
'.scroll-reveal-scale:not(.revealed)',
].join(',');
const els = document.querySelectorAll(sel);
if (!els.length) return;
const obs = new IntersectionObserver(
entries =>
entries.forEach(e => {
if (e.isIntersecting) {
e.target.classList.add('revealed');
obs.unobserve(e.target);
}
}),
{ threshold: 0.12, rootMargin: '0px 0px -60px 0px' },
);
els.forEach(el => obs.observe(el));
return () => obs.disconnect();
}, []);
}

View File

@ -1,8 +1,7 @@
import * as React from "react" import * as React from "react";
import { Slot } from "@radix-ui/react-slot" import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "../../lib/utils";
import { cn } from "@/lib/utils"
const badgeVariants = cva( const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", "inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
@ -22,8 +21,8 @@ const badgeVariants = cva(
defaultVariants: { defaultVariants: {
variant: "default", variant: "default",
}, },
} },
) );
function Badge({ function Badge({
className, className,
@ -32,7 +31,7 @@ function Badge({
...props ...props
}: React.ComponentProps<"span"> & }: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) { VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span" const Comp = asChild ? Slot : "span";
return ( return (
<Comp <Comp
@ -40,7 +39,7 @@ function Badge({
className={cn(badgeVariants({ variant }), className)} className={cn(badgeVariants({ variant }), className)}
{...props} {...props}
/> />
) );
} }
export { Badge, badgeVariants } export { Badge, badgeVariants };

View File

@ -2,7 +2,7 @@ import * as React from "react";
import { Slot } from "@radix-ui/react-slot"; import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority"; import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils"; import { cn } from "../../lib/utils";
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",

View File

@ -1,6 +1,6 @@
import * as React from "react" import * as React from "react";
import { cn } from "@/lib/utils" import { cn } from "../../lib/utils";
function Card({ className, ...props }: React.ComponentProps<"div">) { function Card({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
@ -8,11 +8,11 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
data-slot="card" data-slot="card"
className={cn( className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm", "bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function CardHeader({ className, ...props }: React.ComponentProps<"div">) { function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
@ -21,11 +21,11 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
data-slot="card-header" data-slot="card-header"
className={cn( className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6", "@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function CardTitle({ className, ...props }: React.ComponentProps<"div">) { function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
@ -35,7 +35,7 @@ function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
className={cn("leading-none font-semibold", className)} className={cn("leading-none font-semibold", className)}
{...props} {...props}
/> />
) );
} }
function CardDescription({ className, ...props }: React.ComponentProps<"div">) { function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
@ -45,7 +45,7 @@ function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
className={cn("text-muted-foreground text-sm", className)} className={cn("text-muted-foreground text-sm", className)}
{...props} {...props}
/> />
) );
} }
function CardAction({ className, ...props }: React.ComponentProps<"div">) { function CardAction({ className, ...props }: React.ComponentProps<"div">) {
@ -54,11 +54,11 @@ function CardAction({ className, ...props }: React.ComponentProps<"div">) {
data-slot="card-action" data-slot="card-action"
className={cn( className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end", "col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function CardContent({ className, ...props }: React.ComponentProps<"div">) { function CardContent({ className, ...props }: React.ComponentProps<"div">) {
@ -68,7 +68,7 @@ function CardContent({ className, ...props }: React.ComponentProps<"div">) {
className={cn("px-6", className)} className={cn("px-6", className)}
{...props} {...props}
/> />
) );
} }
function CardFooter({ className, ...props }: React.ComponentProps<"div">) { function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
@ -78,7 +78,7 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
className={cn("flex items-center px-6 [.border-t]:pt-6", className)} className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props} {...props}
/> />
) );
} }
export { export {
@ -89,4 +89,4 @@ export {
CardAction, CardAction,
CardDescription, CardDescription,
CardContent, CardContent,
} };

View File

@ -1,43 +1,43 @@
import * as React from "react" import * as React from "react";
import useEmblaCarousel, { import useEmblaCarousel, {
type UseEmblaCarouselType, type UseEmblaCarouselType,
} from "embla-carousel-react" } from "embla-carousel-react";
import { ArrowLeft, ArrowRight } from "lucide-react" import { ArrowLeft, ArrowRight } from "lucide-react";
import { cn } from "@/lib/utils" import { cn } from "../../lib/utils";
import { Button } from "@/components/ui/button" import { Button } from "./button";
type CarouselApi = UseEmblaCarouselType[1] type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel> type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
type CarouselOptions = UseCarouselParameters[0] type CarouselOptions = UseCarouselParameters[0];
type CarouselPlugin = UseCarouselParameters[1] type CarouselPlugin = UseCarouselParameters[1];
type CarouselProps = { type CarouselProps = {
opts?: CarouselOptions opts?: CarouselOptions;
plugins?: CarouselPlugin plugins?: CarouselPlugin;
orientation?: "horizontal" | "vertical" orientation?: "horizontal" | "vertical";
setApi?: (api: CarouselApi) => void setApi?: (api: CarouselApi) => void;
} };
type CarouselContextProps = { type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0] carouselRef: ReturnType<typeof useEmblaCarousel>[0];
api: ReturnType<typeof useEmblaCarousel>[1] api: ReturnType<typeof useEmblaCarousel>[1];
scrollPrev: () => void scrollPrev: () => void;
scrollNext: () => void scrollNext: () => void;
canScrollPrev: boolean canScrollPrev: boolean;
canScrollNext: boolean canScrollNext: boolean;
} & CarouselProps } & CarouselProps;
const CarouselContext = React.createContext<CarouselContextProps | null>(null) const CarouselContext = React.createContext<CarouselContextProps | null>(null);
function useCarousel() { function useCarousel() {
const context = React.useContext(CarouselContext) const context = React.useContext(CarouselContext);
if (!context) { if (!context) {
throw new Error("useCarousel must be used within a <Carousel />") throw new Error("useCarousel must be used within a <Carousel />");
} }
return context return context;
} }
function Carousel({ function Carousel({
@ -54,53 +54,53 @@ function Carousel({
...opts, ...opts,
axis: orientation === "horizontal" ? "x" : "y", axis: orientation === "horizontal" ? "x" : "y",
}, },
plugins plugins,
) );
const [canScrollPrev, setCanScrollPrev] = React.useState(false) const [canScrollPrev, setCanScrollPrev] = React.useState(false);
const [canScrollNext, setCanScrollNext] = React.useState(false) const [canScrollNext, setCanScrollNext] = React.useState(false);
const onSelect = React.useCallback((api: CarouselApi) => { const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) return if (!api) return;
setCanScrollPrev(api.canScrollPrev()) setCanScrollPrev(api.canScrollPrev());
setCanScrollNext(api.canScrollNext()) setCanScrollNext(api.canScrollNext());
}, []) }, []);
const scrollPrev = React.useCallback(() => { const scrollPrev = React.useCallback(() => {
api?.scrollPrev() api?.scrollPrev();
}, [api]) }, [api]);
const scrollNext = React.useCallback(() => { const scrollNext = React.useCallback(() => {
api?.scrollNext() api?.scrollNext();
}, [api]) }, [api]);
const handleKeyDown = React.useCallback( const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => { (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") { if (event.key === "ArrowLeft") {
event.preventDefault() event.preventDefault();
scrollPrev() scrollPrev();
} else if (event.key === "ArrowRight") { } else if (event.key === "ArrowRight") {
event.preventDefault() event.preventDefault();
scrollNext() scrollNext();
} }
}, },
[scrollPrev, scrollNext] [scrollPrev, scrollNext],
) );
React.useEffect(() => { React.useEffect(() => {
if (!api || !setApi) return if (!api || !setApi) return;
setApi(api) setApi(api);
}, [api, setApi]) }, [api, setApi]);
React.useEffect(() => { React.useEffect(() => {
if (!api) return if (!api) return;
onSelect(api) onSelect(api);
api.on("reInit", onSelect) api.on("reInit", onSelect);
api.on("select", onSelect) api.on("select", onSelect);
return () => { return () => {
api?.off("select", onSelect) api?.off("select", onSelect);
} };
}, [api, onSelect]) }, [api, onSelect]);
return ( return (
<CarouselContext.Provider <CarouselContext.Provider
@ -127,11 +127,11 @@ function Carousel({
{children} {children}
</div> </div>
</CarouselContext.Provider> </CarouselContext.Provider>
) );
} }
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) { function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
const { carouselRef, orientation } = useCarousel() const { carouselRef, orientation } = useCarousel();
return ( return (
<div <div
@ -143,16 +143,16 @@ function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
className={cn( className={cn(
"flex", "flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col", orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className className,
)} )}
{...props} {...props}
/> />
</div> </div>
) );
} }
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) { function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
const { orientation } = useCarousel() const { orientation } = useCarousel();
return ( return (
<div <div
@ -162,11 +162,11 @@ function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
className={cn( className={cn(
"min-w-0 shrink-0 grow-0 basis-full", "min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4", orientation === "horizontal" ? "pl-4" : "pt-4",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function CarouselPrevious({ function CarouselPrevious({
@ -175,7 +175,7 @@ function CarouselPrevious({
size = "icon", size = "icon",
...props ...props
}: React.ComponentProps<typeof Button>) { }: React.ComponentProps<typeof Button>) {
const { orientation, scrollPrev, canScrollPrev } = useCarousel() const { orientation, scrollPrev, canScrollPrev } = useCarousel();
return ( return (
<Button <Button
@ -187,7 +187,7 @@ function CarouselPrevious({
orientation === "horizontal" orientation === "horizontal"
? "top-1/2 -left-12 -translate-y-1/2" ? "top-1/2 -left-12 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90", : "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className className,
)} )}
disabled={!canScrollPrev} disabled={!canScrollPrev}
onClick={scrollPrev} onClick={scrollPrev}
@ -196,7 +196,7 @@ function CarouselPrevious({
<ArrowLeft /> <ArrowLeft />
<span className="sr-only">Previous slide</span> <span className="sr-only">Previous slide</span>
</Button> </Button>
) );
} }
function CarouselNext({ function CarouselNext({
@ -205,7 +205,7 @@ function CarouselNext({
size = "icon", size = "icon",
...props ...props
}: React.ComponentProps<typeof Button>) { }: React.ComponentProps<typeof Button>) {
const { orientation, scrollNext, canScrollNext } = useCarousel() const { orientation, scrollNext, canScrollNext } = useCarousel();
return ( return (
<Button <Button
@ -217,7 +217,7 @@ function CarouselNext({
orientation === "horizontal" orientation === "horizontal"
? "top-1/2 -right-12 -translate-y-1/2" ? "top-1/2 -right-12 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90", : "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className className,
)} )}
disabled={!canScrollNext} disabled={!canScrollNext}
onClick={scrollNext} onClick={scrollNext}
@ -226,7 +226,7 @@ function CarouselNext({
<ArrowRight /> <ArrowRight />
<span className="sr-only">Next slide</span> <span className="sr-only">Next slide</span>
</Button> </Button>
) );
} }
export { export {
@ -236,4 +236,4 @@ export {
CarouselItem, CarouselItem,
CarouselPrevious, CarouselPrevious,
CarouselNext, CarouselNext,
} };

View File

@ -1,32 +1,32 @@
import * as React from "react" import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog" import * as DialogPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react" import { XIcon } from "lucide-react";
import { cn } from "@/lib/utils" import { cn } from "../../lib/utils";
import { Button } from "@/components/ui/button" import { Button } from "./button";
function Dialog({ function Dialog({
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) { }: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} /> return <DialogPrimitive.Root data-slot="dialog" {...props} />;
} }
function DialogTrigger({ function DialogTrigger({
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) { }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} /> return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
} }
function DialogPortal({ function DialogPortal({
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) { }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} /> return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
} }
function DialogClose({ function DialogClose({
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) { }: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} /> return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
} }
function DialogOverlay({ function DialogOverlay({
@ -38,11 +38,11 @@ function DialogOverlay({
data-slot="dialog-overlay" data-slot="dialog-overlay"
className={cn( className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50", "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function DialogContent({ function DialogContent({
@ -51,7 +51,7 @@ function DialogContent({
showCloseButton = true, showCloseButton = true,
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & { }: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean showCloseButton?: boolean;
}) { }) {
return ( return (
<DialogPortal data-slot="dialog-portal"> <DialogPortal data-slot="dialog-portal">
@ -60,7 +60,7 @@ function DialogContent({
data-slot="dialog-content" data-slot="dialog-content"
className={cn( className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg", "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
className className,
)} )}
{...props} {...props}
> >
@ -76,7 +76,7 @@ function DialogContent({
)} )}
</DialogPrimitive.Content> </DialogPrimitive.Content>
</DialogPortal> </DialogPortal>
) );
} }
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
@ -86,7 +86,7 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
className={cn("flex flex-col gap-2 text-center sm:text-left", className)} className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props} {...props}
/> />
) );
} }
function DialogFooter({ function DialogFooter({
@ -95,14 +95,14 @@ function DialogFooter({
children, children,
...props ...props
}: React.ComponentProps<"div"> & { }: React.ComponentProps<"div"> & {
showCloseButton?: boolean showCloseButton?: boolean;
}) { }) {
return ( return (
<div <div
data-slot="dialog-footer" data-slot="dialog-footer"
className={cn( className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className className,
)} )}
{...props} {...props}
> >
@ -113,7 +113,7 @@ function DialogFooter({
</DialogPrimitive.Close> </DialogPrimitive.Close>
)} )}
</div> </div>
) );
} }
function DialogTitle({ function DialogTitle({
@ -126,7 +126,7 @@ function DialogTitle({
className={cn("text-lg leading-none font-semibold", className)} className={cn("text-lg leading-none font-semibold", className)}
{...props} {...props}
/> />
) );
} }
function DialogDescription({ function DialogDescription({
@ -139,7 +139,7 @@ function DialogDescription({
className={cn("text-muted-foreground text-sm", className)} className={cn("text-muted-foreground text-sm", className)}
{...props} {...props}
/> />
) );
} }
export { export {
@ -153,4 +153,4 @@ export {
DialogPortal, DialogPortal,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} };

View File

@ -1,30 +1,30 @@
import * as React from "react" import * as React from "react";
import { Drawer as DrawerPrimitive } from "vaul" import { Drawer as DrawerPrimitive } from "vaul";
import { cn } from "@/lib/utils" import { cn } from "../../lib/utils";
function Drawer({ function Drawer({
...props ...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) { }: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} /> return <DrawerPrimitive.Root data-slot="drawer" {...props} />;
} }
function DrawerTrigger({ function DrawerTrigger({
...props ...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) { }: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} /> return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />;
} }
function DrawerPortal({ function DrawerPortal({
...props ...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) { }: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} /> return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />;
} }
function DrawerClose({ function DrawerClose({
...props ...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) { }: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} /> return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />;
} }
function DrawerOverlay({ function DrawerOverlay({
@ -36,11 +36,11 @@ function DrawerOverlay({
data-slot="drawer-overlay" data-slot="drawer-overlay"
className={cn( className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50", "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function DrawerContent({ function DrawerContent({
@ -59,7 +59,7 @@ function DrawerContent({
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t", "data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm", "data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm", "data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
className className,
)} )}
{...props} {...props}
> >
@ -67,7 +67,7 @@ function DrawerContent({
{children} {children}
</DrawerPrimitive.Content> </DrawerPrimitive.Content>
</DrawerPortal> </DrawerPortal>
) );
} }
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) { function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
@ -76,11 +76,11 @@ function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
data-slot="drawer-header" data-slot="drawer-header"
className={cn( className={cn(
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left", "flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) { function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
@ -90,7 +90,7 @@ function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
className={cn("mt-auto flex flex-col gap-2 p-4", className)} className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props} {...props}
/> />
) );
} }
function DrawerTitle({ function DrawerTitle({
@ -103,7 +103,7 @@ function DrawerTitle({
className={cn("text-foreground font-semibold", className)} className={cn("text-foreground font-semibold", className)}
{...props} {...props}
/> />
) );
} }
function DrawerDescription({ function DrawerDescription({
@ -116,7 +116,7 @@ function DrawerDescription({
className={cn("text-muted-foreground text-sm", className)} className={cn("text-muted-foreground text-sm", className)}
{...props} {...props}
/> />
) );
} }
export { export {
@ -130,4 +130,4 @@ export {
DrawerFooter, DrawerFooter,
DrawerTitle, DrawerTitle,
DrawerDescription, DrawerDescription,
} };

View File

@ -1,13 +1,13 @@
import * as React from "react" import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import { cn } from "@/lib/utils" import { cn } from "../../lib/utils";
function DropdownMenu({ function DropdownMenu({
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} /> return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
} }
function DropdownMenuPortal({ function DropdownMenuPortal({
@ -15,7 +15,7 @@ function DropdownMenuPortal({
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return ( return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} /> <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
) );
} }
function DropdownMenuTrigger({ function DropdownMenuTrigger({
@ -26,7 +26,7 @@ function DropdownMenuTrigger({
data-slot="dropdown-menu-trigger" data-slot="dropdown-menu-trigger"
{...props} {...props}
/> />
) );
} }
function DropdownMenuContent({ function DropdownMenuContent({
@ -41,12 +41,12 @@ function DropdownMenuContent({
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md", "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className className,
)} )}
{...props} {...props}
/> />
</DropdownMenuPrimitive.Portal> </DropdownMenuPrimitive.Portal>
) );
} }
function DropdownMenuGroup({ function DropdownMenuGroup({
@ -54,7 +54,7 @@ function DropdownMenuGroup({
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return ( return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} /> <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
) );
} }
function DropdownMenuItem({ function DropdownMenuItem({
@ -63,8 +63,8 @@ function DropdownMenuItem({
variant = "default", variant = "default",
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & { }: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean inset?: boolean;
variant?: "default" | "destructive" variant?: "default" | "destructive";
}) { }) {
return ( return (
<DropdownMenuPrimitive.Item <DropdownMenuPrimitive.Item
@ -73,11 +73,11 @@ function DropdownMenuItem({
data-variant={variant} data-variant={variant}
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function DropdownMenuCheckboxItem({ function DropdownMenuCheckboxItem({
@ -91,7 +91,7 @@ function DropdownMenuCheckboxItem({
data-slot="dropdown-menu-checkbox-item" data-slot="dropdown-menu-checkbox-item"
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className className,
)} )}
checked={checked} checked={checked}
{...props} {...props}
@ -103,7 +103,7 @@ function DropdownMenuCheckboxItem({
</span> </span>
{children} {children}
</DropdownMenuPrimitive.CheckboxItem> </DropdownMenuPrimitive.CheckboxItem>
) );
} }
function DropdownMenuRadioGroup({ function DropdownMenuRadioGroup({
@ -114,7 +114,7 @@ function DropdownMenuRadioGroup({
data-slot="dropdown-menu-radio-group" data-slot="dropdown-menu-radio-group"
{...props} {...props}
/> />
) );
} }
function DropdownMenuRadioItem({ function DropdownMenuRadioItem({
@ -127,7 +127,7 @@ function DropdownMenuRadioItem({
data-slot="dropdown-menu-radio-item" data-slot="dropdown-menu-radio-item"
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className className,
)} )}
{...props} {...props}
> >
@ -138,7 +138,7 @@ function DropdownMenuRadioItem({
</span> </span>
{children} {children}
</DropdownMenuPrimitive.RadioItem> </DropdownMenuPrimitive.RadioItem>
) );
} }
function DropdownMenuLabel({ function DropdownMenuLabel({
@ -146,7 +146,7 @@ function DropdownMenuLabel({
inset, inset,
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & { }: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean inset?: boolean;
}) { }) {
return ( return (
<DropdownMenuPrimitive.Label <DropdownMenuPrimitive.Label
@ -154,11 +154,11 @@ function DropdownMenuLabel({
data-inset={inset} data-inset={inset}
className={cn( className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8", "px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function DropdownMenuSeparator({ function DropdownMenuSeparator({
@ -171,7 +171,7 @@ function DropdownMenuSeparator({
className={cn("bg-border -mx-1 my-1 h-px", className)} className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props} {...props}
/> />
) );
} }
function DropdownMenuShortcut({ function DropdownMenuShortcut({
@ -183,17 +183,17 @@ function DropdownMenuShortcut({
data-slot="dropdown-menu-shortcut" data-slot="dropdown-menu-shortcut"
className={cn( className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest", "text-muted-foreground ml-auto text-xs tracking-widest",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function DropdownMenuSub({ function DropdownMenuSub({
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} /> return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
} }
function DropdownMenuSubTrigger({ function DropdownMenuSubTrigger({
@ -202,7 +202,7 @@ function DropdownMenuSubTrigger({
children, children,
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & { }: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean inset?: boolean;
}) { }) {
return ( return (
<DropdownMenuPrimitive.SubTrigger <DropdownMenuPrimitive.SubTrigger
@ -210,14 +210,14 @@ function DropdownMenuSubTrigger({
data-inset={inset} data-inset={inset}
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className className,
)} )}
{...props} {...props}
> >
{children} {children}
<ChevronRightIcon className="ml-auto size-4" /> <ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger> </DropdownMenuPrimitive.SubTrigger>
) );
} }
function DropdownMenuSubContent({ function DropdownMenuSubContent({
@ -229,11 +229,11 @@ function DropdownMenuSubContent({
data-slot="dropdown-menu-sub-content" data-slot="dropdown-menu-sub-content"
className={cn( className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg", "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
export { export {
@ -252,4 +252,4 @@ export {
DropdownMenuSub, DropdownMenuSub,
DropdownMenuSubTrigger, DropdownMenuSubTrigger,
DropdownMenuSubContent, DropdownMenuSubContent,
} };

View File

@ -1,9 +1,9 @@
import { useMemo } from "react" import { useMemo } from "react";
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils" import { cn } from "../../lib/utils";
import { Label } from "@/components/ui/label" import { Label } from "./label";
import { Separator } from "@/components/ui/separator" import { Separator } from "./separator";
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) { function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return ( return (
@ -12,11 +12,11 @@ function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
className={cn( className={cn(
"flex flex-col gap-6", "flex flex-col gap-6",
"has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3", "has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function FieldLegend({ function FieldLegend({
@ -32,11 +32,11 @@ function FieldLegend({
"mb-3 font-medium", "mb-3 font-medium",
"data-[variant=legend]:text-base", "data-[variant=legend]:text-base",
"data-[variant=label]:text-sm", "data-[variant=label]:text-sm",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) { function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
@ -44,12 +44,12 @@ function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
<div <div
data-slot="field-group" data-slot="field-group"
className={cn( className={cn(
"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4", "group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
const fieldVariants = cva( const fieldVariants = cva(
@ -73,8 +73,8 @@ const fieldVariants = cva(
defaultVariants: { defaultVariants: {
orientation: "vertical", orientation: "vertical",
}, },
} },
) );
function Field({ function Field({
className, className,
@ -89,7 +89,7 @@ function Field({
className={cn(fieldVariants({ orientation }), className)} className={cn(fieldVariants({ orientation }), className)}
{...props} {...props}
/> />
) );
} }
function FieldContent({ className, ...props }: React.ComponentProps<"div">) { function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
@ -98,11 +98,11 @@ function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
data-slot="field-content" data-slot="field-content"
className={cn( className={cn(
"group/field-content flex flex-1 flex-col gap-1.5 leading-snug", "group/field-content flex flex-1 flex-col gap-1.5 leading-snug",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function FieldLabel({ function FieldLabel({
@ -114,13 +114,13 @@ function FieldLabel({
data-slot="field-label" data-slot="field-label"
className={cn( className={cn(
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50", "group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4", "has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border *:data-[slot=field]:p-4",
"has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10", "has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) { function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
@ -129,11 +129,11 @@ function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
data-slot="field-label" data-slot="field-label"
className={cn( className={cn(
"flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50", "flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) { function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
@ -141,14 +141,14 @@ function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
<p <p
data-slot="field-description" data-slot="field-description"
className={cn( className={cn(
"text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance", "text-muted-foreground text-sm leading-normal font-normal group-has-data-[orientation=horizontal]/field:text-balance",
"last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5", "last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5",
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4", "[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function FieldSeparator({ function FieldSeparator({
@ -156,7 +156,7 @@ function FieldSeparator({
className, className,
...props ...props
}: React.ComponentProps<"div"> & { }: React.ComponentProps<"div"> & {
children?: React.ReactNode children?: React.ReactNode;
}) { }) {
return ( return (
<div <div
@ -164,7 +164,7 @@ function FieldSeparator({
data-content={!!children} data-content={!!children}
className={cn( className={cn(
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2", "relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
className className,
)} )}
{...props} {...props}
> >
@ -178,7 +178,7 @@ function FieldSeparator({
</span> </span>
)} )}
</div> </div>
) );
} }
function FieldError({ function FieldError({
@ -187,37 +187,37 @@ function FieldError({
errors, errors,
...props ...props
}: React.ComponentProps<"div"> & { }: React.ComponentProps<"div"> & {
errors?: Array<{ message?: string } | undefined> errors?: Array<{ message?: string } | undefined>;
}) { }) {
const content = useMemo(() => { const content = useMemo(() => {
if (children) { if (children) {
return children return children;
} }
if (!errors?.length) { if (!errors?.length) {
return null return null;
} }
const uniqueErrors = [ const uniqueErrors = [
...new Map(errors.map((error) => [error?.message, error])).values(), ...new Map(errors.map((error) => [error?.message, error])).values(),
] ];
if (uniqueErrors?.length == 1) { if (uniqueErrors?.length == 1) {
return uniqueErrors[0]?.message return uniqueErrors[0]?.message;
} }
return ( return (
<ul className="ml-4 flex list-disc flex-col gap-1"> <ul className="ml-4 flex list-disc flex-col gap-1">
{uniqueErrors.map( {uniqueErrors.map(
(error, index) => (error, index) =>
error?.message && <li key={index}>{error.message}</li> error?.message && <li key={index}>{error.message}</li>,
)} )}
</ul> </ul>
) );
}, [children, errors]) }, [children, errors]);
if (!content) { if (!content) {
return null return null;
} }
return ( return (
@ -229,7 +229,7 @@ function FieldError({
> >
{content} {content}
</div> </div>
) );
} }
export { export {
@ -243,4 +243,4 @@ export {
FieldSet, FieldSet,
FieldContent, FieldContent,
FieldTitle, FieldTitle,
} };

View File

@ -1,6 +1,5 @@
import * as React from "react" import * as React from "react";
import { cn } from "../../lib/utils";
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) { function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return ( return (
@ -11,11 +10,11 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
export { Input } export { Input };

View File

@ -1,7 +1,7 @@
import * as React from "react" import * as React from "react";
import { Label as LabelPrimitive } from "radix-ui" import { Label as LabelPrimitive } from "radix-ui";
import { cn } from "@/lib/utils" import { cn } from "../../lib/utils";
function Label({ function Label({
className, className,
@ -12,11 +12,11 @@ function Label({
data-slot="label" data-slot="label"
className={cn( className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50", "flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
export { Label } export { Label };

View File

@ -1,9 +1,9 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import { Separator as SeparatorPrimitive } from "radix-ui" import { Separator as SeparatorPrimitive } from "radix-ui";
import { cn } from "@/lib/utils" import { cn } from "../../lib/utils";
function Separator({ function Separator({
className, className,
@ -18,11 +18,11 @@ function Separator({
orientation={orientation} orientation={orientation}
className={cn( className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px", "bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
export { Separator } export { Separator };

View File

@ -1,31 +1,31 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import { XIcon } from "lucide-react" import { XIcon } from "lucide-react";
import { Dialog as SheetPrimitive } from "radix-ui" import { Dialog as SheetPrimitive } from "radix-ui";
import { cn } from "@/lib/utils" import { cn } from "../../lib/utils";
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) { function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} /> return <SheetPrimitive.Root data-slot="sheet" {...props} />;
} }
function SheetTrigger({ function SheetTrigger({
...props ...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) { }: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} /> return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
} }
function SheetClose({ function SheetClose({
...props ...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) { }: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} /> return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
} }
function SheetPortal({ function SheetPortal({
...props ...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) { }: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} /> return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
} }
function SheetOverlay({ function SheetOverlay({
@ -37,11 +37,11 @@ function SheetOverlay({
data-slot="sheet-overlay" data-slot="sheet-overlay"
className={cn( className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50", "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function SheetContent({ function SheetContent({
@ -51,8 +51,8 @@ function SheetContent({
showCloseButton = true, showCloseButton = true,
...props ...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & { }: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left" side?: "top" | "right" | "bottom" | "left";
showCloseButton?: boolean showCloseButton?: boolean;
}) { }) {
return ( return (
<SheetPortal> <SheetPortal>
@ -69,7 +69,7 @@ function SheetContent({
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b", "data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" && side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t", "data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className className,
)} )}
{...props} {...props}
> >
@ -82,7 +82,7 @@ function SheetContent({
)} )}
</SheetPrimitive.Content> </SheetPrimitive.Content>
</SheetPortal> </SheetPortal>
) );
} }
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
@ -92,7 +92,7 @@ function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
className={cn("flex flex-col gap-1.5 p-4", className)} className={cn("flex flex-col gap-1.5 p-4", className)}
{...props} {...props}
/> />
) );
} }
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) { function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
@ -102,7 +102,7 @@ function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
className={cn("mt-auto flex flex-col gap-2 p-4", className)} className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props} {...props}
/> />
) );
} }
function SheetTitle({ function SheetTitle({
@ -115,7 +115,7 @@ function SheetTitle({
className={cn("text-foreground font-semibold", className)} className={cn("text-foreground font-semibold", className)}
{...props} {...props}
/> />
) );
} }
function SheetDescription({ function SheetDescription({
@ -128,7 +128,7 @@ function SheetDescription({
className={cn("text-muted-foreground text-sm", className)} className={cn("text-muted-foreground text-sm", className)}
{...props} {...props}
/> />
) );
} }
export { export {
@ -140,4 +140,4 @@ export {
SheetFooter, SheetFooter,
SheetTitle, SheetTitle,
SheetDescription, SheetDescription,
} };

View File

@ -1,56 +1,56 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority";
import { PanelLeftIcon } from "lucide-react" import { PanelLeftIcon } from "lucide-react";
import { Slot } from "radix-ui" import { Slot } from "radix-ui";
import { useIsMobile } from "@/hooks/use-mobile" import { useIsMobile } from "../../hooks/use-mobile";
import { cn } from "@/lib/utils" import { cn } from "../../lib/utils";
import { Button } from "@/components/ui/button" import { Button } from "./button";
import { Input } from "@/components/ui/input" import { Input } from "./input";
import { Separator } from "@/components/ui/separator" import { Separator } from "./separator";
import { import {
Sheet, Sheet,
SheetContent, SheetContent,
SheetDescription, SheetDescription,
SheetHeader, SheetHeader,
SheetTitle, SheetTitle,
} from "@/components/ui/sheet" } from "./sheet";
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "./skeleton";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip" } from "./tooltip";
const SIDEBAR_COOKIE_NAME = "sidebar_state" const SIDEBAR_COOKIE_NAME = "sidebar_state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = "16rem" const SIDEBAR_WIDTH = "16rem";
const SIDEBAR_WIDTH_MOBILE = "18rem" const SIDEBAR_WIDTH_MOBILE = "18rem";
const SIDEBAR_WIDTH_ICON = "3rem" const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b" const SIDEBAR_KEYBOARD_SHORTCUT = "b";
type SidebarContextProps = { type SidebarContextProps = {
state: "expanded" | "collapsed" state: "expanded" | "collapsed";
open: boolean open: boolean;
setOpen: (open: boolean) => void setOpen: (open: boolean) => void;
openMobile: boolean openMobile: boolean;
setOpenMobile: (open: boolean) => void setOpenMobile: (open: boolean) => void;
isMobile: boolean isMobile: boolean;
toggleSidebar: () => void toggleSidebar: () => void;
} };
const SidebarContext = React.createContext<SidebarContextProps | null>(null) const SidebarContext = React.createContext<SidebarContextProps | null>(null);
function useSidebar() { function useSidebar() {
const context = React.useContext(SidebarContext) const context = React.useContext(SidebarContext);
if (!context) { if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.") throw new Error("useSidebar must be used within a SidebarProvider.");
} }
return context return context;
} }
function SidebarProvider({ function SidebarProvider({
@ -62,36 +62,36 @@ function SidebarProvider({
children, children,
...props ...props
}: React.ComponentProps<"div"> & { }: React.ComponentProps<"div"> & {
defaultOpen?: boolean defaultOpen?: boolean;
open?: boolean open?: boolean;
onOpenChange?: (open: boolean) => void onOpenChange?: (open: boolean) => void;
}) { }) {
const isMobile = useIsMobile() const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false) const [openMobile, setOpenMobile] = React.useState(false);
// This is the internal state of the sidebar. // This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component. // We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen) const [_open, _setOpen] = React.useState(defaultOpen);
const open = openProp ?? _open const open = openProp ?? _open;
const setOpen = React.useCallback( const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => { (value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value const openState = typeof value === "function" ? value(open) : value;
if (setOpenProp) { if (setOpenProp) {
setOpenProp(openState) setOpenProp(openState);
} else { } else {
_setOpen(openState) _setOpen(openState);
} }
// This sets the cookie to keep the sidebar state. // This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}` document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
}, },
[setOpenProp, open] [setOpenProp, open],
) );
// Helper to toggle the sidebar. // Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => { const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open) return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
}, [isMobile, setOpen, setOpenMobile]) }, [isMobile, setOpen, setOpenMobile]);
// Adds a keyboard shortcut to toggle the sidebar. // Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => { React.useEffect(() => {
@ -100,18 +100,18 @@ function SidebarProvider({
event.key === SIDEBAR_KEYBOARD_SHORTCUT && event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey) (event.metaKey || event.ctrlKey)
) { ) {
event.preventDefault() event.preventDefault();
toggleSidebar() toggleSidebar();
}
} }
};
window.addEventListener("keydown", handleKeyDown) window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown) return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar]) }, [toggleSidebar]);
// We add a state so that we can do data-state="expanded" or "collapsed". // We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes. // This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed" const state = open ? "expanded" : "collapsed";
const contextValue = React.useMemo<SidebarContextProps>( const contextValue = React.useMemo<SidebarContextProps>(
() => ({ () => ({
@ -123,8 +123,8 @@ function SidebarProvider({
setOpenMobile, setOpenMobile,
toggleSidebar, toggleSidebar,
}), }),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar] [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
) );
return ( return (
<SidebarContext.Provider value={contextValue}> <SidebarContext.Provider value={contextValue}>
@ -140,7 +140,7 @@ function SidebarProvider({
} }
className={cn( className={cn(
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full", "group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
className className,
)} )}
{...props} {...props}
> >
@ -148,7 +148,7 @@ function SidebarProvider({
</div> </div>
</TooltipProvider> </TooltipProvider>
</SidebarContext.Provider> </SidebarContext.Provider>
) );
} }
function Sidebar({ function Sidebar({
@ -159,11 +159,11 @@ function Sidebar({
children, children,
...props ...props
}: React.ComponentProps<"div"> & { }: React.ComponentProps<"div"> & {
side?: "left" | "right" side?: "left" | "right";
variant?: "sidebar" | "floating" | "inset" variant?: "sidebar" | "floating" | "inset";
collapsible?: "offcanvas" | "icon" | "none" collapsible?: "offcanvas" | "icon" | "none";
}) { }) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar() const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
if (collapsible === "none") { if (collapsible === "none") {
return ( return (
@ -171,13 +171,13 @@ function Sidebar({
data-slot="sidebar" data-slot="sidebar"
className={cn( className={cn(
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col", "bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
className className,
)} )}
{...props} {...props}
> >
{children} {children}
</div> </div>
) );
} }
if (isMobile) { if (isMobile) {
@ -202,7 +202,7 @@ function Sidebar({
<div className="flex h-full w-full flex-col">{children}</div> <div className="flex h-full w-full flex-col">{children}</div>
</SheetContent> </SheetContent>
</Sheet> </Sheet>
) );
} }
return ( return (
@ -223,7 +223,7 @@ function Sidebar({
"group-data-[side=right]:rotate-180", "group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset" variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]" ? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)" : "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
)} )}
/> />
<div <div
@ -237,20 +237,27 @@ function Sidebar({
variant === "floating" || variant === "inset" variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]" ? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l", : "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className className,
)} )}
{...props} {...props}
> >
<div <div
data-sidebar="sidebar" data-sidebar="sidebar"
data-slot="sidebar-inner" data-slot="sidebar-inner"
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm" className={cn(
"flex h-full w-full flex-col",
// For the custom floating pill sidebar we render our own card,
// so keep this container visually transparent.
variant === "floating"
? "bg-transparent"
: "bg-sidebar group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm",
)}
> >
{children} {children}
</div> </div>
</div> </div>
</div> </div>
) );
} }
function SidebarTrigger({ function SidebarTrigger({
@ -258,7 +265,7 @@ function SidebarTrigger({
onClick, onClick,
...props ...props
}: React.ComponentProps<typeof Button>) { }: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar() const { toggleSidebar } = useSidebar();
return ( return (
<Button <Button
@ -268,19 +275,19 @@ function SidebarTrigger({
size="icon" size="icon"
className={cn("size-7", className)} className={cn("size-7", className)}
onClick={(event) => { onClick={(event) => {
onClick?.(event) onClick?.(event);
toggleSidebar() toggleSidebar();
}} }}
{...props} {...props}
> >
<PanelLeftIcon /> <PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span> <span className="sr-only">Toggle Sidebar</span>
</Button> </Button>
) );
} }
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) { function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar() const { toggleSidebar } = useSidebar();
return ( return (
<button <button
@ -291,17 +298,17 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
onClick={toggleSidebar} onClick={toggleSidebar}
title="Toggle Sidebar" title="Toggle Sidebar"
className={cn( className={cn(
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex", "hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-0.5 sm:flex",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize", "in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize", "[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full", "hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2", "[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2", "[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) { function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
@ -311,11 +318,11 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
className={cn( className={cn(
"bg-background relative flex w-full flex-1 flex-col", "bg-background relative flex w-full flex-1 flex-col",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2", "md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function SidebarInput({ function SidebarInput({
@ -329,7 +336,7 @@ function SidebarInput({
className={cn("bg-background h-8 w-full shadow-none", className)} className={cn("bg-background h-8 w-full shadow-none", className)}
{...props} {...props}
/> />
) );
} }
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) { function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
@ -340,7 +347,7 @@ function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
className={cn("flex flex-col gap-2 p-2", className)} className={cn("flex flex-col gap-2 p-2", className)}
{...props} {...props}
/> />
) );
} }
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) { function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
@ -351,7 +358,7 @@ function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
className={cn("flex flex-col gap-2 p-2", className)} className={cn("flex flex-col gap-2 p-2", className)}
{...props} {...props}
/> />
) );
} }
function SidebarSeparator({ function SidebarSeparator({
@ -365,7 +372,7 @@ function SidebarSeparator({
className={cn("bg-sidebar-border mx-2 w-auto", className)} className={cn("bg-sidebar-border mx-2 w-auto", className)}
{...props} {...props}
/> />
) );
} }
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) { function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
@ -375,11 +382,11 @@ function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
data-sidebar="content" data-sidebar="content"
className={cn( className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden", "flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) { function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
@ -390,7 +397,7 @@ function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
className={cn("relative flex w-full min-w-0 flex-col p-2", className)} className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props} {...props}
/> />
) );
} }
function SidebarGroupLabel({ function SidebarGroupLabel({
@ -398,7 +405,7 @@ function SidebarGroupLabel({
asChild = false, asChild = false,
...props ...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) { }: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "div" const Comp = asChild ? Slot.Root : "div";
return ( return (
<Comp <Comp
@ -407,11 +414,11 @@ function SidebarGroupLabel({
className={cn( className={cn(
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", "text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0", "group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function SidebarGroupAction({ function SidebarGroupAction({
@ -419,7 +426,7 @@ function SidebarGroupAction({
asChild = false, asChild = false,
...props ...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) { }: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "button" const Comp = asChild ? Slot.Root : "button";
return ( return (
<Comp <Comp
@ -430,11 +437,11 @@ function SidebarGroupAction({
// Increases the hit area of the button on mobile. // Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden", "after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden", "group-data-[collapsible=icon]:hidden",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function SidebarGroupContent({ function SidebarGroupContent({
@ -448,7 +455,7 @@ function SidebarGroupContent({
className={cn("w-full text-sm", className)} className={cn("w-full text-sm", className)}
{...props} {...props}
/> />
) );
} }
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) { function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
@ -459,7 +466,7 @@ function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
className={cn("flex w-full min-w-0 flex-col gap-1", className)} className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props} {...props}
/> />
) );
} }
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) { function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
@ -470,7 +477,7 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
className={cn("group/menu-item relative", className)} className={cn("group/menu-item relative", className)}
{...props} {...props}
/> />
) );
} }
const sidebarMenuButtonVariants = cva( const sidebarMenuButtonVariants = cva(
@ -492,8 +499,8 @@ const sidebarMenuButtonVariants = cva(
variant: "default", variant: "default",
size: "default", size: "default",
}, },
} },
) );
function SidebarMenuButton({ function SidebarMenuButton({
asChild = false, asChild = false,
@ -504,12 +511,12 @@ function SidebarMenuButton({
className, className,
...props ...props
}: React.ComponentProps<"button"> & { }: React.ComponentProps<"button"> & {
asChild?: boolean asChild?: boolean;
isActive?: boolean isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent> tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>) { } & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot.Root : "button" const Comp = asChild ? Slot.Root : "button";
const { isMobile, state } = useSidebar() const { isMobile, state } = useSidebar();
const button = ( const button = (
<Comp <Comp
@ -520,16 +527,16 @@ function SidebarMenuButton({
className={cn(sidebarMenuButtonVariants({ variant, size }), className)} className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props} {...props}
/> />
) );
if (!tooltip) { if (!tooltip) {
return button return button;
} }
if (typeof tooltip === "string") { if (typeof tooltip === "string") {
tooltip = { tooltip = {
children: tooltip, children: tooltip,
} };
} }
return ( return (
@ -542,7 +549,7 @@ function SidebarMenuButton({
{...tooltip} {...tooltip}
/> />
</Tooltip> </Tooltip>
) );
} }
function SidebarMenuAction({ function SidebarMenuAction({
@ -551,10 +558,10 @@ function SidebarMenuAction({
showOnHover = false, showOnHover = false,
...props ...props
}: React.ComponentProps<"button"> & { }: React.ComponentProps<"button"> & {
asChild?: boolean asChild?: boolean;
showOnHover?: boolean showOnHover?: boolean;
}) { }) {
const Comp = asChild ? Slot.Root : "button" const Comp = asChild ? Slot.Root : "button";
return ( return (
<Comp <Comp
@ -570,11 +577,11 @@ function SidebarMenuAction({
"group-data-[collapsible=icon]:hidden", "group-data-[collapsible=icon]:hidden",
showOnHover && showOnHover &&
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0", "peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function SidebarMenuBadge({ function SidebarMenuBadge({
@ -592,11 +599,11 @@ function SidebarMenuBadge({
"peer-data-[size=default]/menu-button:top-1.5", "peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5", "peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden", "group-data-[collapsible=icon]:hidden",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function SidebarMenuSkeleton({ function SidebarMenuSkeleton({
@ -604,12 +611,12 @@ function SidebarMenuSkeleton({
showIcon = false, showIcon = false,
...props ...props
}: React.ComponentProps<"div"> & { }: React.ComponentProps<"div"> & {
showIcon?: boolean showIcon?: boolean;
}) { }) {
// Random width between 50 to 90%. // Random width between 50 to 90%.
const width = React.useMemo(() => { const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%` return `${Math.floor(Math.random() * 40) + 50}%`;
}, []) }, []);
return ( return (
<div <div
@ -634,7 +641,7 @@ function SidebarMenuSkeleton({
} }
/> />
</div> </div>
) );
} }
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) { function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
@ -645,11 +652,11 @@ function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
className={cn( className={cn(
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5", "border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden", "group-data-[collapsible=icon]:hidden",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function SidebarMenuSubItem({ function SidebarMenuSubItem({
@ -663,7 +670,7 @@ function SidebarMenuSubItem({
className={cn("group/menu-sub-item relative", className)} className={cn("group/menu-sub-item relative", className)}
{...props} {...props}
/> />
) );
} }
function SidebarMenuSubButton({ function SidebarMenuSubButton({
@ -673,11 +680,11 @@ function SidebarMenuSubButton({
className, className,
...props ...props
}: React.ComponentProps<"a"> & { }: React.ComponentProps<"a"> & {
asChild?: boolean asChild?: boolean;
size?: "sm" | "md" size?: "sm" | "md";
isActive?: boolean isActive?: boolean;
}) { }) {
const Comp = asChild ? Slot.Root : "a" const Comp = asChild ? Slot.Root : "a";
return ( return (
<Comp <Comp
@ -691,11 +698,11 @@ function SidebarMenuSubButton({
size === "sm" && "text-xs", size === "sm" && "text-xs",
size === "md" && "text-sm", size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden", "group-data-[collapsible=icon]:hidden",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
export { export {
@ -723,4 +730,4 @@ export {
SidebarSeparator, SidebarSeparator,
SidebarTrigger, SidebarTrigger,
useSidebar, useSidebar,
} };

View File

@ -1,4 +1,4 @@
import { cn } from "@/lib/utils" import { cn } from "../../lib/utils";
function Skeleton({ className, ...props }: React.ComponentProps<"div">) { function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
@ -7,7 +7,7 @@ function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
className={cn("bg-accent animate-pulse rounded-md", className)} className={cn("bg-accent animate-pulse rounded-md", className)}
{...props} {...props}
/> />
) );
} }
export { Skeleton } export { Skeleton };

View File

@ -1,6 +1,6 @@
import * as React from "react" import * as React from "react";
import { cn } from "@/lib/utils" import { cn } from "../../lib/utils";
function Table({ className, ...props }: React.ComponentProps<"table">) { function Table({ className, ...props }: React.ComponentProps<"table">) {
return ( return (
@ -14,7 +14,7 @@ function Table({ className, ...props }: React.ComponentProps<"table">) {
{...props} {...props}
/> />
</div> </div>
) );
} }
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
@ -24,7 +24,7 @@ function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
className={cn("[&_tr]:border-b", className)} className={cn("[&_tr]:border-b", className)}
{...props} {...props}
/> />
) );
} }
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
@ -34,7 +34,7 @@ function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
className={cn("[&_tr:last-child]:border-0", className)} className={cn("[&_tr:last-child]:border-0", className)}
{...props} {...props}
/> />
) );
} }
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
@ -43,11 +43,11 @@ function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
data-slot="table-footer" data-slot="table-footer"
className={cn( className={cn(
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0", "bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function TableRow({ className, ...props }: React.ComponentProps<"tr">) { function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
@ -56,11 +56,11 @@ function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
data-slot="table-row" data-slot="table-row"
className={cn( className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors", "hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function TableHead({ className, ...props }: React.ComponentProps<"th">) { function TableHead({ className, ...props }: React.ComponentProps<"th">) {
@ -69,11 +69,11 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
data-slot="table-head" data-slot="table-head"
className={cn( className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", "text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function TableCell({ className, ...props }: React.ComponentProps<"td">) { function TableCell({ className, ...props }: React.ComponentProps<"td">) {
@ -82,11 +82,11 @@ function TableCell({ className, ...props }: React.ComponentProps<"td">) {
data-slot="table-cell" data-slot="table-cell"
className={cn( className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", "p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function TableCaption({ function TableCaption({
@ -99,7 +99,7 @@ function TableCaption({
className={cn("text-muted-foreground mt-4 text-sm", className)} className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props} {...props}
/> />
) );
} }
export { export {
@ -111,4 +111,4 @@ export {
TableRow, TableRow,
TableCell, TableCell,
TableCaption, TableCaption,
} };

View File

@ -1,7 +1,7 @@
import * as React from "react" import * as React from "react";
import { Tooltip as TooltipPrimitive } from "radix-ui" import { Tooltip as TooltipPrimitive } from "radix-ui";
import { cn } from "@/lib/utils" import { cn } from "../../lib/utils";
function TooltipProvider({ function TooltipProvider({
delayDuration = 0, delayDuration = 0,
@ -13,19 +13,19 @@ function TooltipProvider({
delayDuration={delayDuration} delayDuration={delayDuration}
{...props} {...props}
/> />
) );
} }
function Tooltip({ function Tooltip({
...props ...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) { }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} /> return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
} }
function TooltipTrigger({ function TooltipTrigger({
...props ...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) { }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} /> return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
} }
function TooltipContent({ function TooltipContent({
@ -41,7 +41,7 @@ function TooltipContent({
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance", "bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className className,
)} )}
{...props} {...props}
> >
@ -49,7 +49,7 @@ function TooltipContent({
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" /> <TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content> </TooltipPrimitive.Content>
</TooltipPrimitive.Portal> </TooltipPrimitive.Portal>
) );
} }
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

View File

@ -1,344 +0,0 @@
import type { QuestArc } from "../types/quest";
// ─── QUEST DATA ───────────────────────────────────────────────────────────────
// Replace each node's `progress` and `status` with live API values.
// Everything else (titles, flavour, rewards) is content — edit freely.
export const QUEST_ARCS: QuestArc[] = [
// ── ARC 1: The Calm Seas ──────────────────────────────────────────────────
{
id: "east_blue",
name: "The Calm Seas",
subtitle: "Every great voyage begins at shore",
emoji: "🌊",
accentColor: "#0ea5e9",
accentDark: "#0369a1",
bgFrom: "#0c4a6e",
bgTo: "#075985",
nodes: [
{
id: "eb_1",
title: "First Steps",
flavourText:
'"I\'ll become the greatest sailor who ever lived!" — Every legend begins with a single step.',
islandName: "Hawthorn Cove",
emoji: "🏝️",
requirement: {
type: "questions",
target: 10,
label: "questions answered",
},
progress: 10,
status: "completed",
reward: { xp: 50, title: "Cabin Hand" },
},
{
id: "eb_2",
title: "Cast Off",
flavourText:
'"The sea doesn\'t care who you were — only who you become." Chart your course.',
islandName: "Redmast Port",
emoji: "⚓",
requirement: {
type: "sessions",
target: 3,
label: "practice sessions",
},
progress: 3,
status: "completed",
reward: { xp: 75 },
},
{
id: "eb_3",
title: "The Tangerine Coast",
flavourText:
'"Even alone, I protect my crew." Keep your streak burning bright.',
islandName: "Citrus Bay",
emoji: "🍊",
requirement: { type: "streak", target: 3, label: "day streak" },
progress: 3,
status: "completed",
reward: {
xp: 100,
item: "streak_shield",
itemLabel: "Streak Shield ×1",
},
},
{
id: "eb_4",
title: "The Fog Village",
flavourText:
'"I\'ve fooled everyone — except myself." Prove yourself across new territory.',
islandName: "Mistholm Village",
emoji: "🌿",
requirement: { type: "topics", target: 5, label: "topics practiced" },
progress: 3,
status: "claimable",
reward: { xp: 125, title: "Deckhand" },
},
{
id: "eb_5",
title: "The Floating Galley",
flavourText:
'"Nothing happened." Cut through the noise with razor accuracy.',
islandName: "The Iron Kitchen",
emoji: "🍖",
requirement: {
type: "accuracy",
target: 75,
label: "% accuracy (any session)",
},
progress: 58,
status: "active",
reward: {
xp: 150,
item: "xp_boost",
itemLabel: "2× XP Boost (1 session)",
},
},
{
id: "eb_6",
title: "The Sharkfin Strait",
flavourText:
'"This is my dream!" Conquer the Calm Seas before the Grand Voyage beckons.',
islandName: "Sharkfin Strait",
emoji: "🦈",
requirement: {
type: "questions",
target: 100,
label: "questions answered",
},
progress: 0,
status: "locked",
reward: { xp: 300, title: "First Mate" },
},
],
},
// ── ARC 2: The Amber Wastes ───────────────────────────────────────────────
{
id: "alabasta",
name: "The Amber Wastes",
subtitle: "Through the desert sands, to glory",
emoji: "🏜️",
accentColor: "#f59e0b",
accentDark: "#b45309",
bgFrom: "#78350f",
bgTo: "#92400e",
nodes: [
{
id: "al_1",
title: "Crossing the Mirrorlake",
flavourText:
'"A true sailor never makes excuses after losing." Enter the warzone.',
islandName: "Mirrorlake Basin",
emoji: "💧",
requirement: {
type: "sessions",
target: 5,
label: "practice sessions",
},
progress: 5,
status: "completed",
reward: { xp: 150 },
},
{
id: "al_2",
title: "The Sand March",
flavourText:
'"They underestimated us." Grind through the scorching heat.',
islandName: "The Amber Dunes",
emoji: "🌵",
requirement: {
type: "questions",
target: 50,
label: "questions answered",
},
progress: 50,
status: "completed",
reward: {
xp: 175,
item: "xp_boost",
itemLabel: "1.5× XP Boost (1 session)",
},
},
{
id: "al_3",
title: "The Sunstone Palace",
flavourText: '"I refuse to let my crew fall!" Climb the leaderboard.',
islandName: "Sunstone City",
emoji: "🏰",
requirement: {
type: "leaderboard",
target: 10,
label: "leaderboard rank",
},
progress: 22,
status: "active",
reward: { xp: 250, title: "Corsair" },
},
{
id: "al_4",
title: "Blades in the Bazaar",
flavourText:
'"I\'ll cut through iron." Maintain brutal accuracy under pressure.',
islandName: "Bazaar Streets",
emoji: "⚔️",
requirement: {
type: "accuracy",
target: 85,
label: "% accuracy (any session)",
},
progress: 0,
status: "locked",
reward: {
xp: 300,
item: "streak_shield",
itemLabel: "Streak Shield ×2",
},
},
{
id: "al_5",
title: "The Warlord Falls",
flavourText:
"\"I'm not dying here, partner.\" Prove you're worthy of the Wastes.",
islandName: "The Throne Dune",
emoji: "👑",
requirement: { type: "streak", target: 7, label: "day streak" },
progress: 0,
status: "locked",
reward: { xp: 400, title: "Corsair" },
},
{
id: "al_6",
title: "The Princess's Farewell",
flavourText:
'"Even if our paths split, you\'ll always sail with my crew." The arc is complete.',
islandName: "Mirrorlake Harbour",
emoji: "🌅",
requirement: { type: "xp", target: 1000, label: "total XP earned" },
progress: 0,
status: "locked",
reward: { xp: 500, title: "Sea Emperor" },
},
],
},
// ── ARC 3: The Sky Reaches ────────────────────────────────────────────────
{
id: "skypiea",
name: "The Sky Reaches",
subtitle: "Ascend to the island above the clouds",
emoji: "☁️",
accentColor: "#a855f7",
accentDark: "#7c3aed",
bgFrom: "#3b0764",
bgTo: "#4c1d95",
nodes: [
{
id: "sk_1",
title: "The Skyward Torrent",
flavourText:
'"The sky island is real!" Believe it — launch yourself upward.',
islandName: "Upper Cloudreach",
emoji: "🌤️",
requirement: {
type: "topics",
target: 3,
label: "topics at 70%+ accuracy",
},
progress: 0,
status: "locked",
reward: { xp: 200 },
},
{
id: "sk_2",
title: "The Trial of Storms",
flavourText:
'"Follow the wind, follow the stars." Navigate every corner of the cloudscape.',
islandName: "The Tempest Ordeal",
emoji: "🎯",
requirement: {
type: "topics",
target: 8,
label: "distinct topics practiced",
},
progress: 0,
status: "locked",
reward: {
xp: 250,
item: "xp_boost",
itemLabel: "2× XP Boost (2 sessions)",
},
},
{
id: "sk_3",
title: "The Sky God's Wrath",
flavourText: '"I am the heavens." Are you good enough to defy a deity?',
islandName: "The Celestial Ark",
emoji: "⚡",
requirement: {
type: "accuracy",
target: 90,
label: "% accuracy (any session)",
},
progress: 0,
status: "locked",
reward: { xp: 400, title: "Sea Emperor" },
},
{
id: "sk_4",
title: "The Ancient Bell",
flavourText:
'"I hear the torrent calling." Ring the bell — make history echo.',
islandName: "The Cloudvine Spire",
emoji: "🔔",
requirement: {
type: "questions",
target: 250,
label: "questions answered",
},
progress: 0,
status: "locked",
reward: {
xp: 500,
item: "streak_shield",
itemLabel: "Streak Shield ×3",
},
},
{
id: "sk_5",
title: "The Gilded Ruins",
flavourText:
'"THE GREAT CAPTAIN WAS HERE." Touch the treasure that all legends sought.',
islandName: "Aureveil",
emoji: "💰",
requirement: { type: "xp", target: 3000, label: "total XP earned" },
progress: 0,
status: "locked",
reward: { xp: 750, title: "Grand Captain" },
},
{
id: "sk_6",
title: "The Grand Captain",
flavourText:
'"This is my treasure!" You\'ve reached the summit — your target score awaits.',
islandName: "The Last Isle",
emoji: "🏴‍☠️",
requirement: {
type: "sessions",
target: 30,
label: "total sessions completed",
},
progress: 0,
status: "locked",
reward: {
xp: 1000,
title: "Grand Captain",
item: "xp_boost",
itemLabel: "Permanent 1.2× XP",
},
},
],
},
];

View File

@ -1,4 +1,4 @@
import type { PracticeQuestion } from "../../types/lesson"; import { type PracticeQuestion } from "../../types/lesson";
export const CENTRAL_IDEAS_EASY: PracticeQuestion[] = [ export const CENTRAL_IDEAS_EASY: PracticeQuestion[] = [
{ {

View File

@ -151,8 +151,8 @@ export const RW_TOPICS: TopicRegistry = {
id: "transitions", id: "transitions",
name: "Transitions", name: "Transitions",
section: "rw", section: "rw",
category: "Standard English", category: "Expression of Ideas",
color: "purple", color: "rose",
questions: { questions: {
easy: TRANSITIONS_EASY, easy: TRANSITIONS_EASY,
medium: TRANSITIONS_MEDIUM, medium: TRANSITIONS_MEDIUM,

View File

@ -1,13 +0,0 @@
import { QUEST_ARCS } from "../data/questData";
// Returns the player's current crew rank, or a default if none earned yet
export function getCrewRank(arcs = QUEST_ARCS): string {
const earned = arcs
.flatMap((a) => a.nodes)
.filter((n) => n.status === "completed" && n.reward.title)
.map((n) => n.reward.title!);
// Return the last one — questData is ordered by difficulty,
// so the last earned title is always the highest rank
return earned.at(-1) ?? "Cabin Hand";
}

View File

@ -4,7 +4,7 @@ import { useSatExam } from "../stores/useSatExam";
export const useSatTimer = () => { export const useSatTimer = () => {
const phase = useSatExam((s) => s.phase); const phase = useSatExam((s) => s.phase);
const getRemainingTime = useSatExam((s) => s.getRemainingTime); const getRemainingTime = useSatExam((s) => s.getRemainingTime);
const startBreak = useSatExam((s) => s.startBreak);
const skipBreak = useSatExam((s) => s.skipBreak); const skipBreak = useSatExam((s) => s.skipBreak);
const finishExam = useSatExam((s) => s.finishExam); const finishExam = useSatExam((s) => s.finishExam);

View File

@ -1,32 +0,0 @@
// src/pages/ErrorPage.tsx
import { useRouteError, isRouteErrorResponse } from "react-router-dom";
export default function ErrorPage() {
const error = useRouteError();
console.error(error);
let title = "Something went wrong";
let message = "An unexpected error occurred.";
if (isRouteErrorResponse(error)) {
title = `${error.status} ${error.statusText}`;
message = error.data?.message || message;
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="bg-white shadow-xl rounded-2xl p-8 max-w-md text-center">
<h1 className="text-2xl font-bold text-red-600 mb-4">{title}</h1>
<p className="text-gray-600 mb-6">{message}</p>
<button
onClick={() => (window.location.href = "/")}
className="px-4 py-2 bg-black text-white rounded-lg"
>
Go Home
</button>
</div>
</div>
);
}

View File

@ -2,181 +2,363 @@ import { useState, useEffect } from "react";
import type { FormEvent } from "react"; import type { FormEvent } from "react";
import { useNavigate, useLocation } from "react-router-dom"; import { useNavigate, useLocation } from "react-router-dom";
import { useAuthStore } from "../../stores/authStore"; import { useAuthStore } from "../../stores/authStore";
import { Loader2, Mail, Lock } from "lucide-react"; import { Loader2, Mail, Lock, Target, Clock, BarChart2 } from "lucide-react";
interface LocationState { interface LocationState {
from?: { pathname: string }; from?: { pathname: string };
} }
const DOTS = [
{ size: 12, color: "#f97316", top: "8%", left: "6%", delay: "0s" },
{ size: 7, color: "#a855f7", top: "22%", left: "3%", delay: "1.2s" },
{ size: 9, color: "#22c55e", top: "65%", left: "5%", delay: "0.6s" },
{ size: 8, color: "#f43f5e", top: "80%", left: "8%", delay: "2.1s" },
{ size: 12, color: "#3b82f6", top: "10%", right: "6%", delay: "1.8s" },
{ size: 7, color: "#eab308", top: "40%", right: "3%", delay: "0.9s" },
{ size: 10, color: "#a855f7", top: "72%", right: "5%", delay: "0.4s" },
{ size: 8, color: "#f97316", top: "55%", right: "8%", delay: "1.5s" },
];
const STYLES = ` const STYLES = `
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap');
.lg-screen { *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
.lg-root {
min-height: 100vh; min-height: 100vh;
background: #fffbf4; display: flex;
font-family: 'Nunito', sans-serif; font-family: 'Nunito', sans-serif;
background: #fffbf4;
}
/* ─── LEFT PANEL ─── */
.lg-left {
position: relative; position: relative;
width: 50%;
min-height: 100vh;
background: linear-gradient(160deg, #060d1f 0%, #0f2044 40%, #0e3476 75%, #1a56c4 100%);
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
padding: 3rem 3.5rem;
overflow: hidden; overflow: hidden;
flex-shrink: 0;
}
/* Animated grid */
.lg-grid {
position: absolute; inset: 0; pointer-events: none;
background-image:
linear-gradient(rgba(99,179,255,0.06) 1px, transparent 1px),
linear-gradient(90deg, rgba(99,179,255,0.06) 1px, transparent 1px);
background-size: 52px 52px;
animation: gridScroll 25s linear infinite;
}
@keyframes gridScroll {
from { background-position: 0 0; }
to { background-position: 52px 52px; }
}
/* Radial glow spots */
.lg-glow {
position: absolute; pointer-events: none; border-radius: 50%;
filter: blur(60px);
}
.lg-glow-1 { width: 380px; height: 380px; background: #1d4ed8; opacity: 0.35; top: -120px; right: -80px; animation: glowPulse 8s ease-in-out infinite; }
.lg-glow-2 { width: 280px; height: 280px; background: #0ea5e9; opacity: 0.2; bottom: -60px; left: -60px; animation: glowPulse 10s ease-in-out infinite 2s; }
.lg-glow-3 { width: 200px; height: 200px; background: #f97316; opacity: 0.12; top: 55%; left: 55%; animation: glowPulse 12s ease-in-out infinite 1s; }
@keyframes glowPulse {
0%,100% { transform: scale(1); opacity: 0.2; }
50% { transform: scale(1.2); opacity: 0.35; }
}
/* ── Floating score card ── */
.lg-score-card {
position: absolute;
top: 9%; right: 6%;
width: 162px;
background: linear-gradient(135deg, rgba(255,255,255,0.11), rgba(255,255,255,0.05));
border: 1px solid rgba(255,255,255,0.18);
border-radius: 20px;
padding: 1.1rem 1.2rem 1rem;
backdrop-filter: blur(16px);
box-shadow: 0 8px 32px rgba(0,0,0,0.35), inset 0 1px 0 rgba(255,255,255,0.15);
animation: floatA 6s ease-in-out infinite;
z-index: 2;
}
@keyframes floatA {
0%,100% { transform: translateY(0) rotate(-1.5deg); }
50% { transform: translateY(-14px) rotate(0.5deg); }
}
.lg-score-tag {
font-size: 0.58rem; font-weight: 800; letter-spacing: 0.14em;
text-transform: uppercase; color: #7dd3fc; margin-bottom: 0.5rem;
display: flex; align-items: center; gap: 0.3rem;
}
.lg-score-tag::before { content:''; width:6px;height:6px;border-radius:50%;background:#34d399;display:inline-block;box-shadow:0 0 6px #34d399; }
.lg-score-num {
font-size: 2.2rem; font-weight: 900; color: white; line-height: 1; margin-bottom: 0.2rem;
text-shadow: 0 2px 12px rgba(99,179,255,0.4);
}
.lg-score-delta {
font-family: 'Nunito Sans', sans-serif;
font-size: 0.7rem; font-weight: 700; color: #4ade80;
display: flex; align-items: center; gap: 0.2rem; margin-bottom: 0.75rem;
}
.lg-bars {
display: flex; align-items: flex-end; gap: 3px; height: 40px;
}
.lg-bar {
flex: 1; border-radius: 3px 3px 0 0;
animation: barGrow 1s cubic-bezier(0.34,1.56,0.64,1) both;
transform-origin: bottom;
}
@keyframes barGrow {
from { transform: scaleY(0); }
to { transform: scaleY(1); }
}
/* ── Streak pill ── */
.lg-streak {
position: absolute;
bottom: 12%; left: 5%;
background: linear-gradient(135deg, rgba(249,115,22,0.18), rgba(239,68,68,0.1));
border: 1px solid rgba(249,115,22,0.4);
border-radius: 100px;
padding: 0.65rem 1.2rem;
backdrop-filter: blur(14px);
box-shadow: 0 4px 20px rgba(249,115,22,0.2), inset 0 1px 0 rgba(255,255,255,0.1);
display: flex; align-items: center; gap: 0.65rem;
animation: floatB 7s ease-in-out infinite 1s;
z-index: 2;
}
@keyframes floatB {
0%,100% { transform: translateY(0) rotate(1deg); }
50% { transform: translateY(-10px) rotate(-0.5deg); }
}
.lg-streak-fire { font-size: 1.5rem; filter: drop-shadow(0 0 8px #f97316); }
.lg-streak-text strong { display:block; font-size:0.85rem; font-weight:900; color:white; }
.lg-streak-text span { font-family:'Nunito Sans',sans-serif; font-size:0.68rem; font-weight:600; color:#fed7aa; }
/* ── Questions badge ── */
.lg-q-badge {
position: absolute;
bottom: 28%; right: 5%;
background: linear-gradient(135deg, rgba(139,92,246,0.18), rgba(99,102,241,0.1));
border: 1px solid rgba(139,92,246,0.4);
border-radius: 16px;
padding: 0.7rem 1.05rem;
backdrop-filter: blur(14px);
box-shadow: 0 4px 20px rgba(139,92,246,0.2), inset 0 1px 0 rgba(255,255,255,0.1);
animation: floatA 9s ease-in-out infinite 0.5s;
z-index: 2;
}
.lg-q-badge p { font-family:'Nunito Sans',sans-serif; font-size:0.68rem; font-weight:700; color:#c4b5fd; margin-bottom:0.15rem; }
.lg-q-badge strong { font-size:1.35rem; font-weight:900; color:white; display:block; text-shadow:0 0 20px rgba(167,139,250,0.5); }
/* ── Accuracy ring ── */
.lg-ring-wrap {
position: absolute;
top: 52%; left: 6%;
width: 80px; height: 80px;
animation: floatB 10s ease-in-out infinite 0.8s;
z-index: 2;
}
.lg-ring-svg { width: 80px; height: 80px; transform: rotate(-90deg); }
.lg-ring-bg { fill: none; stroke: rgba(255,255,255,0.08); stroke-width: 5; }
.lg-ring-fill { fill: none; stroke: #34d399; stroke-width: 5; stroke-linecap: round;
stroke-dasharray: 188; stroke-dashoffset: 18;
animation: ringFill 1.8s ease both 0.3s; }
@keyframes ringFill {
from { stroke-dashoffset: 188; }
to { stroke-dashoffset: 18; }
}
.lg-ring-label {
position: absolute; inset: 0;
display: flex; flex-direction: column; align-items: center; justify-content: center;
}
.lg-ring-label strong { font-size: 0.95rem; font-weight: 900; color: white; line-height: 1; }
.lg-ring-label span { font-family:'Nunito Sans',sans-serif; font-size: 0.52rem; font-weight: 700; color: #7dd3fc; text-transform: uppercase; letter-spacing: 0.05em; }
/* Scattered glitter dots */
.lg-glitter { position: absolute; border-radius: 50%; pointer-events: none; animation: glitterFloat 8s ease-in-out infinite; }
@keyframes glitterFloat {
0%,100% { transform: translateY(0) scale(1); opacity: 0.5; }
50% { transform: translateY(-16px) scale(1.3); opacity: 0.9; }
}
/* Stars */
.lg-star { position: absolute; pointer-events: none; animation: starTwinkle 2.5s ease-in-out infinite; color: #fde68a; }
@keyframes starTwinkle {
0%,100% { opacity: 0.4; transform: scale(0.9) rotate(0deg); }
50% { opacity: 1; transform: scale(1.4) rotate(20deg); }
}
/* Thin decorative rings */
.lg-deco-ring {
position: absolute; border-radius: 50%; pointer-events: none;
border: 1.5px solid rgba(255,255,255,0.07);
animation: decoSpin 40s linear infinite;
}
@keyframes decoSpin { to { transform: rotate(360deg); } }
/* Panel content */
.lg-panel-content {
position: relative; z-index: 3;
display: flex; flex-direction: column;
align-items: flex-start; gap: 2rem;
width: 100%;
}
.lg-panel-logo { display: flex; align-items: center; gap: 0.75rem; }
.lg-panel-logo-badge {
width: 46px; height: 46px; border-radius: 13px;
background: linear-gradient(135deg, #f97316, #ef4444);
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
padding: 2rem 1.25rem; box-shadow: 0 5px 0 rgba(0,0,0,0.3), 0 8px 20px rgba(249,115,22,0.45);
font-size: 1.3rem;
}
.lg-panel-logo-text { font-size: 1.35rem; font-weight: 900; color: white; letter-spacing: -0.02em; }
.lg-panel-headline { display: flex; flex-direction: column; gap: 0.6rem; }
.lg-panel-headline h2 {
font-size: 2.6rem; font-weight: 900; line-height: 1.1;
color: white; letter-spacing: -0.035em;
text-shadow: 0 4px 24px rgba(0,0,0,0.3);
}
.lg-panel-headline h2 span {
background: linear-gradient(90deg, #fbbf24 0%, #f97316 60%, #ef4444 100%);
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
filter: drop-shadow(0 0 16px rgba(249,115,22,0.4));
}
.lg-panel-headline p {
font-family: 'Nunito Sans', sans-serif;
font-size: 0.92rem; font-weight: 600; color: #93c5fd; line-height: 1.65;
} }
/* Blobs */ /* Stats row */
.lg-blob { position:fixed;pointer-events:none;z-index:0; } .lg-stats { display: flex; gap: 0.75rem; width: 100%; }
.lg-blob-1 { width:280px;height:280px;background:#fde68a;top:-100px;left:-100px;border-radius:60% 40% 70% 30%/50% 60% 40% 50%;animation:lgWobble1 14s ease-in-out infinite; } .lg-stat {
.lg-blob-2 { width:220px;height:220px;background:#a5f3c0;bottom:-60px;left:4%;border-radius:40% 60% 30% 70%/60% 40% 60% 40%;animation:lgWobble2 16s ease-in-out infinite; } flex: 1;
.lg-blob-3 { width:250px;height:250px;background:#fbcfe8;top:10%;right:-70px;border-radius:70% 30% 50% 50%/40% 60% 40% 60%;animation:lgWobble1 18s ease-in-out infinite reverse; } background: linear-gradient(135deg, rgba(255,255,255,0.09), rgba(255,255,255,0.04));
.lg-blob-4 { width:180px;height:180px;background:#bfdbfe;bottom:8%;right:0;border-radius:50% 50% 30% 70%/60% 40% 60% 40%;animation:lgWobble2 12s ease-in-out infinite; } border: 1px solid rgba(255,255,255,0.13);
border-radius: 18px; padding: 0.9rem 0.85rem;
@keyframes lgWobble1 { backdrop-filter: blur(12px);
0%,100%{border-radius:60% 40% 70% 30%/50% 60% 40% 50%;transform:translate(0,0) rotate(0deg);} box-shadow: 0 4px 16px rgba(0,0,0,0.25), inset 0 1px 0 rgba(255,255,255,0.12);
50%{border-radius:40% 60% 30% 70%/60% 40% 60% 40%;transform:translate(14px,18px) rotate(8deg);} display: flex; flex-direction: column; gap: 0.2rem;
animation: statSlide 0.5s ease both;
} }
@keyframes lgWobble2 { .lg-stat:nth-child(1) { animation-delay: 0.1s; }
0%,100%{border-radius:40% 60% 30% 70%/60% 40% 60% 40%;transform:translate(0,0) rotate(0deg);} .lg-stat:nth-child(2) { animation-delay: 0.2s; }
50%{border-radius:60% 40% 70% 30%/40% 60% 40% 60%;transform:translate(-12px,14px) rotate(-6deg);} .lg-stat:nth-child(3) { animation-delay: 0.3s; }
@keyframes statSlide {
from { opacity: 0; transform: translateY(14px); }
to { opacity: 1; transform: translateY(0); }
} }
.lg-stat-icon { width: 28px; height: 28px; border-radius: 8px; display:flex;align-items:center;justify-content:center; margin-bottom:0.3rem; }
.lg-stat strong { font-size: 1.3rem; font-weight: 900; color: white; line-height: 1; }
.lg-stat span { font-family:'Nunito Sans',sans-serif; font-size:0.64rem; font-weight:600; color:#93c5fd; }
.lg-dot { position:fixed;border-radius:50%;pointer-events:none;z-index:0;opacity:0.28;animation:lgFloat 7s ease-in-out infinite; } /* Social proof */
@keyframes lgFloat { .lg-social { display: flex; align-items: center; gap: 0.75rem; }
0%,100%{transform:translateY(0) rotate(0deg);} .lg-avs { display: flex; }
50%{transform:translateY(-14px) rotate(180deg);} .lg-av {
width: 30px; height: 30px; border-radius: 50%;
border: 2px solid #0f2044; margin-left: -8px;
display: flex; align-items: center; justify-content: center;
font-size: 0.6rem; font-weight: 800; color: white;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
} }
.lg-av:first-child { margin-left: 0; }
.lg-social p { font-family:'Nunito Sans',sans-serif; font-size:0.75rem; font-weight:700; color:#93c5fd; }
.lg-social p strong { color:#fbbf24; }
/* Card */ /* ─── RIGHT PANEL ─── */
.lg-card { .lg-right {
flex: 1;
display: flex; align-items: center; justify-content: center;
padding: 3rem 4rem;
position: relative; overflow: hidden;
}
.lg-bg-dot { position:absolute;border-radius:50%;pointer-events:none;opacity:0.09;animation:bgFloat 10s ease-in-out infinite; }
@keyframes bgFloat { 0%,100%{transform:translateY(0);} 50%{transform:translateY(-14px);} }
.lg-form-wrap {
position: relative; z-index: 1; position: relative; z-index: 1;
width: 100%; max-width: 400px; width: 100%; max-width: 400px;
background: white; border: 2.5px solid #f3f4f6; display: flex; flex-direction: column; gap: 2rem;
border-radius: 28px; animation: formPop 0.55s cubic-bezier(0.34,1.56,0.64,1) both;
box-shadow: 0 12px 40px rgba(0,0,0,0.08), 0 4px 12px rgba(0,0,0,0.04);
padding: 2.25rem 2rem 2rem;
display: flex; flex-direction: column; gap: 1.75rem;
animation: lgPopIn 0.5s cubic-bezier(0.34,1.56,0.64,1) both;
} }
@keyframes lgPopIn { @keyframes formPop {
from { opacity:0; transform:scale(0.9) translateY(20px); } from { opacity:0; transform:translateY(22px) scale(0.97); }
to { opacity:1; transform:scale(1) translateY(0); } to { opacity:1; transform:translateY(0) scale(1); }
} }
/* Logo area */ .lg-form-header { display:flex;flex-direction:column;gap:0.4rem; }
.lg-logo-wrap { .lg-form-header h1 { font-size:2rem;font-weight:900;color:#1e1b4b;letter-spacing:-0.03em;line-height:1.2; }
display: flex; flex-direction: column; align-items: center; gap: 0.85rem; .lg-form-header p { font-family:'Nunito Sans',sans-serif;font-size:0.88rem;font-weight:600;color:#9ca3af; }
}
.lg-logo-badge {
width: 64px; height: 64px; border-radius: 20px;
background: linear-gradient(135deg, #a855f7, #7c3aed);
display: flex; align-items: center; justify-content: center;
box-shadow: 0 6px 0 #5b21b655, 0 10px 24px rgba(124,58,237,0.25);
font-size: 1.75rem;
animation: lgPopIn 0.5s cubic-bezier(0.34,1.56,0.64,1) 0.1s both;
}
.lg-title {
font-size: 1.5rem; font-weight: 900; color: #1e1b4b;
letter-spacing: -0.02em; text-align: center;
}
.lg-sub {
font-family: 'Nunito Sans', sans-serif;
font-size: 0.82rem; font-weight: 600; color: #9ca3af;
text-align: center; margin-top: -0.25rem;
}
/* Form fields */
.lg-fields { display: flex; flex-direction: column; gap: 1rem; }
.lg-fields { display:flex;flex-direction:column;gap:1.1rem; }
.lg-field { display:flex;flex-direction:column;gap:0.4rem; } .lg-field { display:flex;flex-direction:column;gap:0.4rem; }
.lg-label { .lg-label { font-size:0.7rem;font-weight:800;letter-spacing:0.1em;text-transform:uppercase;color:#6b7280;padding-left:0.2rem; }
font-size: 0.72rem; font-weight: 800; letter-spacing: 0.1em;
text-transform: uppercase; color: #6b7280;
padding-left: 0.25rem;
}
.lg-input-wrap { position:relative; } .lg-input-wrap { position:relative; }
.lg-input-icon { .lg-input-icon { position:absolute;left:0.9rem;top:50%;transform:translateY(-50%);pointer-events:none;color:#9ca3af;transition:color 0.2s; }
position: absolute; left: 0.85rem; top: 50%;
transform: translateY(-50%); pointer-events: none; color: #9ca3af;
transition: color 0.2s ease;
}
.lg-input { .lg-input {
width: 100%; padding: 0.8rem 1rem 0.8rem 2.6rem; width:100%;padding:0.9rem 1rem 0.9rem 2.65rem;
background: #f9fafb; border: 2.5px solid #f3f4f6; background:#f9fafb;border:2.5px solid #f3f4f6;border-radius:14px;
border-radius: 14px; font-family:'Nunito Sans',sans-serif;font-size:0.9rem;font-weight:600;color:#1e1b4b;
font-family: 'Nunito Sans', sans-serif; outline:none;transition:all 0.2s;box-sizing:border-box;
font-size: 0.88rem; font-weight: 600; color: #1e1b4b;
outline: none; transition: all 0.2s ease;
box-sizing: border-box;
} }
.lg-input:focus { .lg-input:focus { background:white;border-color:#93c5fd;box-shadow:0 0 0 3.5px rgba(59,130,246,0.1); }
background: white; border-color: #c4b5fd;
box-shadow: 0 0 0 3px rgba(168,85,247,0.1);
}
.lg-input:focus ~ .lg-input-icon { color: #a855f7; }
.lg-input:disabled { opacity:0.5;cursor:not-allowed; } .lg-input:disabled { opacity:0.5;cursor:not-allowed; }
.lg-input::placeholder { color:#d1d5db; } .lg-input::placeholder { color:#d1d5db; }
/* Remember me */ .lg-remember { display:flex;align-items:center;gap:0.5rem;padding:0 0.1rem; }
.lg-remember { .lg-checkbox { width:17px;height:17px;border-radius:5px;accent-color:#3b82f6;cursor:pointer;flex-shrink:0; }
display: flex; align-items: center; gap: 0.5rem; .lg-remember-label { font-family:'Nunito Sans',sans-serif;font-size:0.8rem;font-weight:600;color:#6b7280;cursor:pointer; }
padding: 0 0.1rem;
}
.lg-checkbox {
width: 18px; height: 18px; border-radius: 6px;
accent-color: #a855f7; cursor: pointer; flex-shrink: 0;
}
.lg-remember-label {
font-family: 'Nunito Sans', sans-serif;
font-size: 0.8rem; font-weight: 600; color: #6b7280;
cursor: pointer;
}
/* Error */
.lg-error { .lg-error {
background: #fff1f2; border: 2px solid #fecdd3; background:#fff1f2;border:2px solid #fecdd3;border-radius:14px;padding:0.8rem 1rem;
border-radius: 14px; padding: 0.75rem 1rem; font-family:'Nunito Sans',sans-serif;font-size:0.82rem;font-weight:700;color:#e11d48;
font-family: 'Nunito Sans', sans-serif;
font-size: 0.82rem; font-weight: 700; color: #e11d48;
display:flex;align-items:center;gap:0.5rem; display:flex;align-items:center;gap:0.5rem;
} }
/* Submit button */
.lg-btn { .lg-btn {
width: 100%; padding: 0.95rem; width:100%;padding:1rem;background:#f97316;color:white;border:none;border-radius:100px;cursor:pointer;
background: #f97316; color: white; border: none; font-family:'Nunito',sans-serif;font-size:1rem;font-weight:900;
border-radius: 100px; cursor: pointer;
font-family: 'Nunito', sans-serif; font-size: 0.95rem; font-weight: 900;
display:flex;align-items:center;justify-content:center;gap:0.5rem; display:flex;align-items:center;justify-content:center;gap:0.5rem;
box-shadow: 0 6px 0 #c2560e, 0 8px 20px rgba(249,115,22,0.25); box-shadow:0 6px 0 #c2560e,0 10px 24px rgba(249,115,22,0.28);
transition: transform 0.1s ease, box-shadow 0.1s ease; transition:transform 0.1s,box-shadow 0.1s;
} }
.lg-btn:hover { transform:translateY(-2px); box-shadow:0 8px 0 #c2560e,0 12px 24px rgba(249,115,22,0.3); } .lg-btn:hover { transform:translateY(-2px);box-shadow:0 8px 0 #c2560e,0 14px 28px rgba(249,115,22,0.32); }
.lg-btn:active { transform:translateY(3px);box-shadow:0 3px 0 #c2560e; } .lg-btn:active { transform:translateY(3px);box-shadow:0 3px 0 #c2560e; }
.lg-btn:disabled { .lg-btn:disabled { background:#e5e7eb;color:#9ca3af;cursor:not-allowed;box-shadow:0 4px 0 #d1d5db; }
background: #e5e7eb; color: #9ca3af;
cursor: not-allowed; box-shadow: 0 4px 0 #d1d5db;
}
.lg-btn:disabled:hover { transform:none;box-shadow:0 4px 0 #d1d5db; } .lg-btn:disabled:hover { transform:none;box-shadow:0 4px 0 #d1d5db; }
.lg-spinner { animation: lgSpin 0.8s linear infinite; } .lg-spinner { animation:spin 0.8s linear infinite; }
@keyframes lgSpin { to { transform: rotate(360deg); } } @keyframes spin { to { transform:rotate(360deg); } }
/* Footer hint */ .lg-footer { text-align:center;font-family:'Nunito Sans',sans-serif;font-size:0.75rem;font-weight:600;color:#9ca3af; }
.lg-footer { .lg-signup-footer { text-align:center;font-family:'Nunito Sans',sans-serif;font-size:0.8rem;font-weight:600;color:#9ca3af; }
text-align: center; .lg-link { color:#f97316;font-weight:800;text-decoration:none;cursor:pointer; }
font-family: 'Nunito Sans', sans-serif; .lg-link:hover { color:#ea6c00; }
font-size: 0.75rem; font-weight: 600; color: #9ca3af;
@media (max-width: 860px) {
.lg-left { display:none; }
.lg-right { padding:2rem 1.5rem; }
} }
`; `;
const BAR_HEIGHTS = [30, 50, 42, 75, 55, 88, 65];
const BAR_COLORS = [
"#60a5fa",
"#60a5fa",
"#7dd3fc",
"#38bdf8",
"#60a5fa",
"#7dd3fc",
"#38bdf8",
];
const AV_COLORS = [
"linear-gradient(135deg,#3b82f6,#1d4ed8)",
"linear-gradient(135deg,#f97316,#ef4444)",
"linear-gradient(135deg,#22c55e,#15803d)",
"linear-gradient(135deg,#a855f7,#7c3aed)",
"linear-gradient(135deg,#eab308,#ca8a04)",
];
const AV_INITIALS = ["SK", "NR", "TM", "AB", "PL"];
export const Login = () => { export const Login = () => {
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
@ -185,13 +367,12 @@ export const Login = () => {
const { login, isAuthenticated, isLoading, error, clearError } = const { login, isAuthenticated, isLoading, error, clearError } =
useAuthStore(); useAuthStore();
const from =
const from = (location.state as LocationState)?.from?.pathname || "/student"; (location.state as LocationState)?.from?.pathname || "/student/home";
useEffect(() => { useEffect(() => {
if (isAuthenticated) navigate("/student/home", { replace: true }); if (isAuthenticated) navigate("/student/home", { replace: true });
}, [isAuthenticated, navigate]); }, [isAuthenticated, navigate]);
useEffect(() => { useEffect(() => {
return () => clearError(); return () => clearError();
}, [clearError]); }, [clearError]);
@ -206,61 +387,249 @@ export const Login = () => {
if (isAuthenticated) return null; if (isAuthenticated) return null;
return ( return (
<div className="lg-screen"> <div className="lg-root">
<style>{STYLES}</style> <style>{STYLES}</style>
{/* Blobs */} {/* ── LEFT PANEL ── */}
<div className="lg-blob lg-blob-1" /> <div className="lg-left">
<div className="lg-blob lg-blob-2" /> {/* Background layers */}
<div className="lg-blob lg-blob-3" /> <div className="lg-grid" />
<div className="lg-blob lg-blob-4" /> <div className="lg-glow lg-glow-1" />
<div className="lg-glow lg-glow-2" />
<div className="lg-glow lg-glow-3" />
{/* Dots */} {/* Decorative spinning rings */}
{DOTS.map((d, i) => ( {[
{ s: 260, t: "38%", l: "58%", mt: -130, ml: -130 },
{
s: 380,
t: "42%",
l: "54%",
mt: -190,
ml: -190,
dir: "reverse" as const,
},
].map((r, i) => (
<div <div
key={i} key={i}
className="lg-dot" className="lg-deco-ring"
style={ style={
{ {
width: d.size, width: r.s,
height: d.size, height: r.s,
background: d.color, top: r.t,
top: d.top, left: r.l,
left: d.left, marginTop: r.mt,
right: d.right, marginLeft: r.ml,
animationDelay: d.delay, animationDirection: r.dir ?? "normal",
animationDuration: `${5.5 + i * 0.4}s`, animationDuration: `${35 + i * 10}s`,
} as React.CSSProperties } as React.CSSProperties
} }
/> />
))} ))}
<div className="lg-card"> {/* Floating score card */}
{/* Logo + heading */} <div className="lg-score-card">
<div className="lg-logo-wrap space-y-5"> <p className="lg-score-tag">SAT Score</p>
<img <p className="lg-score-num">1480</p>
src="src/assets/ed_logo.png" <p className="lg-score-delta"> +120 pts this month</p>
alt="EdBridge" <div className="lg-bars">
{BAR_HEIGHTS.map((h, i) => (
<div
key={i}
className="lg-bar"
style={{ style={{
width: 600, height: `${h}%`,
height: 70, background: BAR_COLORS[i],
objectFit: "contain", animationDelay: `${i * 0.08}s`,
borderRadius: 8,
}}
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}} }}
/> />
))}
<div>
<h1 className="lg-title">Welcome back 👋</h1>
<p className="lg-sub">Sign in to continue your SAT prep</p>
</div> </div>
</div> </div>
{/* Fields */} {/* Accuracy ring */}
<div className="lg-ring-wrap">
<svg className="lg-ring-svg" viewBox="0 0 80 80">
<circle className="lg-ring-bg" cx="40" cy="40" r="30" />
<circle className="lg-ring-fill" cx="40" cy="40" r="30" />
</svg>
<div className="lg-ring-label">
<strong>94%</strong>
<span>Accuracy</span>
</div>
</div>
{/* Streak pill */}
<div className="lg-streak">
<span className="lg-streak-fire">🔥</span>
<div className="lg-streak-text">
<strong>14-day streak!</strong>
<span>Keep it going</span>
</div>
</div>
{/* Questions badge */}
<div className="lg-q-badge">
<p>Questions solved</p>
<strong>2,847</strong>
</div>
{/* Glitter dots */}
{[
{ s: 10, c: "#60a5fa", t: "13%", l: "58%", d: "0s", dur: "9s" },
{ s: 7, c: "#fbbf24", t: "70%", l: "70%", d: "1s", dur: "11s" },
{ s: 12, c: "#34d399", t: "38%", l: "7%", d: "0.4s", dur: "8s" },
{ s: 5, c: "#f472b6", t: "82%", l: "35%", d: "1.8s", dur: "13s" },
{ s: 8, c: "#a78bfa", t: "20%", l: "36%", d: "0.9s", dur: "10s" },
{ s: 6, c: "#fb923c", t: "62%", l: "80%", d: "1.3s", dur: "7s" },
].map((d, i) => (
<div
key={i}
className="lg-glitter"
style={
{
width: d.s,
height: d.s,
background: d.c,
top: d.t,
left: d.l,
animationDelay: d.d,
animationDuration: d.dur,
} as React.CSSProperties
}
/>
))}
{/* Stars */}
{[
{ t: "8%", l: "16%", s: "1.1rem", d: "0s" },
{ t: "22%", l: "74%", s: "0.85rem", d: "0.7s" },
{ t: "55%", l: "20%", s: "0.95rem", d: "1.4s" },
{ t: "86%", l: "58%", s: "0.75rem", d: "0.3s" },
{ t: "44%", l: "88%", s: "1rem", d: "1.0s" },
].map((s, i) => (
<span
key={i}
className="lg-star"
style={
{
top: s.t,
left: s.l,
fontSize: s.s,
animationDelay: s.d,
} as React.CSSProperties
}
>
</span>
))}
{/* Panel content */}
<div className="lg-panel-content">
<div className="lg-panel-logo">
<div className="lg-panel-logo-badge">📚</div>
<span className="lg-panel-logo-text">EdBridge</span>
</div>
<div className="lg-panel-headline">
<h2>
Welcome
<br />
back,
<br />
<span>champion.</span>
</h2>
<p>
Your SAT goals are waiting.
<br />
Pick up right where you left off.
</p>
</div>
<div className="lg-stats">
{[
{
icon: <Target size={14} color="#fff" />,
bg: "linear-gradient(135deg,#3b82f6,#1d4ed8)",
val: "94%",
label: "Accuracy",
},
{
icon: <Clock size={14} color="#fff" />,
bg: "linear-gradient(135deg,#f97316,#ef4444)",
val: "47m",
label: "Daily study",
},
{
icon: <BarChart2 size={14} color="#fff" />,
bg: "linear-gradient(135deg,#22c55e,#15803d)",
val: "+180",
label: "Score gain",
},
].map((s, i) => (
<div className="lg-stat" key={i}>
<div className="lg-stat-icon" style={{ background: s.bg }}>
{s.icon}
</div>
<strong>{s.val}</strong>
<span>{s.label}</span>
</div>
))}
</div>
<div className="lg-social">
<div className="lg-avs">
{AV_INITIALS.map((init, i) => (
<div
key={i}
className="lg-av"
style={{ background: AV_COLORS[i] }}
>
{init}
</div>
))}
</div>
<p>
<strong>2,400+</strong> students improved their scores
</p>
</div>
</div>
</div>
{/* ── RIGHT PANEL ── */}
<div className="lg-right">
{[
{ s: 200, c: "#f97316", t: "4%", r: "4%", d: "0s", dur: "12s" },
{ s: 120, c: "#3b82f6", b: "8%", l: "2%", d: "1.5s", dur: "10s" },
{ s: 70, c: "#22c55e", t: "52%", r: "2%", d: "0.8s", dur: "8s" },
].map((d, i) => (
<div
key={i}
className="lg-bg-dot"
style={
{
width: d.s,
height: d.s,
background: d.c,
top: (d as any).t,
right: (d as any).r,
bottom: (d as any).b,
left: (d as any).l,
animationDelay: d.d,
animationDuration: d.dur,
} as React.CSSProperties
}
/>
))}
<div className="lg-form-wrap">
<div className="lg-form-header">
<h1>Welcome back 👋</h1>
<p>Sign in to continue your SAT prep</p>
</div>
<div className="lg-fields"> <div className="lg-fields">
{/* Email */}
<div className="lg-field"> <div className="lg-field">
<label className="lg-label" htmlFor="email"> <label className="lg-label" htmlFor="email">
Email Email
@ -279,7 +648,6 @@ export const Login = () => {
</div> </div>
</div> </div>
{/* Password */}
<div className="lg-field"> <div className="lg-field">
<label className="lg-label" htmlFor="password"> <label className="lg-label" htmlFor="password">
Password Password
@ -298,7 +666,6 @@ export const Login = () => {
</div> </div>
</div> </div>
{/* Remember me */}
<div className="lg-remember"> <div className="lg-remember">
<input id="rememberMe" type="checkbox" className="lg-checkbox" /> <input id="rememberMe" type="checkbox" className="lg-checkbox" />
<label htmlFor="rememberMe" className="lg-remember-label"> <label htmlFor="rememberMe" className="lg-remember-label">
@ -306,14 +673,12 @@ export const Login = () => {
</label> </label>
</div> </div>
{/* Error */}
{error && ( {error && (
<div className="lg-error"> <div className="lg-error">
<span></span> {error} <span></span> {error}
</div> </div>
)} )}
{/* Submit */}
<button <button
className="lg-btn" className="lg-btn"
onClick={handleSubmit} onClick={handleSubmit}
@ -330,8 +695,15 @@ export const Login = () => {
</div> </div>
<p className="lg-footer"> <p className="lg-footer">
By signing in you agree to Edbridge's Terms & Privacy Policy. By signing in you agree to EdBridge's Terms & Privacy Policy.
</p> </p>
<p className="lg-signup-footer">
Don't have an account?{" "}
<span className="lg-link" onClick={() => navigate("/register")}>
Sign up
</span>
</p>
</div>
</div> </div>
</div> </div>
); );

794
src/pages/auth/Register.tsx Normal file
View File

@ -0,0 +1,794 @@
import { useState } from "react";
import type { FormEvent } from "react";
import { useNavigate } from "react-router-dom";
import { useAuthStore } from "../../stores/authStore";
import {
Loader2,
Mail,
Lock,
ImageIcon,
BookOpen,
Star,
Zap,
Trophy,
} from "lucide-react";
const STYLES = `
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap');
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
.rg-root {
min-height: 100vh;
display: flex;
font-family: 'Nunito', sans-serif;
background: #fffbf4;
}
/* ─── LEFT PANEL ─── */
.rg-left {
position: relative;
width: 50%;
min-height: 100vh;
background: linear-gradient(150deg, #1e1b4b 0%, #3b1d8a 50%, #6d28d9 100%);
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
padding: 3rem 3.5rem;
overflow: hidden;
flex-shrink: 0;
}
/* Blobs inside left panel */
.rg-panel-blob {
position: absolute; pointer-events: none; border-radius: 50%;
opacity: 0.18;
}
.rg-panel-blob-1 {
width: 420px; height: 420px;
background: #a855f7;
top: -140px; right: -100px;
animation: blobDrift1 16s ease-in-out infinite;
}
.rg-panel-blob-2 {
width: 300px; height: 300px;
background: #f97316;
bottom: -100px; left: -80px;
animation: blobDrift2 18s ease-in-out infinite;
}
.rg-panel-blob-3 {
width: 200px; height: 200px;
background: #22c55e;
top: 50%; left: 50%;
transform: translate(-50%, -50%);
animation: blobDrift1 14s ease-in-out infinite reverse;
}
@keyframes blobDrift1 {
0%,100% { transform: translate(0,0) scale(1); }
50% { transform: translate(24px, 32px) scale(1.08); }
}
@keyframes blobDrift2 {
0%,100% { transform: translate(0,0) scale(1); }
50% { transform: translate(-20px, -28px) scale(1.06); }
}
/* Floating decorative shapes */
.rg-shape {
position: absolute; pointer-events: none;
animation: floatShape 8s ease-in-out infinite;
}
@keyframes floatShape {
0%,100% { transform: translateY(0) rotate(0deg); }
50% { transform: translateY(-16px) rotate(12deg); }
}
/* Stars scattered */
.rg-star {
position: absolute; pointer-events: none;
color: #fde68a; opacity: 0.55;
animation: twinkle 3s ease-in-out infinite;
}
@keyframes twinkle {
0%,100% { opacity: 0.55; transform: scale(1); }
50% { opacity: 0.9; transform: scale(1.3); }
}
/* Left panel content */
.rg-panel-content {
position: relative; z-index: 1;
display: flex; flex-direction: column;
align-items: flex-start; gap: 2.5rem;
width: 100%;
}
.rg-panel-logo {
display: flex; align-items: center; gap: 0.75rem;
}
.rg-panel-logo-badge {
width: 48px; height: 48px; border-radius: 14px;
background: linear-gradient(135deg, #f97316, #ef4444);
display: flex; align-items: center; justify-content: center;
box-shadow: 0 6px 0 rgba(0,0,0,0.25), 0 8px 20px rgba(249,115,22,0.4);
font-size: 1.4rem;
}
.rg-panel-logo-text {
font-size: 1.4rem; font-weight: 900;
color: white; letter-spacing: -0.02em;
}
.rg-panel-headline {
display: flex; flex-direction: column; gap: 0.75rem;
}
.rg-panel-headline h2 {
font-size: 2.4rem; font-weight: 900; line-height: 1.15;
color: white; letter-spacing: -0.03em;
}
.rg-panel-headline h2 span {
background: linear-gradient(90deg, #fde68a, #f97316);
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
}
.rg-panel-headline p {
font-family: 'Nunito Sans', sans-serif;
font-size: 1rem; font-weight: 600; color: #c4b5fd; line-height: 1.6;
}
/* Feature pills */
.rg-features { display: flex; flex-direction: column; gap: 0.85rem; }
.rg-feature {
display: flex; align-items: center; gap: 0.85rem;
background: rgba(255,255,255,0.07);
border: 1.5px solid rgba(255,255,255,0.12);
border-radius: 14px; padding: 0.85rem 1.1rem;
backdrop-filter: blur(8px);
animation: fadeSlideIn 0.5s ease both;
}
.rg-feature:nth-child(1) { animation-delay: 0.1s; }
.rg-feature:nth-child(2) { animation-delay: 0.2s; }
.rg-feature:nth-child(3) { animation-delay: 0.3s; }
@keyframes fadeSlideIn {
from { opacity: 0; transform: translateX(-16px); }
to { opacity: 1; transform: translateX(0); }
}
.rg-feature-icon {
width: 36px; height: 36px; border-radius: 10px;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0;
}
.rg-feature-text strong {
display: block; font-size: 0.85rem; font-weight: 800; color: white;
}
.rg-feature-text span {
font-family: 'Nunito Sans', sans-serif;
font-size: 0.75rem; font-weight: 600; color: #a5b4fc;
}
/* Social proof */
.rg-social-proof {
display: flex; align-items: center; gap: 0.75rem;
padding: 0.1rem 0;
}
.rg-avatars { display: flex; }
.rg-av {
width: 30px; height: 30px; border-radius: 50%;
border: 2px solid #3b1d8a;
background: linear-gradient(135deg, #a855f7, #6d28d9);
margin-left: -8px; display: flex; align-items: center;
justify-content: center; font-size: 0.65rem; font-weight: 800; color: white;
}
.rg-av:first-child { margin-left: 0; }
.rg-social-proof p {
font-family: 'Nunito Sans', sans-serif;
font-size: 0.78rem; font-weight: 700; color: #c4b5fd;
}
.rg-social-proof p strong { color: #fde68a; }
/* ─── RIGHT PANEL (form) ─── */
.rg-right {
flex: 1;
display: flex; align-items: center; justify-content: center;
padding: 3rem 4rem;
position: relative; overflow: hidden;
}
/* Subtle bg dots on right */
.rg-bg-dot {
position: absolute; border-radius: 50%; pointer-events: none; opacity: 0.10;
animation: bgDotFloat 9s ease-in-out infinite;
}
@keyframes bgDotFloat {
0%,100% { transform: translateY(0); }
50% { transform: translateY(-12px); }
}
.rg-form-wrap {
position: relative; z-index: 1;
width: 100%; max-width: 420px;
display: flex; flex-direction: column; gap: 2rem;
animation: formPopIn 0.55s cubic-bezier(0.34,1.56,0.64,1) both;
}
@keyframes formPopIn {
from { opacity: 0; transform: translateY(24px) scale(0.97); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
/* Form header */
.rg-form-header { display: flex; flex-direction: column; gap: 0.4rem; }
.rg-form-header h1 {
font-size: 2rem; font-weight: 900; color: #1e1b4b;
letter-spacing: -0.03em; line-height: 1.2;
}
.rg-form-header p {
font-family: 'Nunito Sans', sans-serif;
font-size: 0.88rem; font-weight: 600; color: #9ca3af;
}
/* Avatar row */
.rg-avatar-row {
display: flex; align-items: center; gap: 1.1rem;
background: #f9fafb; border: 2.5px solid #f3f4f6;
border-radius: 18px; padding: 0.9rem 1.1rem;
transition: border-color 0.2s;
}
.rg-avatar-row:focus-within { border-color: #c4b5fd; background: white; }
.rg-avatar-ring {
width: 52px; height: 52px; border-radius: 50%;
border: 2.5px dashed #e5e7eb;
display: flex; align-items: center; justify-content: center;
overflow: hidden; background: white; flex-shrink: 0;
transition: border-color 0.25s, border-style 0.25s;
}
.rg-avatar-ring.filled { border-style: solid; border-color: #a855f7; }
.rg-avatar-ring img { width: 100%; height: 100%; object-fit: cover; }
.rg-avatar-input-col { flex: 1; display: flex; flex-direction: column; gap: 0.2rem; }
.rg-avatar-label {
font-size: 0.68rem; font-weight: 800; letter-spacing: 0.1em;
text-transform: uppercase; color: #6b7280;
}
.rg-avatar-input {
background: transparent; border: none; outline: none;
font-family: 'Nunito Sans', sans-serif;
font-size: 0.85rem; font-weight: 600; color: #1e1b4b;
width: 100%;
}
.rg-avatar-input::placeholder { color: #d1d5db; }
.rg-avatar-hint {
font-family: 'Nunito Sans', sans-serif;
font-size: 0.7rem; font-weight: 600; color: #c4b5fd;
}
/* Fields grid */
.rg-fields { display: flex; flex-direction: column; gap: 1rem; }
.rg-row { display: flex; gap: 1rem; }
.rg-row .rg-field { flex: 1; }
.rg-field { display: flex; flex-direction: column; gap: 0.4rem; }
.rg-label {
font-size: 0.7rem; font-weight: 800; letter-spacing: 0.1em;
text-transform: uppercase; color: #6b7280; padding-left: 0.2rem;
}
.rg-input-wrap { position: relative; }
.rg-input-icon {
position: absolute; left: 0.9rem; top: 50%;
transform: translateY(-50%); pointer-events: none; color: #9ca3af;
transition: color 0.2s;
}
.rg-input {
width: 100%; padding: 0.85rem 1rem 0.85rem 2.6rem;
background: #f9fafb; border: 2.5px solid #f3f4f6;
border-radius: 14px;
font-family: 'Nunito Sans', sans-serif;
font-size: 0.88rem; font-weight: 600; color: #1e1b4b;
outline: none; transition: all 0.2s;
}
.rg-input:focus {
background: white; border-color: #c4b5fd;
box-shadow: 0 0 0 3.5px rgba(168,85,247,0.1);
}
.rg-input:focus + .rg-input-icon { color: #a855f7; }
.rg-input:disabled { opacity: 0.5; cursor: not-allowed; }
.rg-input::placeholder { color: #d1d5db; }
/* Strength */
.rg-strength-bar { display: flex; gap: 5px; margin-top: 0.4rem; }
.rg-strength-seg {
flex: 1; height: 4px; border-radius: 999px;
background: #f3f4f6; transition: background 0.3s;
}
.rg-strength-seg.weak { background: #f43f5e; }
.rg-strength-seg.medium { background: #eab308; }
.rg-strength-seg.strong { background: #22c55e; }
.rg-strength-hint {
font-family: 'Nunito Sans', sans-serif;
font-size: 0.7rem; font-weight: 700; margin-top: 0.2rem; padding-left: 0.1rem;
color: #9ca3af;
}
.rg-strength-hint.weak { color: #f43f5e; }
.rg-strength-hint.medium { color: #eab308; }
.rg-strength-hint.strong { color: #22c55e; }
/* Error */
.rg-error {
background: #fff1f2; border: 2px solid #fecdd3;
border-radius: 14px; padding: 0.8rem 1rem;
font-family: 'Nunito Sans', sans-serif;
font-size: 0.82rem; font-weight: 700; color: #e11d48;
display: flex; align-items: center; gap: 0.5rem;
}
/* Submit */
.rg-btn {
width: 100%; padding: 1rem;
background: #a855f7; color: white; border: none;
border-radius: 100px; cursor: pointer;
font-family: 'Nunito', sans-serif; font-size: 1rem; font-weight: 900;
display: flex; align-items: center; justify-content: center; gap: 0.5rem;
box-shadow: 0 6px 0 #7c3aed, 0 10px 24px rgba(168,85,247,0.3);
transition: transform 0.1s, box-shadow 0.1s;
letter-spacing: 0.01em;
}
.rg-btn:hover { transform: translateY(-2px); box-shadow: 0 8px 0 #7c3aed, 0 14px 28px rgba(168,85,247,0.35); }
.rg-btn:active { transform: translateY(3px); box-shadow: 0 3px 0 #7c3aed; }
.rg-btn:disabled {
background: #e5e7eb; color: #9ca3af;
cursor: not-allowed; box-shadow: 0 4px 0 #d1d5db;
}
.rg-btn:disabled:hover { transform: none; box-shadow: 0 4px 0 #d1d5db; }
.rg-spinner { animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.rg-form-footer {
text-align: center;
font-family: 'Nunito Sans', sans-serif;
font-size: 0.8rem; font-weight: 600; color: #9ca3af;
}
.rg-link {
color: #a855f7; font-weight: 800; text-decoration: none;
}
.rg-link:hover { color: #7c3aed; }
/* Responsive */
@media (max-width: 860px) {
.rg-left { display: none; }
.rg-right { padding: 2rem 1.5rem; }
}
`;
function getStrength(p: string): 0 | 1 | 2 | 3 {
if (!p) return 0;
let s = 0;
if (p.length >= 8) s++;
if (/[A-Z]/.test(p) && /[a-z]/.test(p)) s++;
if (/[0-9]/.test(p) || /[^A-Za-z0-9]/.test(p)) s++;
return s as 0 | 1 | 2 | 3;
}
const S_LABEL = ["", "Weak", "Medium", "Strong"];
const S_CLASS = ["", "weak", "medium", "strong"];
const FEATURES = [
{
icon: <BookOpen size={18} color="#fff" />,
bg: "#a855f7",
title: "Adaptive Practice",
sub: "Questions tailored to your skill level",
},
{
icon: <Zap size={18} color="#fff" />,
bg: "#f97316",
title: "Instant Feedback",
sub: "Know exactly where you went wrong",
},
{
icon: <Trophy size={18} color="#fff" />,
bg: "#22c55e",
title: "Score Tracking",
sub: "Watch your SAT score climb over time",
},
];
const PANEL_DOTS = [
{
size: 70,
color: "#f97316",
top: "15%",
left: "65%",
delay: "0s",
dur: "9s",
},
{
size: 45,
color: "#22c55e",
top: "62%",
left: "10%",
delay: "1s",
dur: "11s",
},
{
size: 30,
color: "#fde68a",
top: "35%",
left: "75%",
delay: "0.5s",
dur: "7s",
},
{
size: 20,
color: "#a855f7",
top: "78%",
left: "50%",
delay: "2s",
dur: "13s",
},
];
const BG_DOTS = [
{
size: 180,
color: "#a855f7",
top: "5%",
right: "5%",
delay: "0s",
dur: "12s",
},
{
size: 100,
color: "#f97316",
bottom: "10%",
left: "2%",
delay: "1.5s",
dur: "10s",
},
{
size: 60,
color: "#22c55e",
top: "50%",
right: "3%",
delay: "0.8s",
dur: "8s",
},
];
const INITIALS = ["JD", "AS", "MK", "RP", "LL"];
export const Register = () => {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [avatarUrl, setAvatarUrl] = useState("");
const [password, setPassword] = useState("");
const [avatarError, setAvatarError] = useState(false);
const navigate = useNavigate();
const { register, isLoading, error, clearError } = useAuthStore();
const strength = getStrength(password);
const isValid = name.trim() && email.trim() && password.length >= 6;
const handleSubmit = async (e: FormEvent<HTMLButtonElement>) => {
e.preventDefault();
clearError();
const success = await register({
email,
name,
avatar_url: avatarUrl,
password,
});
if (success) navigate("/student/home", { replace: true });
};
return (
<div className="rg-root">
<style>{STYLES}</style>
{/* ── LEFT PANEL ── */}
<div className="rg-left">
<div className="rg-panel-blob rg-panel-blob-1" />
<div className="rg-panel-blob rg-panel-blob-2" />
<div className="rg-panel-blob rg-panel-blob-3" />
{/* Decorative floating dots */}
{PANEL_DOTS.map((d, i) => (
<div
key={i}
className="rg-shape"
style={
{
width: d.size,
height: d.size,
background: d.color,
borderRadius: "50%",
opacity: 0.12,
top: d.top,
left: d.left,
animationDelay: d.delay,
animationDuration: d.dur,
} as React.CSSProperties
}
/>
))}
{/* Stars */}
{[
{ top: "14%", left: "20%", size: 14, delay: "0s" },
{ top: "28%", left: "78%", size: 10, delay: "0.7s" },
{ top: "52%", left: "30%", size: 12, delay: "1.3s" },
{ top: "70%", left: "65%", size: 8, delay: "0.4s" },
{ top: "88%", left: "22%", size: 10, delay: "1.8s" },
].map((s, i) => (
<Star
key={i}
className="rg-star"
size={s.size}
style={
{
top: s.top,
left: s.left,
animationDelay: s.delay,
fill: "#fde68a",
} as React.CSSProperties
}
/>
))}
{/* Decorative ring shapes */}
{[
{ size: 90, top: "72%", left: "5%", delay: "0.2s", dur: "10s" },
{ size: 60, top: "8%", left: "55%", delay: "1.1s", dur: "13s" },
].map((r, i) => (
<div
key={i}
className="rg-shape"
style={
{
width: r.size,
height: r.size,
border: "2.5px solid rgba(255,255,255,0.1)",
borderRadius: "50%",
top: r.top,
left: r.left,
animationDelay: r.delay,
animationDuration: r.dur,
} as React.CSSProperties
}
/>
))}
<div className="rg-panel-content">
{/* Logo */}
<div className="rg-panel-logo">
<div className="rg-panel-logo-badge">📚</div>
<span className="rg-panel-logo-text">EdBridge</span>
</div>
{/* Headline */}
<div className="rg-panel-headline">
<h2>
Ace the SAT.
<br />
<span>Start for free.</span>
</h2>
<p>
Join thousands of students who improved their
<br />
SAT scores with personalized practice.
</p>
</div>
{/* Feature pills */}
<div className="rg-features">
{FEATURES.map((f, i) => (
<div className="rg-feature" key={i}>
<div className="rg-feature-icon" style={{ background: f.bg }}>
{f.icon}
</div>
<div className="rg-feature-text">
<strong>{f.title}</strong>
<span>{f.sub}</span>
</div>
</div>
))}
</div>
{/* Social proof */}
<div className="rg-social-proof">
<div className="rg-avatars">
{INITIALS.map((s, i) => (
<div className="rg-av" key={i}>
{s}
</div>
))}
</div>
<p>
<strong>2,400+</strong> students already enrolled
</p>
</div>
</div>
</div>
{/* ── RIGHT PANEL ── */}
<div className="rg-right">
{BG_DOTS.map((d, i) => (
<div
key={i}
className="rg-bg-dot"
style={
{
width: d.size,
height: d.size,
background: d.color,
top: (d as any).top,
right: (d as any).right,
bottom: (d as any).bottom,
left: (d as any).left,
animationDelay: d.delay,
animationDuration: d.dur,
} as React.CSSProperties
}
/>
))}
<div className="rg-form-wrap">
{/* Header */}
<div className="rg-form-header">
<h1>Create your account </h1>
<p>Fill in the details below to get started</p>
</div>
{/* Avatar URL row */}
<div className="rg-avatar-row">
<div
className={`rg-avatar-ring ${avatarUrl && !avatarError ? "filled" : ""}`}
>
{avatarUrl && !avatarError ? (
<img
src={avatarUrl}
alt="Avatar"
onError={() => setAvatarError(true)}
/>
) : (
<ImageIcon size={20} color="#d1d5db" />
)}
</div>
<div className="rg-avatar-input-col">
<span className="rg-avatar-label">
Avatar URL{" "}
<span
style={{
fontWeight: 600,
textTransform: "none",
letterSpacing: 0,
color: "#c4b5fd",
fontSize: "0.68rem",
}}
>
(optional)
</span>
</span>
<input
className="rg-avatar-input"
type="url"
placeholder="https://example.com/photo.jpg"
value={avatarUrl}
onChange={(e) => {
setAvatarUrl(e.target.value);
setAvatarError(false);
}}
disabled={isLoading}
/>
<span className="rg-avatar-hint">
Paste any image URL to set your profile photo
</span>
</div>
</div>
{/* Fields */}
<div className="rg-fields">
{/* Name + Email row */}
<div className="rg-row">
<div className="rg-field">
<label className="rg-label" htmlFor="name">
Full Name
</label>
<div className="rg-input-wrap">
<input
id="name"
type="text"
className="rg-input"
style={{ paddingLeft: "1rem" }}
placeholder="Jane Doe"
value={name}
onChange={(e) => setName(e.target.value)}
disabled={isLoading}
/>
</div>
</div>
</div>
{/* Email */}
<div className="rg-field">
<label className="rg-label" htmlFor="email">
Email
</label>
<div className="rg-input-wrap">
<Mail size={15} className="rg-input-icon" />
<input
id="email"
type="email"
className="rg-input"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={isLoading}
/>
</div>
</div>
{/* Password */}
<div className="rg-field">
<label className="rg-label" htmlFor="password">
Password
</label>
<div className="rg-input-wrap">
<Lock size={15} className="rg-input-icon" />
<input
id="password"
type="password"
className="rg-input"
placeholder="Min. 6 characters"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isLoading}
/>
</div>
{password && (
<>
<div className="rg-strength-bar">
{[1, 2, 3].map((seg) => (
<div
key={seg}
className={`rg-strength-seg ${strength >= seg ? S_CLASS[strength] : ""}`}
/>
))}
</div>
<p className={`rg-strength-hint ${S_CLASS[strength]}`}>
{S_LABEL[strength]} password
</p>
</>
)}
</div>
{/* Error */}
{error && (
<div className="rg-error">
<span></span> {error}
</div>
)}
{/* Submit */}
<button
className="rg-btn"
onClick={handleSubmit}
disabled={isLoading || !isValid}
>
{isLoading ? (
<>
<Loader2 size={18} className="rg-spinner" /> Creating
account...
</>
) : (
"Create Account →"
)}
</button>
</div>
<p className="rg-form-footer">
Already have an account?{" "}
<a href="/login" className="rg-link">
Sign in
</a>
</p>
</div>
</div>
</div>
);
};

View File

@ -1,71 +0,0 @@
import { List, SquarePen, DecimalsArrowRight, MapPin } from "lucide-react";
import { Progress } from "../../components/ui/progress";
import { Button } from "../../components/ui/button";
import {
Card,
CardHeader,
CardTitle,
CardContent,
CardFooter,
} from "../../components/ui/card";
import { Field, FieldLabel } from "../../components/ui/field";
import { CircularProgress } from "../../components/CircularProgress";
export const Analytics = () => {
return (
<main className="min-h-screen max-w-7xl mx-auto px-8 sm:px-6 lg:px-8 py-8 space-y-4">
<h1 className="font-satoshi-bold text-3xl text-center tracking-tight">
Analytics
</h1>
<section className="flex w-full gap-3 justify-between">
<Card className="w-1/3 relative bg-linear-to-br from-purple-600 to-purple-700 rounded-4xl">
<div className="space-y-4">
<CardContent className="md:w-full space-y-4 flex flex-col items-center justify-center h-50">
<MapPin size={60} color="white" />
<h1 className="text-4xl font-satoshi-bold text-white flex">
<span>145</span> <span className="text-xl">th</span>
</h1>
</CardContent>
</div>
<div className="overflow-hidden opacity-0 -rotate-45 absolute -top-2 -right-30 ">
<DecimalsArrowRight size={380} color="white" />
</div>
</Card>
<Card
className="w-2/3 relative bg-linear-to-br from-gray-100 to-gray-300 rounded-4xl
flex-row"
>
<div className="space-y-4">
<CardHeader className="md:w-full">
<CardTitle className="font-satoshi-bold tracking-tight text-3xl ">
Details
</CardTitle>
</CardHeader>
<CardContent className="md:w-full space-y-4"></CardContent>
<CardFooter className="flex justify-between"></CardFooter>
</div>
<div className="overflow-hidden opacity-30 -rotate-45 absolute -top-2 -right-30 ">
<DecimalsArrowRight size={380} color="white" />
</div>
</Card>
</section>
<section>
<Card>
<CardContent>
<Field className="w-full max-w-sm">
<FieldLabel htmlFor="progress-upload">
<span className="font-satoshi text-xl">Score</span>
<span className="ml-auto font-satoshi">
<span className="text-5xl">854</span>
<span className="text-lg">/1600</span>
</span>
</FieldLabel>
<Progress value={55} id="progress-upload" max={100} />
</Field>
</CardContent>
</Card>
</section>
</main>
);
};

View File

@ -7,7 +7,6 @@ import { formatStatus } from "../../lib/utils";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { SearchOverlay } from "../../components/SearchOverlay"; import { SearchOverlay } from "../../components/SearchOverlay";
import { InfoHeader } from "../../components/InfoHeader"; import { InfoHeader } from "../../components/InfoHeader";
import { InventoryButton } from "../../components/InventoryButton";
// ─── Shared blob/dot background (same as break/results screens) ──────────────── // ─── Shared blob/dot background (same as break/results screens) ────────────────
const DOTS = [ const DOTS = [
@ -22,6 +21,8 @@ const DOTS = [
const STYLES = ` const STYLES = `
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap');
:root { --content-max: 1100px; }
.home-screen { .home-screen {
min-height: 100vh; min-height: 100vh;
background: #fffbf4; background: #fffbf4;
@ -30,6 +31,13 @@ const STYLES = `
overflow-x: hidden; overflow-x: hidden;
} }
/* On desktop, account for sidebar */
@media (min-width: 768px) {
.home-screen {
padding-left: calc(17rem + 1.25rem);
}
}
/* ── Blobs ── */ /* ── Blobs ── */
.h-blob { position: fixed; pointer-events: none; z-index: 0; filter: blur(48px); opacity: 0.35; } .h-blob { position: fixed; pointer-events: none; z-index: 0; filter: blur(48px); opacity: 0.35; }
.h-blob-1 { width:240px;height:240px;background:#fde68a;top:-80px;left:-80px;border-radius:60% 40% 70% 30%/50% 60% 40% 50%;animation:hWobble1 14s ease-in-out infinite; } .h-blob-1 { width:240px;height:240px;background:#fde68a;top:-80px;left:-80px;border-radius:60% 40% 70% 30%/50% 60% 40% 50%;animation:hWobble1 14s ease-in-out infinite; }
@ -149,7 +157,7 @@ const STYLES = `
} }
.h-tab-btn.active { color:#1e1b4b; border-bottom-color:#a855f7; } .h-tab-btn.active { color:#1e1b4b; border-bottom-color:#a855f7; }
/* ── Practice sheet card ── */ /* ── Practice sheet ── */
.h-sheet-grid { .h-sheet-grid {
display:grid; gap:0.85rem; display:grid; gap:0.85rem;
grid-template-columns: 1fr; grid-template-columns: 1fr;
@ -241,6 +249,18 @@ const STYLES = `
.h-anim-3 { animation-delay:0.15s; } .h-anim-3 { animation-delay:0.15s; }
.h-anim-4 { animation-delay:0.2s; } .h-anim-4 { animation-delay:0.2s; }
.h-anim-5 { animation-delay:0.25s; } .h-anim-5 { animation-delay:0.25s; }
/* Desktop / wide tweaks */
@media (min-width: 900px) {
.home-inner { max-width: var(--content-max); padding: 3rem 1.5rem 6rem; }
.h-sheet-grid { grid-template-columns: repeat(3, 1fr); gap: 1rem; }
/* nudge blobs so they align visually with the centered container */
.h-blob-1 { left: calc((100vw - var(--content-max)) / 2 - 120px); top: -120px; width: 300px; height: 300px; }
.h-blob-2 { left: calc((100vw - var(--content-max)) / 2 + 20px); bottom: -80px; width: 220px; height: 220px; }
.h-blob-3 { right: calc((100vw - var(--content-max)) / 2 - 40px); top: 10%; width: 260px; height: 260px; }
.h-blob-4 { right: calc((100vw - var(--content-max)) / 2 + 10px); bottom: 6%; width: 180px; height: 180px; }
}
`; `;
// ─── Sheet card ─────────────────────────────────────────────────────────────── // ─── Sheet card ───────────────────────────────────────────────────────────────

View File

@ -33,6 +33,10 @@ const DOTS = [
const STYLES = ` const STYLES = `
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap');
:root { --content-max: 1100px; }
:root { --content-max: 1100px; }
.ls-screen { .ls-screen {
min-height: 100vh; min-height: 100vh;
background: #fffbf4; background: #fffbf4;
@ -41,6 +45,13 @@ const STYLES = `
overflow-x: hidden; overflow-x: hidden;
} }
/* On desktop, account for sidebar */
@media (min-width: 768px) {
.ls-screen {
padding-left: calc(17rem + 1.25rem);
}
}
.ls-blob { position:fixed; pointer-events:none; z-index:0; filter:blur(48px); opacity:0.35; } .ls-blob { position:fixed; pointer-events:none; z-index:0; filter:blur(48px); opacity:0.35; }
.ls-blob-1 { width:240px;height:240px;background:#fde68a;top:-80px;left:-80px;border-radius:60% 40% 70% 30%/50% 60% 40% 50%;animation:lsWobble1 14s ease-in-out infinite; } .ls-blob-1 { width:240px;height:240px;background:#fde68a;top:-80px;left:-80px;border-radius:60% 40% 70% 30%/50% 60% 40% 50%;animation:lsWobble1 14s ease-in-out infinite; }
.ls-blob-2 { width:190px;height:190px;background:#a5f3c0;bottom:-50px;left:6%;border-radius:40% 60% 30% 70%/60% 40% 60% 40%;animation:lsWobble2 16s ease-in-out infinite; } .ls-blob-2 { width:190px;height:190px;background:#a5f3c0;bottom:-50px;left:6%;border-radius:40% 60% 30% 70%/60% 40% 60% 40%;animation:lsWobble2 16s ease-in-out infinite; }
@ -69,6 +80,14 @@ const STYLES = `
display:flex; flex-direction:column; gap:1.5rem; display:flex; flex-direction:column; gap:1.5rem;
} }
/* Desktop: wider centered layout matching rewards page */
@media (min-width: 900px) {
.ls-inner {
max-width: var(--content-max);
padding: 2rem 2rem 6rem;
}
}
@keyframes lsPopIn { @keyframes lsPopIn {
from { opacity:0; transform:scale(0.92) translateY(12px); } from { opacity:0; transform:scale(0.92) translateY(12px); }
to { opacity:1; transform:scale(1) translateY(0); } to { opacity:1; transform:scale(1) translateY(0); }
@ -453,12 +472,15 @@ export const Lessons = () => {
const [activeTab, setActiveTab] = useState<"rw" | "math" | "video">("rw"); const [activeTab, setActiveTab] = useState<"rw" | "math" | "video">("rw");
const [videoSubTab, setVideoSubTab] = useState<VideoSubTab>("rw"); const [videoSubTab, setVideoSubTab] = useState<VideoSubTab>("rw");
const [selectedLessonId, setSelectedLessonId] = useState<string | null>(null); const [selectedLessonData, setSelectedLessonData] = useState<{
id: string | null;
name: string | null;
}>({ id: null, name: null });
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const handleLessonClick = (id: string) => { const handleLessonClick = (id: string, name: string) => {
setSelectedLessonId(id); setSelectedLessonData({ id, name });
setIsModalOpen(true); setIsModalOpen(true);
}; };
@ -469,7 +491,9 @@ export const Lessons = () => {
setLessonLoading(true); setLessonLoading(true);
const authStorage = localStorage.getItem("auth-storage"); const authStorage = localStorage.getItem("auth-storage");
if (!authStorage) return; if (!authStorage) return;
const { const {
// @ts-ignore
state: { token }, state: { token },
} = JSON.parse(authStorage) as { state?: { token?: string } }; } = JSON.parse(authStorage) as { state?: { token?: string } };
if (!token) return; if (!token) return;
@ -552,7 +576,7 @@ export const Lessons = () => {
<div <div
key={lesson.id} key={lesson.id}
className="ls-lesson-row" className="ls-lesson-row"
onClick={() => handleLessonClick(lesson.id)} onClick={() => handleLessonClick(lesson.id, lesson.title)}
> >
<span className="ls-row-num"> <span className="ls-row-num">
{String(li + 1).padStart(2, "0")} {String(li + 1).padStart(2, "0")}
@ -609,7 +633,7 @@ export const Lessons = () => {
lesson={lesson} lesson={lesson}
index={i} index={i}
searchQuery={searchQuery} searchQuery={searchQuery}
onClick={() => handleLessonClick(lesson.id)} onClick={() => handleLessonClick(lesson.id, lesson.title)}
/> />
))} ))}
</div> </div>
@ -751,10 +775,10 @@ export const Lessons = () => {
<LessonModal <LessonModal
open={isModalOpen} open={isModalOpen}
lessonId={selectedLessonId} selectedLessonData={selectedLessonData}
onOpenChange={(open) => { onOpenChange={(open) => {
setIsModalOpen(open); setIsModalOpen(open);
if (!open) setSelectedLessonId(null); if (!open) setSelectedLessonData({ id: null, name: null });
}} }}
/> />
</div> </div>

View File

@ -8,8 +8,6 @@ import {
Zap, Zap,
} from "lucide-react"; } from "lucide-react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useExamConfigStore } from "../../stores/useExamConfigStore";
import { LevelBar } from "../../components/LevelBar";
import { InfoHeader } from "../../components/InfoHeader"; import { InfoHeader } from "../../components/InfoHeader";
const DOTS = [ const DOTS = [
@ -24,6 +22,8 @@ const DOTS = [
const STYLES = ` const STYLES = `
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap');
:root { --content-max: 1100px; }
.pr-screen { .pr-screen {
min-height: 100vh; min-height: 100vh;
background: #fffbf4; background: #fffbf4;
@ -32,6 +32,13 @@ const STYLES = `
overflow-x: hidden; overflow-x: hidden;
} }
/* On desktop, account for sidebar */
@media (min-width: 768px) {
.pr-screen {
padding-left: calc(17rem + 1.25rem);
}
}
/* ── Blobs ── */ /* ── Blobs ── */
.pr-blob { position: fixed; pointer-events: none; z-index: 0; filter: blur(48px); opacity: 0.35; } .pr-blob { position: fixed; pointer-events: none; z-index: 0; filter: blur(48px); opacity: 0.35; }
.pr-blob-1 { width:240px;height:240px;background:#fde68a;top:-80px;left:-80px;border-radius:60% 40% 70% 30%/50% 60% 40% 50%;animation:prWobble1 14s ease-in-out infinite; } .pr-blob-1 { width:240px;height:240px;background:#fde68a;top:-80px;left:-80px;border-radius:60% 40% 70% 30%/50% 60% 40% 50%;animation:prWobble1 14s ease-in-out infinite; }
@ -63,6 +70,20 @@ const STYLES = `
display: flex; flex-direction: column; gap: 1.5rem; display: flex; flex-direction: column; gap: 1.5rem;
} }
/* Desktop / wide layout */
@media (min-width: 900px) {
.pr-inner { max-width: var(--content-max); padding: 3rem 1.5rem 6rem; }
.pr-grid { grid-template-columns: repeat(3, 1fr); gap: 1rem; }
.pr-blob-1 { left: calc((100vw - var(--content-max)) / 2 - 120px); top: -120px; width: 300px; height: 300px; }
.pr-blob-2 { left: calc((100vw - var(--content-max)) / 2 + 20px); bottom: -80px; width: 220px; height: 220px; }
.pr-blob-3 { right: calc((100vw - var(--content-max)) / 2 - 40px); top: 10%; width: 260px; height: 260px; }
.pr-blob-4 { right: calc((100vw - var(--content-max)) / 2 + 10px); bottom: 6%; width: 180px; height: 180px; }
.pr-hero { padding: 2rem; }
.pr-hero-icon-bg { right: -60px; top: -40px; opacity: 0.12; }
}
/* ── Animations ── */ /* ── Animations ── */
@keyframes prPopIn { @keyframes prPopIn {
from { opacity:0; transform: scale(0.92) translateY(12px); } from { opacity:0; transform: scale(0.92) translateY(12px); }

View File

@ -19,6 +19,8 @@ const DOTS = [
const STYLES = ` const STYLES = `
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap');
:root { --content-max: 1100px; }
.pf-screen { .pf-screen {
min-height: 100vh; min-height: 100vh;
background: #fffbf4; background: #fffbf4;
@ -27,6 +29,13 @@ const STYLES = `
overflow-x: hidden; overflow-x: hidden;
} }
/* On desktop, account for sidebar */
@media (min-width: 768px) {
.pf-screen {
padding-left: calc(17rem + 1.25rem);
}
}
.pf-blob { position: fixed; pointer-events: none; z-index: 0; filter: blur(48px); opacity: 0.35; } .pf-blob { position: fixed; pointer-events: none; z-index: 0; filter: blur(48px); opacity: 0.35; }
.pf-blob-1 { width:240px;height:240px;background:#fde68a;top:-80px;left:-80px;border-radius:60% 40% 70% 30%/50% 60% 40% 50%;animation:pfWobble1 14s ease-in-out infinite; } .pf-blob-1 { width:240px;height:240px;background:#fde68a;top:-80px;left:-80px;border-radius:60% 40% 70% 30%/50% 60% 40% 50%;animation:pfWobble1 14s ease-in-out infinite; }
.pf-blob-2 { width:190px;height:190px;background:#a5f3c0;bottom:-50px;left:6%;border-radius:40% 60% 30% 70%/60% 40% 60% 40%;animation:pfWobble2 16s ease-in-out infinite; } .pf-blob-2 { width:190px;height:190px;background:#a5f3c0;bottom:-50px;left:6%;border-radius:40% 60% 30% 70%/60% 40% 60% 40%;animation:pfWobble2 16s ease-in-out infinite; }
@ -55,6 +64,34 @@ const STYLES = `
display: flex; flex-direction: column; gap: 1.5rem; display: flex; flex-direction: column; gap: 1.5rem;
} }
/* Desktop / web layout: wider container and two-column grid */
@media (min-width: 900px) {
.pf-inner {
max-width: var(--content-max);
padding: 2.5rem 2.5rem 4rem;
display: grid;
grid-template-columns: 1fr 420px;
grid-template-rows: auto;
gap: 1.5rem 2rem;
align-items: start;
}
/* Hero spans full width */
.pf-hero { grid-column: 1 / -1; display: flex; align-items: center; gap: 1.5rem; }
/* Keep page title centered across the full layout */
.pf-page-title { grid-column: 1 / -1; }
/* Place first section (Account) in left column */
.pf-inner > section:nth-of-type(1) { grid-column: 1 / 2; }
/* Right column wrapper (Legal + Support) */
.pf-right-col { grid-column: 2 / 3; display: flex; flex-direction: column; gap: 1.5rem; }
/* Make signout button centered and constrained width */
.pf-signout-btn { grid-column: 1 / -1; justify-self: center; width: 420px; }
}
@keyframes pfPopIn { @keyframes pfPopIn {
from { opacity:0; transform: scale(0.92) translateY(12px); } from { opacity:0; transform: scale(0.92) translateY(12px); }
to { opacity:1; transform: scale(1) translateY(0); } to { opacity:1; transform: scale(1) translateY(0); }
@ -307,17 +344,18 @@ export const Profile = () => {
<SettingsGroup rows={ACCOUNT_ROWS} /> <SettingsGroup rows={ACCOUNT_ROWS} />
</section> </section>
{/* Legal */} {/* Right column: Legal + Support (stacked) */}
<section className="pf-section pf-anim pf-anim-3"> <div className="pf-right-col pf-anim pf-anim-3">
<section className="pf-section">
<p className="pf-section-label">Legal</p> <p className="pf-section-label">Legal</p>
<SettingsGroup rows={LEGAL_ROWS} /> <SettingsGroup rows={LEGAL_ROWS} />
</section> </section>
{/* Support */} <section className="pf-section">
<section className="pf-section pf-anim pf-anim-4">
<p className="pf-section-label">Support</p> <p className="pf-section-label">Support</p>
<SettingsGroup rows={SUPPORT_ROWS} /> <SettingsGroup rows={SUPPORT_ROWS} />
</section> </section>
</div>
{/* Sign out */} {/* Sign out */}
<button <button

File diff suppressed because it is too large Load Diff

View File

@ -33,6 +33,8 @@ const DOTS = [
const STYLES = ` const STYLES = `
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap');
:root { --content-max: 1100px; }
.rw-screen { .rw-screen {
height: 100vh; height: 100vh;
background: #fffbf4; background: #fffbf4;
@ -43,6 +45,13 @@ const STYLES = `
overflow: hidden; overflow: hidden;
} }
/* On desktop, account for sidebar */
@media (min-width: 768px) {
.rw-screen {
padding-left: calc(17rem + 1.25rem);
}
}
.rw-blob { position:fixed;pointer-events:none;z-index:0;filter:blur(48px);opacity:0.35; } .rw-blob { position:fixed;pointer-events:none;z-index:0;filter:blur(48px);opacity:0.35; }
.rw-blob-1 { width:240px;height:240px;background:#fde68a;top:-80px;left:-80px;border-radius:60% 40% 70% 30%/50% 60% 40% 50%;animation:rwWobble1 14s ease-in-out infinite; } .rw-blob-1 { width:240px;height:240px;background:#fde68a;top:-80px;left:-80px;border-radius:60% 40% 70% 30%/50% 60% 40% 50%;animation:rwWobble1 14s ease-in-out infinite; }
.rw-blob-2 { width:190px;height:190px;background:#a5f3c0;bottom:-50px;left:6%;border-radius:40% 60% 30% 70%/60% 40% 60% 40%;animation:rwWobble2 16s ease-in-out infinite; } .rw-blob-2 { width:190px;height:190px;background:#a5f3c0;bottom:-50px;left:6%;border-radius:40% 60% 30% 70%/60% 40% 60% 40%;animation:rwWobble2 16s ease-in-out infinite; }
@ -66,7 +75,6 @@ const STYLES = `
.rw-sticky-top { .rw-sticky-top {
position:relative;z-index:2; position:relative;z-index:2;
background:#fffbf4;
flex-shrink:0; flex-shrink:0;
padding:2rem 1.25rem 0; padding:2rem 1.25rem 0;
} }
@ -85,6 +93,26 @@ const STYLES = `
} }
.rw-scroll-inner { max-width:580px;margin:0 auto; } .rw-scroll-inner { max-width:580px;margin:0 auto; }
/* Desktop: wider centered layout */
@media (min-width: 900px) {
.rw-sticky-top-inner { max-width: var(--content-max); padding: 2rem 2rem 1.25rem; }
.rw-scroll-inner { max-width: var(--content-max); }
.rw-scroll-area { padding: 1.5rem 2.5rem 10rem; }
/* Make empty state sit visually centered within larger canvas */
.rw-empty { padding: 5rem 1rem; }
/* Slightly larger island pill on wide screens and rebalance blobs */
.rw-island-wrap { max-width: 420px; left:auto; right:calc((100vw - 256px - var(--content-max)) / 2); top:240px; bottom:auto; transform:none; margin-left:25px; }
.rw-island-card { gap: 0.75rem; }
/* Rebalance decorative blobs on wide screens */
.rw-blob-1 { left: -120px; top: -100px; width:300px; height:300px; }
.rw-blob-2 { left: 6%; bottom: -40px; }
.rw-blob-3 { right: -100px; top: 8%; width:260px; height:260px; }
.rw-blob-4 { right: 2%; bottom: 6%; }
}
@keyframes rwPopIn { @keyframes rwPopIn {
from{opacity:0;transform:scale(0.92) translateY(12px);} from{opacity:0;transform:scale(0.92) translateY(12px);}
to{opacity:1;transform:scale(1) translateY(0);} to{opacity:1;transform:scale(1) translateY(0);}
@ -177,8 +205,26 @@ const STYLES = `
flex-direction:column; flex-direction:column;
align-items:center; align-items:center;
gap:0.5rem; gap:0.5rem;
width:calc(100% - 2rem); width:auto;
max-width:300px; max-width:300px;
top:auto;
}
/* Tablet/small desktop: shift pill right to avoid sidebar overlap */
@media (min-width: 768px) and (max-width: 1200px) {
.rw-island-wrap {
left: calc(17rem + 10rem); /* sidebar width + gap */
transform: none;
align-items: flex-start;
}
}
/* Tablet/small desktop: shift pill right to avoid sidebar overlap */
@media (min-width: 1200px) {
.rw-island-wrap {
left: 50%;
transform: none;
align-items: flex-start;
}
} }
.rw-island-card { .rw-island-card {
@ -388,9 +434,10 @@ export const Rewards = () => {
if (!user) return; if (!user) return;
const authStorage = localStorage.getItem("auth-storage"); const authStorage = localStorage.getItem("auth-storage");
if (!authStorage) return; if (!authStorage) return;
const { const parsed = JSON.parse(authStorage) as {
state: { token }, state?: { token?: string };
} = JSON.parse(authStorage) as { state?: { token?: string } }; } | null;
const token = parsed?.state?.token;
if (!token) return; if (!token) return;
try { try {
setLoading(true); setLoading(true);
@ -435,7 +482,7 @@ export const Rewards = () => {
// ✅ FIX 2: Safely cast user_rank — null becomes undefined so all optional chaining works // ✅ FIX 2: Safely cast user_rank — null becomes undefined so all optional chaining works
const ur = (leaderboard?.user_rank ?? undefined) as const ur = (leaderboard?.user_rank ?? undefined) as
| Record<string, unknown> | Record<string, number>
| undefined; | undefined;
const islandStats = getIslandStats(ur, activeTab); const islandStats = getIslandStats(ur, activeTab);

View File

@ -1,6 +1,6 @@
import { Outlet, NavLink, useLocation } from "react-router-dom"; import { Outlet, NavLink, useLocation } from "react-router-dom";
import { Home, BookOpen, Award, User, Video, Map } from "lucide-react"; import { Home, BookOpen, Award, User, Map, SquareLibrary } from "lucide-react";
import { SidebarProvider, SidebarTrigger } from "../../components/ui/sidebar"; import { SidebarProvider } from "../../components/ui/sidebar";
import { AppSidebar } from "../../components/AppSidebar"; import { AppSidebar } from "../../components/AppSidebar";
const NAV_ITEMS = [ const NAV_ITEMS = [
@ -27,7 +27,7 @@ const NAV_ITEMS = [
}, },
{ {
to: "/student/lessons", to: "/student/lessons",
icon: Video, icon: SquareLibrary,
label: "Lessons", label: "Lessons",
color: "#0891b2", color: "#0891b2",
bg: "rgba(8,145,178,0.12)", bg: "rgba(8,145,178,0.12)",
@ -174,6 +174,11 @@ const STYLES = `
opacity: 1; opacity: 1;
} }
/* Ensure the dock is hidden on desktop (md and up) */
@media (min-width: 768px) {
.sl-dock-wrap { display: none !important; }
}
/* Quest mode: active label uses Cinzel for the pirate feel */ /* Quest mode: active label uses Cinzel for the pirate feel */
.quest-mode .sl-dock-item.active .sl-dock-label { .quest-mode .sl-dock-item.active .sl-dock-label {
font-family: 'Sorts Mill Goudy', serif; font-family: 'Sorts Mill Goudy', serif;
@ -190,6 +195,11 @@ const STYLES = `
.quest-mode .sl-dock-item:not(.active):hover .sl-dock-icon { .quest-mode .sl-dock-item:not(.active):hover .sl-dock-icon {
opacity: 0.85; opacity: 0.85;
} }
/* Ensure the dock is hidden on desktop (md and up) */
@media (min-width: 768px) {
.sl-dock-wrap { display: none !important; }
}
`; `;
export function StudentLayout() { export function StudentLayout() {
@ -204,10 +214,12 @@ export function StudentLayout() {
<style>{STYLES}</style> <style>{STYLES}</style>
<div className="flex min-h-screen w-full overflow-x-hidden"> <div className="flex min-h-screen w-full overflow-x-hidden">
{/* Desktop Sidebar */} {/* Desktop Sidebar */}
<div className="hidden md:block">
<AppSidebar /> <AppSidebar />
</div>
<div className="flex flex-col flex-1 min-w-0"> <div className="flex flex-col flex-1 min-w-0">
<SidebarTrigger className="hidden md:block" /> {/* Extra bottom padding so content clears the floating dock */}
<main className="flex-1 md:pb-0"> <main className="flex-1 md:pb-0">
<Outlet /> <Outlet />
</main> </main>

View File

@ -24,6 +24,8 @@ const DOTS = [
const STYLES = ` const STYLES = `
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap');
:root { --content-max: 1100px; }
.dr-screen { .dr-screen {
min-height: 100vh; min-height: 100vh;
background: #fffbf4; background: #fffbf4;
@ -32,6 +34,13 @@ const STYLES = `
overflow-x: hidden; overflow-x: hidden;
} }
/* On desktop, account for sidebar */
@media (min-width: 768px) {
.dr-screen {
padding-left: calc(17rem + 1.25rem);
}
}
.dr-blob { position:fixed;pointer-events:none;z-index:0;filter:blur(48px);opacity:0.35; } .dr-blob { position:fixed;pointer-events:none;z-index:0;filter:blur(48px);opacity:0.35; }
.dr-blob-1 { width:240px;height:240px;background:#fde68a;top:-80px;left:-80px;border-radius:60% 40% 70% 30%/50% 60% 40% 50%;animation:drWobble1 14s ease-in-out infinite; } .dr-blob-1 { width:240px;height:240px;background:#fde68a;top:-80px;left:-80px;border-radius:60% 40% 70% 30%/50% 60% 40% 50%;animation:drWobble1 14s ease-in-out infinite; }
.dr-blob-2 { width:190px;height:190px;background:#a5f3fc;bottom:-50px;left:6%;border-radius:40% 60% 30% 70%/60% 40% 60% 40%;animation:drWobble2 16s ease-in-out infinite; } .dr-blob-2 { width:190px;height:190px;background:#a5f3fc;bottom:-50px;left:6%;border-radius:40% 60% 30% 70%/60% 40% 60% 40%;animation:drWobble2 16s ease-in-out infinite; }
@ -213,14 +222,31 @@ const STYLES = `
/* CTA bar */ /* CTA bar */
.dr-cta-bar { .dr-cta-bar {
position:fixed;bottom:96px;left:0;right:0;z-index:10; position: fixed;
bottom: 96px;
left: 0;
right: 0;
z-index: 5;
padding: 0.85rem 1.25rem calc(0.85rem + env(safe-area-inset-bottom)); padding: 0.85rem 1.25rem calc(0.85rem + env(safe-area-inset-bottom));
} }
.dr-cta-inner { .dr-cta-inner {
max-width:560px;margin:0 auto; max-width: 560px;
display:flex;gap:0.75rem;align-items:center; margin: 0 auto;
display: flex;
gap: 0.75rem;
align-items: center;
}
@media (min-width: 900px) {
.dr-inner { max-width: var(--content-max); padding: 3rem 1.5rem 10rem; }
.dr-topic-grid { grid-template-columns: repeat(3, 1fr); gap: 0.75rem; }
.dr-cta-bar { left: var(--sidebar-width); right: 0; }
/* Align decorative blobs relative to the centered content container */
.dr-blob-3 { right: calc((100vw - var(--content-max)) / 2 - 48px); }
.dr-blob-1 { left: calc((100vw - var(--content-max)) / 2 - 56px); }
.dr-blob-2 { left: calc((100vw - var(--content-max)) / 2 + 12px); }
.dr-blob-4 { right: calc((100vw - var(--content-max)) / 2 + 12px); }
} }
.dr-next-btn { .dr-next-btn {
@ -298,9 +324,10 @@ export const Drills = () => {
setLoading(true); setLoading(true);
const authStorage = localStorage.getItem("auth-storage"); const authStorage = localStorage.getItem("auth-storage");
if (!authStorage) return; if (!authStorage) return;
const { const parsed = JSON.parse(authStorage) as {
state: { token }, state?: { token?: string };
} = JSON.parse(authStorage) as { state?: { token?: string } }; } | null;
const token = parsed?.state?.token;
if (!token) return; if (!token) return;
const response = await api.fetchAllTopics(token); const response = await api.fetchAllTopics(token);
setTopics(response); setTopics(response);

View File

@ -26,6 +26,8 @@ const DOTS = [
const STYLES = ` const STYLES = `
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap');
:root { --content-max: 1100px; }
.htm-screen { .htm-screen {
min-height: 100vh; min-height: 100vh;
background: #fffbf4; background: #fffbf4;
@ -34,6 +36,13 @@ const STYLES = `
overflow-x: hidden; overflow-x: hidden;
} }
/* On desktop, account for sidebar */
@media (min-width: 768px) {
.htm-screen {
padding-left: calc(17rem + 1.25rem);
}
}
.htm-blob { position:fixed;pointer-events:none;z-index:0;filter:blur(48px);opacity:0.35; } .htm-blob { position:fixed;pointer-events:none;z-index:0;filter:blur(48px);opacity:0.35; }
.htm-blob-1 { width:240px;height:240px;background:#fde68a;top:-80px;left:-80px;border-radius:60% 40% 70% 30%/50% 60% 40% 50%;animation:htmWobble1 14s ease-in-out infinite; } .htm-blob-1 { width:240px;height:240px;background:#fde68a;top:-80px;left:-80px;border-radius:60% 40% 70% 30%/50% 60% 40% 50%;animation:htmWobble1 14s ease-in-out infinite; }
.htm-blob-2 { width:190px;height:190px;background:#a5f3c0;bottom:-50px;left:6%;border-radius:40% 60% 30% 70%/60% 40% 60% 40%;animation:htmWobble2 16s ease-in-out infinite; } .htm-blob-2 { width:190px;height:190px;background:#a5f3c0;bottom:-50px;left:6%;border-radius:40% 60% 30% 70%/60% 40% 60% 40%;animation:htmWobble2 16s ease-in-out infinite; }
@ -179,16 +188,35 @@ const STYLES = `
/* CTA bar */ /* CTA bar */
.htm-cta-bar { .htm-cta-bar {
position: fixed; bottom: 96px; left: 0; right: 0; z-index: 10; position: fixed;
bottom: 96px;
left: 0;
right: 0;
z-index: 10;
padding: 0.85rem 1.25rem calc(0.85rem + env(safe-area-inset-bottom)); padding: 0.85rem 1.25rem calc(0.85rem + env(safe-area-inset-bottom));
transition: transform 0.3s cubic-bezier(0.34,1.56,0.64,1), opacity 0.25s ease; transition: transform 0.3s cubic-bezier(0.34,1.56,0.64,1), opacity 0.25s ease;
} }
.htm-cta-bar.hidden { .htm-cta-bar.hidden {
transform: translateY(100%); opacity: 0; pointer-events: none; transform: translateY(100%); opacity: 0; pointer-events: none;
} }
.htm-cta-inner { .htm-cta-inner {
max-width: 560px; margin: 0 auto; max-width: 560px;
margin: 0 auto;
}
@media (min-width: 900px) {
.htm-inner { max-width: var(--content-max); padding: 3rem 1.5rem 10rem; }
.htm-cta-bar { left: var(--sidebar-width); right: 0; }
.htm-cta-inner { max-width: var(--content-max); margin: 0 auto; }
/* align blobs to centered content */
.htm-blob-3 { right: calc((100vw - var(--content-max)) / 2 - 48px); }
.htm-blob-1 { left: calc((100vw - var(--content-max)) / 2 - 56px); }
.htm-blob-2 { left: calc((100vw - var(--content-max)) / 2 + 12px); }
.htm-blob-4 { right: calc((100vw - var(--content-max)) / 2 + 12px); }
/* make module cards slightly wider on desktop */
.htm-card { min-height: 220px; }
} }
.htm-start-btn { .htm-start-btn {

View File

@ -47,9 +47,9 @@ function FormulaCard({
/> />
</div> </div>
<div <div
className={`transition-all duration-300 ease-in-out ${open ? "max-h-[400px] opacity-100" : "max-h-0 opacity-0"}`} className={`transition-all duration-300 ease-in-out ${open ? "max-h-100 opacity-100" : "max-h-0 opacity-0"}`}
> >
<div className="border-t border-emerald-100 px-5 py-4 flex flex-col sm:flex-row items-center gap-5 bg-gradient-to-br from-emerald-50/50 to-white/80"> <div className="border-t border-emerald-100 px-5 py-4 flex flex-col sm:flex-row items-center gap-5 bg-linear-to-br from-emerald-50/50 to-white/80">
<div className="shrink-0">{diagram}</div> <div className="shrink-0">{diagram}</div>
<div className="text-sm text-slate-600 space-y-1 font-mono"> <div className="text-sm text-slate-600 space-y-1 font-mono">
{example} {example}

View File

@ -81,7 +81,7 @@ const CirclePropertiesLesson: React.FC<LessonProps> = ({ onFinish }) => {
return ( return (
<div className="flex flex-col lg:flex-row min-h-screen"> <div className="flex flex-col lg:flex-row min-h-screen">
<aside className="w-full lg:w-64 lg:fixed lg:top-20 lg:bottom-0 lg:overflow-y-auto p-4 border-r border-slate-200 bg-slate-50 z-0 hidden lg:block"> <aside className="w-full lg:w-64 lg:fixed lg:top-20 lg:bottom-0 lg:overflow-y-auto p-4 border-r border-slate-200 bg-slate-50 lg:bg-transparent z-0 hidden lg:block">
<nav className="space-y-2"> <nav className="space-y-2">
<SectionMarker index={0} title="Central vs Inscribed" icon={Target} /> <SectionMarker index={0} title="Central vs Inscribed" icon={Target} />
<SectionMarker index={1} title="Tangents" icon={Layers} /> <SectionMarker index={1} title="Tangents" icon={Layers} />
@ -469,7 +469,7 @@ const CirclePropertiesLesson: React.FC<LessonProps> = ({ onFinish }) => {
<h2 className="text-4xl font-extrabold text-slate-900 mb-8"> <h2 className="text-4xl font-extrabold text-slate-900 mb-8">
Practice Time Practice Time
</h2> </h2>
{CIRCLE_PROP_QUIZ_DATA.map((quiz, idx) => ( {CIRCLE_PROP_QUIZ_DATA.map((quiz) => (
<div key={quiz.id} className="mb-12"> <div key={quiz.id} className="mb-12">
<Quiz data={quiz} /> <Quiz data={quiz} />
</div> </div>

View File

@ -1,4 +1,3 @@
import React from "react";
import { import {
Circle, Circle,
Target, Target,

View File

@ -92,7 +92,7 @@ const CollectingDataLesson: React.FC<LessonProps> = ({ onFinish }) => {
return ( return (
<div className="flex flex-col lg:flex-row min-h-screen"> <div className="flex flex-col lg:flex-row min-h-screen">
<aside className="w-full lg:w-64 lg:fixed lg:top-20 lg:bottom-0 lg:overflow-y-auto p-4 border-r border-slate-200 bg-slate-50 z-0 hidden lg:block"> <aside className="w-full lg:w-64 lg:fixed lg:top-20 lg:bottom-0 lg:overflow-y-auto p-4 border-r border-slate-200 bg-slate-50 lg:bg-transparent z-0 hidden lg:block">
<nav className="space-y-2"> <nav className="space-y-2">
<SectionMarker index={0} title="Sampling & Bias" icon={Scale} /> <SectionMarker index={0} title="Sampling & Bias" icon={Scale} />
<SectionMarker index={1} title="Study Design" icon={Layers} /> <SectionMarker index={1} title="Study Design" icon={Layers} />

View File

@ -80,7 +80,7 @@ const CongruenceSimilarityLesson: React.FC<LessonProps> = ({ onFinish }) => {
return ( return (
<div className="flex flex-col lg:flex-row min-h-screen"> <div className="flex flex-col lg:flex-row min-h-screen">
<aside className="w-full lg:w-64 lg:fixed lg:top-20 lg:bottom-0 lg:overflow-y-auto p-4 border-r border-slate-200 bg-slate-50 z-0 hidden lg:block"> <aside className="w-full lg:w-64 lg:fixed lg:top-20 lg:bottom-0 lg:overflow-y-auto p-4 border-r border-slate-200 bg-slate-50 lg:bg-transparent z-0 hidden lg:block">
<nav className="space-y-2"> <nav className="space-y-2">
<SectionMarker index={0} title="Congruence Tests" icon={Target} /> <SectionMarker index={0} title="Congruence Tests" icon={Target} />
<SectionMarker index={1} title="Similarity Tests" icon={Layers} /> <SectionMarker index={1} title="Similarity Tests" icon={Layers} />
@ -490,7 +490,7 @@ const CongruenceSimilarityLesson: React.FC<LessonProps> = ({ onFinish }) => {
<h2 className="text-4xl font-extrabold text-slate-900 mb-8"> <h2 className="text-4xl font-extrabold text-slate-900 mb-8">
Practice Time Practice Time
</h2> </h2>
{SIMILARITY_QUIZ_DATA.map((quiz, idx) => ( {SIMILARITY_QUIZ_DATA.map((quiz) => (
<div key={quiz.id} className="mb-12"> <div key={quiz.id} className="mb-12">
<Quiz data={quiz} /> <Quiz data={quiz} />
</div> </div>

View File

@ -114,7 +114,7 @@ const DataAnalysisLesson: React.FC<LessonProps> = ({ onFinish }) => {
return ( return (
<div className="flex flex-col lg:flex-row min-h-screen"> <div className="flex flex-col lg:flex-row min-h-screen">
<aside className="w-full lg:w-64 lg:fixed lg:top-20 lg:bottom-0 lg:overflow-y-auto p-4 border-r border-slate-200 bg-slate-50 z-0 hidden lg:block"> <aside className="w-full lg:w-64 lg:fixed lg:top-20 lg:bottom-0 lg:overflow-y-auto p-4 border-r border-slate-200 bg-slate-50 lg:bg-transparent z-0 hidden lg:block">
<nav className="space-y-2"> <nav className="space-y-2">
<SectionMarker index={0} title="Data Changes" icon={Calculator} /> <SectionMarker index={0} title="Data Changes" icon={Calculator} />
<SectionMarker index={1} title="Distributions" icon={BarChart3} /> <SectionMarker index={1} title="Distributions" icon={BarChart3} />
@ -266,7 +266,7 @@ const DataAnalysisLesson: React.FC<LessonProps> = ({ onFinish }) => {
<h2 className="text-4xl font-extrabold text-slate-900 mb-8"> <h2 className="text-4xl font-extrabold text-slate-900 mb-8">
Practice Time Practice Time
</h2> </h2>
{DATA_ANALYSIS_QUIZ_DATA.map((quiz, idx) => ( {DATA_ANALYSIS_QUIZ_DATA.map((quiz) => (
<div key={quiz.id} className="mb-12"> <div key={quiz.id} className="mb-12">
<Quiz data={quiz} /> <Quiz data={quiz} />
</div> </div>

View File

@ -116,7 +116,7 @@ const DataRepresentationLesson: React.FC<LessonProps> = ({ onFinish }) => {
return ( return (
<div className="flex flex-col lg:flex-row min-h-screen"> <div className="flex flex-col lg:flex-row min-h-screen">
<aside className="w-full lg:w-64 lg:fixed lg:top-20 lg:bottom-0 lg:overflow-y-auto p-4 border-r border-slate-200 bg-slate-50 z-0 hidden lg:block"> <aside className="w-full lg:w-64 lg:fixed lg:top-20 lg:bottom-0 lg:overflow-y-auto p-4 border-r border-slate-200 bg-slate-50 lg:bg-transparent z-0 hidden lg:block">
<nav className="space-y-2"> <nav className="space-y-2">
<SectionMarker index={0} title="Frequency & Mean" icon={Calculator} /> <SectionMarker index={0} title="Frequency & Mean" icon={Calculator} />
<SectionMarker index={1} title="Histograms" icon={BarChart3} /> <SectionMarker index={1} title="Histograms" icon={BarChart3} />

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,773 @@
import React, { useRef, useState, useEffect } from "react";
import { Check, BookOpen, Lightbulb, Zap, Target } from "lucide-react";
import { PracticeFromDataset } from "../../../components/lessons/LessonShell";
import {
CENTRAL_IDEAS_EASY,
CENTRAL_IDEAS_MEDIUM,
} from "../../../data/rw/central-ideas-details";
import EvidenceHunterWidget, {
type EvidenceExercise,
} from "../../../components/lessons/EvidenceHunterWidget";
import RevealCardGrid, {
type RevealCard,
} from "../../../components/lessons/RevealCardGrid";
import useScrollReveal from "../../../components/lessons/useScrollReveal";
interface LessonProps {
onFinish?: () => void;
}
/* ── Data for RevealCardGrid widgets ── */
const WRONG_ANSWER_TAXONOMY: RevealCard[] = [
{
label: "Off-topic",
sublabel: "Is this noun/idea actually in the lines?",
content:
"Mentions something never in the passage or unrelated to the topic.",
},
{
label: "Too broad",
sublabel: "Does answer scope match passage scope?",
content:
'Passage focuses on ONE person but answer says "scientists" or "artists" (plural).',
},
{
label: "Too narrow",
sublabel: "Is this the MAIN thing or just one example?",
content: "Picks a supporting detail instead of the overall idea.",
},
{
label: "Half-right, half-wrong",
sublabel: "Read every word in the answer.",
content:
"First half matches, second half contains a false or unsupported claim.",
},
{
label: "Could-be-true",
sublabel: "Can I point to specific words?",
content:
"Plausible in the real world, but not stated or implied in the text.",
},
{
label: "Wrong scope for purpose",
sublabel: "Focus on what the WHOLE passage does.",
content: "Describes what the passage mentions but not its primary goal.",
},
];
const STRUCTURE_PATTERNS: RevealCard[] = [
{
label: "Old Idea → New Idea",
content: "Challenge/revision: most common in science passages",
},
{
label: "Problem → Solution",
content: "A challenge is identified, then an approach is described",
},
{
label: "Claim → Supporting Evidence",
content: "The main point is stated upfront, followed by examples",
},
{
label: "Description → Implication",
content: "A scenario is described, then its significance is analyzed",
},
{
label: "Comparison / Contrast",
content: "Two entities or views are presented side by side",
},
];
const EVIDENCE_EXERCISES: EvidenceExercise[] = [
{
question: "Which sentence states the central idea of this passage?",
passage: [
"For decades, the standard treatment for depression has been antidepressant medication combined with talk therapy.",
"These approaches help many patients, but roughly one-third do not respond to first-line treatments.",
"Researchers are now investigating ketamine, an anesthetic, as a rapid-acting antidepressant.",
"Unlike traditional medications that take weeks to work, ketamine can reduce depressive symptoms within hours.",
"This suggests that the neuroscience of depression is far more complex — and far more treatable — than previously assumed.",
],
evidenceIndex: 4,
explanation:
'Sentence 5 is the "So What" — it draws a broader conclusion about what ketamine research implies about depression science. It\'s the central idea because it states what the author wants us to take away from all the preceding information.',
},
{
question:
"Which sentence best expresses the main point the author wants the reader to understand?",
passage: [
"Ancient Rome is often praised for its engineering feats: aqueducts, roads, and amphitheaters.",
"These structures have survived millennia and continue to function in some cases today.",
"Less celebrated is Rome's sophisticated financial system, which included credit, interest-bearing loans, and transferable debt.",
"Roman bankers financed trade across the Mediterranean, enabling commerce that would otherwise have been impossible.",
"The financial innovations of Rome were as consequential as its physical ones, yet history has largely ignored them.",
],
evidenceIndex: 4,
explanation:
'Sentence 5 is the thesis — the author\'s main argument that Roman financial innovation was equally important to physical engineering. The word "yet" signals this is the key contrast and the point the author most wants to make.',
},
];
const EBRWCentralIdeasLesson: React.FC<LessonProps> = ({ onFinish }) => {
const [activeSection, setActiveSection] = useState(0);
const sectionsRef = useRef<(HTMLElement | null)[]>([]);
useEffect(() => {
const observers: IntersectionObserver[] = [];
sectionsRef.current.forEach((el, idx) => {
if (!el) return;
const obs = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) setActiveSection(idx);
},
{ threshold: 0.3 },
);
obs.observe(el);
observers.push(obs);
});
return () => observers.forEach((o) => o.disconnect());
}, []);
useScrollReveal();
const scrollToSection = (index: number) => {
setActiveSection(index);
sectionsRef.current[index]?.scrollIntoView({ behavior: "smooth" });
};
const SectionMarker = ({
index,
title,
icon: Icon,
}: {
index: number;
title: string;
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
}) => {
const isActive = activeSection === index;
const isPast = activeSection > index;
return (
<button
onClick={() => scrollToSection(index)}
className={`flex items-center gap-3 p-3 w-full rounded-lg text-left transition-all ${isActive ? "bg-teal-50" : "hover:bg-slate-50"}`}
>
<div
className={`w-8 h-8 rounded-full flex items-center justify-center shrink-0
${isActive ? "bg-teal-600 text-white" : isPast ? "bg-teal-400 text-white" : "bg-slate-200 text-slate-500"}`}
>
{isPast ? (
<Check className="w-4 h-4" />
) : (
<Icon className="w-4 h-4" />
)}
</div>
<p
className={`text-sm font-bold ${isActive ? "text-teal-900" : "text-slate-600"}`}
>
{title}
</p>
</button>
);
};
return (
<div className="flex flex-col lg:flex-row min-h-screen">
<aside className="w-full lg:w-64 lg:fixed lg:top-14 lg:bottom-0 lg:overflow-y-auto p-4 border-r border-slate-200 bg-slate-50 z-0 hidden lg:block">
<nav className="space-y-2 pt-6">
<SectionMarker
index={0}
title="Topic &amp; Main Point"
icon={BookOpen}
/>
<SectionMarker
index={1}
title="Old/New &amp; Structure"
icon={Target}
/>
<SectionMarker
index={2}
title="Pronouns &amp; Compression"
icon={Lightbulb}
/>
<SectionMarker index={3} title="Main Point Hunter" icon={Target} />
<SectionMarker index={4} title="Practice Questions" icon={Zap} />
</nav>
</aside>
<div className="flex-1 lg:ml-64 md:p-12 max-w-full mx-auto">
{/* Section 0 — Topic & Main Point */}
<section
ref={(el) => {
sectionsRef.current[0] = el;
}}
className="min-h-screen flex flex-col justify-center mb-24 pt-20 lg:pt-0"
>
<div className="inline-flex items-center gap-2 bg-teal-100 text-teal-700 px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider mb-4 w-fit">
Information &amp; Ideas Domain 2
</div>
<h2 className="text-4xl font-extrabold text-slate-900 mb-2">
Central Ideas &amp; Details
</h2>
<p className="text-lg text-slate-500 mb-8">
Identify the main point, central claim, and overall structure and
eliminate every wrong answer type.
</p>
{/* 4A — Identifying the Topic */}
<div className="scroll-reveal stagger-1 rounded-2xl p-6 mb-8 bg-teal-50 border border-teal-200 space-y-4">
<h3 className="text-lg font-bold text-teal-900">
Identifying the Topic
</h3>
<p className="text-sm text-slate-700">
The topic is the person, thing, or idea that is the primary
subject of the passage. Correct answers to main idea questions
must reference the topic wrong answers often shift to a related
but different subject.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="card-tilt bg-white border border-teal-200 rounded-xl p-4">
<p className="font-bold text-teal-700 text-sm mb-2">
Key Principles
</p>
<ul className="space-y-1 text-xs text-slate-700">
<li>
The topic appears in sentence 12 in nearly every SAT
passage.
</li>
<li>
A topic is NOT the same as the theme. Topic = subject;
Theme = abstract lesson.
</li>
<li>
Recognize restatements: a computer "the machine," "the
invention," "the technology."
</li>
<li>
The topic word usually recurs throughout by name or via
pronoun/compression noun.
</li>
<li>
Off-topic answers are wrong even if every other word
matches the passage.
</li>
</ul>
</div>
<div className="card-tilt bg-white border border-red-100 rounded-xl p-4">
<p className="font-bold text-red-700 text-sm mb-2">
Common Mistakes
</p>
<ul className="space-y-1 text-xs text-slate-700">
<li>
Naming a category instead of the specific topic (e.g.,
"Japanese art" instead of "Otagaki Rengetsu's art").
</li>
<li>
Confusing a supporting detail mentioned in one sentence
for the main topic.
</li>
<li>
Missing pronoun shifts: "it" may change referent
mid-passage.
</li>
<li>
Selecting an answer that is too broad in scope the
passage discusses ONE scientist, not all scientists.
</li>
</ul>
</div>
</div>
</div>
{/* 4B — Main Point Formula */}
<div className="scroll-reveal stagger-2 rounded-2xl p-6 mb-8 bg-white border border-slate-200 space-y-4">
<h3 className="text-lg font-bold text-slate-900">
The Main Point Formula
</h3>
<p className="text-sm text-slate-700">
The main point answers the question: "So what?" It is the primary
argument the author wants to convey not just a description of
what was discussed.
</p>
<div className="scroll-reveal-scale bg-teal-900 text-white rounded-xl p-5 text-center">
<p className="text-xl font-extrabold tracking-wide">
Topic + So What? = Main Point
</p>
</div>
{/* Worked Example — Otagaki Rengetsu */}
<div className="bg-teal-50 border border-teal-200 rounded-xl p-5 space-y-3">
<p className="font-bold text-teal-800 text-sm uppercase tracking-wider">
Worked Example
</p>
<div className="bg-white border border-slate-200 rounded-lg p-4 text-sm text-slate-700 leading-relaxed italic">
"Admired primarily for her exquisite calligraphy,{" "}
<span className="font-bold text-teal-700 not-italic">
Otagaki Rengetsu
</span>{" "}
(17911875) was among Japan's most celebrated artists. She was
also a writer and ceramicist, often inscribing{" "}
<span className="underline decoration-teal-400">her poems</span>{" "}
in{" "}
<span className="underline decoration-teal-400">
her own calligraphy
</span>{" "}
onto clay vessels —{" "}
<span className="font-bold text-teal-700 not-italic">
a distinctive blending of art forms not replicated by any
other artist in Japanese history
</span>
.{" "}
<span className="underline decoration-teal-400">Her work</span>{" "}
was in such great demand during the nineteenth century that
every household in Kyoto was said to own{" "}
<span className="underline decoration-teal-400">
her pottery
</span>
, and today{" "}
<span className="underline decoration-teal-400">
scrolls and ceramics
</span>{" "}
bearing{" "}
<span className="underline decoration-teal-400">
her calligraphy
</span>{" "}
are sought after by collectors."
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div className="card-tilt bg-white border border-teal-200 rounded-lg p-3 text-center">
<p className="text-xs text-slate-500 uppercase tracking-wider mb-1">
Topic
</p>
<p className="font-bold text-teal-800 text-sm">
Otagaki Rengetsu's art
</p>
</div>
<div className="card-tilt bg-white border border-teal-200 rounded-lg p-3 text-center">
<p className="text-xs text-slate-500 uppercase tracking-wider mb-1">
So What?
</p>
<p className="font-bold text-teal-800 text-sm">
Unique, unreplicated qualities
</p>
</div>
<div className="card-tilt bg-teal-700 text-white rounded-lg p-3 text-center">
<p className="text-xs text-teal-200 uppercase tracking-wider mb-1">
Main Point
</p>
<p className="font-bold text-sm">
Her artistic creations are prized for their unique qualities
</p>
</div>
</div>
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
<p className="text-xs text-slate-700">
<span className="font-bold text-amber-800">
Notice the topic tracking:
</span>{" "}
The topic "Otagaki Rengetsu's art" is introduced in sentence 1
and then restated in other words throughout —{" "}
<em>
her poems, her own calligraphy, her work, her pottery,
scrolls and ceramics, her calligraphy
</em>
. The topic word recurs by name or via pronoun/compression
noun. Every correct answer must reference this specific topic,
not a broader category like "Japanese art."
</p>
</div>
</div>
<p className="text-sm text-slate-600 font-semibold">
Key locations where the main point is typically found:
</p>
<ul className="space-y-1 text-sm text-slate-700">
<li>
•{" "}
<span className="font-bold">First or first two sentences</span>{" "}
— most common.
</li>
<li>
• <span className="font-bold">Last sentence</span> — especially
when the passage confirms or reasserts the opening claim.
</li>
<li>
• <span className="font-bold">After a major transition</span>{" "}
such as however, but, in fact, therefore — the "new idea" is
often the real main point.
</li>
<li>
•{" "}
<span className="font-bold">
After a dash, colon, or italicized word
</span>{" "}
— these signal that something important follows.
</li>
</ul>
</div>
{/* Fiction & Poetry */}
<div className="scroll-reveal stagger-3 rounded-2xl p-6 mb-8 bg-slate-50 border border-slate-200 space-y-4">
<h3 className="text-lg font-bold text-slate-900">
Fiction &amp; Poetry: Special Cases
</h3>
<div className="space-y-3">
<div>
<p className="font-bold text-teal-700 text-sm mb-1">
Fiction Passages
</p>
<ul className="text-xs text-slate-600 space-y-1">
<li>
• Focus on who the character is and what quality or feeling
the passage emphasizes.
</li>
<li>
• Key information often appears at the end (a quoted line of
dialogue, a narrator's summary, or a character's
reflection).
</li>
<li>
• DO NOT read in symbolism or broader themes beyond what the
text literally states.
</li>
<li>
• Wrong answers often import an emotion or quality from
outside the text, or conflate a detail with the main idea.
</li>
<li className="italic text-slate-400">
Example: Amy Tan passage about ink-making → main point =
characters take great pride in their generational ink-making
tradition. NOT "the importance of family" (too abstract).
</li>
</ul>
</div>
<div>
<p className="font-bold text-teal-700 text-sm mb-1">
Poetry Passages
</p>
<ul className="text-xs text-slate-600 space-y-1">
<li>
• Read the poem literally first — what is the speaker
literally saying?
</li>
<li>
• Identify the speaker's attitude (positive, negative,
ambivalent) — this almost always determines the main point.
</li>
<li>
• Look at the last stanza or final couplet for the poem's
culminating idea.
</li>
<li>
• Avoid symbolic overreach: "the winter represents death" is
interpretation, not literal reading.
</li>
<li>
• Process of elimination is very powerful: eliminate any
answer that is either negative when the poem is positive, or
that mentions something never stated.
</li>
</ul>
</div>
</div>
</div>
{/* 4F — Primary Purpose vs. Main Point */}
<div className="scroll-reveal stagger-4 rounded-2xl p-6 mb-8 bg-teal-50 border border-teal-200 space-y-4">
<h3 className="text-lg font-bold text-teal-900">
Primary Purpose vs. Main Point
</h3>
<div className="overflow-x-auto">
<table className="w-full text-sm border-collapse rounded-xl overflow-hidden">
<thead>
<tr className="bg-teal-600 text-white">
<th className="px-4 py-2 text-left"></th>
<th className="px-4 py-2 text-left">Main Point</th>
<th className="px-4 py-2 text-left">Primary Purpose</th>
</tr>
</thead>
<tbody>
{[
[
"Question asked",
"What does the author claim?",
"Why did the author write this?",
],
[
"Answer uses",
'Specific nouns, claims, findings ("dark matter cannot be seen but must exist")',
"Function verbs: describe, argue, challenge, illustrate, explain, contrast, suggest",
],
[
"Example",
'"Octavia Butler resisted being identified exclusively with science fiction."',
'"To present a claim and support it with examples."',
],
].map(([label, mp, pp], i) => (
<tr
key={label}
className={i % 2 === 0 ? "bg-white" : "bg-teal-50"}
>
<td className="px-4 py-2 font-bold text-slate-700 text-xs">
{label}
</td>
<td className="px-4 py-2 text-slate-600 text-xs">{mp}</td>
<td className="px-4 py-2 text-slate-600 text-xs">{pp}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Wrong Answer Taxonomy */}
<div className="scroll-reveal stagger-5 rounded-2xl p-6 mb-8 bg-white border border-slate-200 space-y-4">
<h3 className="text-lg font-bold text-slate-900">
Wrong Answer Taxonomy — tap to reveal each trap:
</h3>
<RevealCardGrid
cards={WRONG_ANSWER_TAXONOMY}
columns={3}
accentColor="teal"
/>
</div>
</section>
{/* Section 1 — Old/New & Structure */}
<section
ref={(el) => {
sectionsRef.current[1] = el;
}}
className="min-h-screen flex flex-col justify-center mb-24"
>
<h2 className="text-4xl font-extrabold text-slate-900 mb-2">
Old/New &amp; Structure
</h2>
<p className="text-lg text-slate-500 mb-8">
The most important structural pattern on the SAT — and how to
describe overall passage organization.
</p>
{/* 4C — Old/New */}
<div className="scroll-reveal stagger-1 rounded-2xl p-6 mb-8 bg-teal-50 border border-teal-200 space-y-4">
<h3 className="text-lg font-bold text-teal-900">
Old Idea vs. New Idea Structure
</h3>
<p className="text-sm text-slate-700">
The Old/New template is one of the most important patterns in SAT
science and social science passages. Authors present a
traditionally held view (old idea), then pivot to a new or
contradictory finding.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="card-tilt bg-white border border-slate-200 rounded-xl p-4">
<p className="font-bold text-red-700 text-sm mb-2">
Old Idea Signal Phrases
</p>
<ul className="text-xs text-slate-600 space-y-1">
<li>• "Some/many/most scientists believe..."</li>
<li>• "It is commonly thought that..."</li>
<li>• "Accepted/conventional wisdom holds..."</li>
<li>• "For decades, researchers thought..."</li>
<li>• "Traditionally, it was believed..."</li>
</ul>
<p className="text-xs text-red-600 mt-2 italic">
→ These phrases signal a view the author DISAGREES with.
</p>
</div>
<div className="card-tilt bg-white border border-slate-200 rounded-xl p-4">
<p className="font-bold text-green-700 text-sm mb-2">
New Idea Signal Phrases
</p>
<ul className="text-xs text-slate-600 space-y-1">
<li>• "However, but in fact..."</li>
<li>• "Actually, in reality..."</li>
<li>• "But is it really true that...?"</li>
<li>• "It now seems / researchers now think..."</li>
<li>• "Recently, it has been found that..."</li>
<li>• "New research/evidence shows..."</li>
</ul>
<p className="text-xs text-green-600 mt-2 italic">
→ These phrases signal the view the author AGREES with.
</p>
</div>
</div>
<div className="bg-teal-900 text-white rounded-xl p-4">
<p className="font-bold text-sm mb-1">STRATEGY</p>
<p className="text-xs text-teal-100">
As you read, jot on scratch paper: Old = [3-word summary] | New
= [3-word summary]. The main point is almost always the NEW
idea. If you identify the old idea, you can predict the new idea
before reading it.
</p>
</div>
</div>
{/* 4G — Overall Structure */}
<div className="scroll-reveal stagger-2 rounded-2xl p-6 mb-8 bg-white border border-slate-200 space-y-4">
<h3 className="text-lg font-bold text-slate-900">
Overall Structure of a Text — tap to reveal each pattern:
</h3>
<p className="text-sm text-slate-600">
Structure questions ask how a passage is organized. Identify the
move from one idea to another — not just what is said, but in what
sequence and for what purpose.
</p>
<RevealCardGrid
cards={STRUCTURE_PATTERNS}
columns={3}
accentColor="teal"
/>
<div className="bg-slate-800 text-white rounded-xl p-4">
<p className="font-bold text-sm mb-1">
STRATEGY for structure questions
</p>
<p className="text-xs text-slate-200">
Focus on the first and last sentence. The first usually
introduces the main move; the last usually shows where the
passage ended up. Then check the answer choices for the option
that correctly names both ends.
</p>
</div>
</div>
</section>
{/* Section 2 — Pronouns & Compression Nouns */}
<section
ref={(el) => {
sectionsRef.current[2] = el;
}}
className="min-h-screen flex flex-col justify-center mb-24"
>
<h2 className="text-4xl font-extrabold text-slate-900 mb-2">
Pronouns &amp; Compression Nouns
</h2>
<p className="text-lg text-slate-500 mb-8">
Failure to track these referents is one of the most common
comprehension errors on the SAT.
</p>
{/* 4H */}
<div className="scroll-reveal stagger-1 rounded-2xl p-6 mb-8 bg-teal-50 border border-teal-200 space-y-4">
<h3 className="text-lg font-bold text-teal-900">
Tracking Pronouns and Compression Nouns
</h3>
<p className="text-sm text-slate-700">
Pronouns (it, they, this, these) and "compression nouns" (this
phenomenon, the former, such developments) refer to ideas already
stated. Failure to track these referents is one of the most common
comprehension errors on the SAT.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="card-tilt bg-white border border-slate-200 rounded-xl p-4">
<p className="font-bold text-teal-700 text-sm mb-2">
How to Track Pronouns
</p>
<ul className="text-xs text-slate-600 space-y-1">
<li>
• Always back up to the previous sentence (or earlier) to
find the referent.
</li>
<li>• Singular pronouns (it, this) → singular noun.</li>
<li>• Plural pronouns (they, these) → plural noun.</li>
<li>
• "The former" → first-mentioned item; "the latter" →
second-mentioned item.
</li>
<li>
• Do NOT start reading at the pronoun; always work backwards
first.
</li>
</ul>
</div>
<div className="card-tilt bg-white border border-slate-200 rounded-xl p-4">
<p className="font-bold text-teal-700 text-sm mb-2">
Compression Noun Examples
</p>
<ul className="text-xs text-slate-600 space-y-1">
<li>
• "This enhanced convenience" → refers to several sentences
of prior description.
</li>
<li>
• "This divergence" → refers to genetic differences between
dolphin species.
</li>
<li>
• "Such developments" → refers to a process described in the
prior paragraph.
</li>
<li>
• "This phenomenon" → compresses a multi-sentence
explanation into one noun.
</li>
<li>
• The referent may be 35 lines BEFORE the compression noun.
</li>
</ul>
</div>
</div>
</div>
<div className="scroll-reveal-scale golden-rule-glow bg-teal-900 text-white rounded-2xl p-5 mb-8">
<p className="font-bold mb-1">Golden Rule</p>
<p className="text-sm text-teal-100">
The main point is NOT the first sentence — it is the "So What"
conclusion. Read the whole passage, then ask: "If I had to explain
in one sentence what the author WANTS me to believe, what would I
say?" That sentence is the main point. For Old/New passages: the
NEW idea is almost always the main point.
</p>
</div>
</section>
{/* Section 3 — Main Point Hunter widget */}
<section
ref={(el) => {
sectionsRef.current[3] = el;
}}
className="min-h-screen flex flex-col justify-center mb-24"
>
<h2 className="text-4xl font-extrabold text-slate-900 mb-2">
Main Point Hunter
</h2>
<p className="text-lg text-slate-500 mb-8">
Find the sentence that states the central idea — the "So What"
conclusion.
</p>
<EvidenceHunterWidget
exercises={EVIDENCE_EXERCISES}
accentColor="teal"
/>
</section>
{/* Section 4 — Practice */}
<section
ref={(el) => {
sectionsRef.current[4] = el;
}}
className="min-h-screen flex flex-col justify-center mb-24"
>
<h2 className="text-4xl font-extrabold text-slate-900 mb-6">
Practice Questions
</h2>
{CENTRAL_IDEAS_EASY.slice(0, 2).map((q) => (
<PracticeFromDataset key={q.id} question={q} color="teal" />
))}
{CENTRAL_IDEAS_MEDIUM.slice(0, 1).map((q) => (
<PracticeFromDataset key={q.id} question={q} color="teal" />
))}
<div className="mt-8 text-center">
<button
onClick={onFinish}
className="px-6 py-3 bg-teal-900 text-white font-bold rounded-full hover:bg-teal-700 transition-colors"
>
Finish Lesson
</button>
</div>
</section>
</div>
</div>
);
};
export default EBRWCentralIdeasLesson;

View File

@ -0,0 +1,624 @@
import React, { useRef, useState, useEffect } from "react";
import { Check, BookOpen, BarChart3, Zap, Target } from "lucide-react";
import { PracticeFromDataset } from "../../../components/lessons/LessonShell";
import {
COMMAND_EVIDENCE_EASY,
COMMAND_EVIDENCE_MEDIUM,
} from "../../../data/rw/command-of-evidence";
import EvidenceHunterWidget, {
type EvidenceExercise,
} from "../../../components/lessons/EvidenceHunterWidget";
import RevealCardGrid, {
type RevealCard,
} from "../../../components/lessons/RevealCardGrid";
import useScrollReveal from "../../../components/lessons/useScrollReveal";
interface LessonProps {
onFinish?: () => void;
}
/* ── Data for RevealCardGrid widgets ── */
const ILLUSTRATION_TRAPS: RevealCard[] = [
{
label: "Wrong speaker",
content:
"The quotation features the correct idea but from a different character.",
},
{
label: "Right topic, wrong direction",
content:
"The quotation mentions the topic but doesn't illustrate the specific claim.",
},
{
label: "Too indirect",
content:
"The connection between quotation and claim requires too much inferential leaping.",
},
{
label: "Question marks",
content: 'A rhetorical question often cannot "illustrate" a direct claim.',
},
];
const VALIDITY_TYPES: RevealCard[] = [
{
label: "Valid / Necessary",
sublabel: "CORRECT on SAT",
content:
"Must be true given the evidence; the only logical conclusion. Example: If 14% are supershear events → 86% are not.",
},
{
label: "Possible / Speculative",
sublabel: "WRONG on SAT",
content:
'Might be true but the evidence doesn\'t require it. Example: "Researchers must want more funding" — not stated.',
},
{
label: "Contradicted",
sublabel: "WRONG on SAT",
content:
'Directly conflicts with information stated in the passage. Example: "Exercise improves fitness equally for all" — passage says otherwise.',
},
{
label: "Off-topic",
sublabel: "WRONG on SAT",
content:
"No logical connection to the claim or evidence. Example: Ocean temperature claim when passage is about land volcanoes.",
},
];
const QUANT_WRONG_ANSWERS: RevealCard[] = [
{
label: "Wrong subgroup / time period",
content: "Accurate data about the WRONG subgroup or time period.",
},
{
label: "Wrong direction",
content:
"Accurate comparison in the WRONG direction (A > B when claim needs B > A).",
},
{
label: "Wrong number of groups",
content: "Involves TWO groups when the claim is about ONE group only.",
},
{
label: "Contradictory trend",
content:
"Describes a trend that contradicts the claim despite accurate numbers.",
},
{
label: "Right data, wrong claim",
content:
"Describes the graph accurately but doesn't address the specific claim.",
},
];
const EVIDENCE_EXERCISES: EvidenceExercise[] = [
{
question:
"The researcher concludes that urban green spaces reduce stress. Which sentence from the study best SUPPORTS this conclusion?",
passage: [
"Participants were randomly assigned to walk for 30 minutes in either an urban park or a busy commercial district.",
"Before and after each walk, cortisol levels were measured using saliva samples.",
"Participants who walked in the park showed a 15% reduction in cortisol, a primary stress hormone.",
"Those who walked in the commercial district showed no significant change in cortisol levels.",
"Participants reported feeling calmer after the park walk, though self-report data is inherently subjective.",
],
evidenceIndex: 2,
explanation:
"Sentence 3 provides direct biological evidence (cortisol reduction) that supports the claim about stress reduction. It uses objective measurement rather than self-report, making it the strongest support for the stated conclusion.",
},
{
question:
"Which sentence from this passage most effectively ILLUSTRATES the claim that microplastics are now found in unexpected locations?",
passage: [
"Microplastics are plastic fragments smaller than 5 millimeters.",
"They originate from the breakdown of larger plastic items or are manufactured at microscopic size.",
"Researchers have detected microplastics in the peak snowpack of Mount Everest.",
"Microplastics have also been found in human blood, lung tissue, and placentas.",
"The long-term health effects of microplastic exposure are still being studied.",
],
evidenceIndex: 2,
explanation:
"Sentence 3 best illustrates the claim about unexpected locations because Mount Everest is one of the most remote places on Earth — finding microplastics there is a striking, concrete example of how pervasive contamination has become.",
},
];
const EBRWCommandEvidenceLesson: React.FC<LessonProps> = ({ onFinish }) => {
const [activeSection, setActiveSection] = useState(0);
const sectionsRef = useRef<(HTMLElement | null)[]>([]);
useEffect(() => {
const observers: IntersectionObserver[] = [];
sectionsRef.current.forEach((el, idx) => {
if (!el) return;
const obs = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) setActiveSection(idx);
},
{ threshold: 0.3 },
);
obs.observe(el);
observers.push(obs);
});
return () => observers.forEach((o) => o.disconnect());
}, []);
useScrollReveal();
const scrollToSection = (index: number) => {
setActiveSection(index);
sectionsRef.current[index]?.scrollIntoView({ behavior: "smooth" });
};
const SectionMarker = ({
index,
title,
icon: Icon,
}: {
index: number;
title: string;
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
}) => {
const isActive = activeSection === index;
const isPast = activeSection > index;
return (
<button
onClick={() => scrollToSection(index)}
className={`flex items-center gap-3 p-3 w-full rounded-lg text-left transition-all ${isActive ? "bg-teal-50" : "hover:bg-slate-50"}`}
>
<div
className={`w-8 h-8 rounded-full flex items-center justify-center shrink-0
${isActive ? "bg-teal-600 text-white" : isPast ? "bg-teal-400 text-white" : "bg-slate-200 text-slate-500"}`}
>
{isPast ? (
<Check className="w-4 h-4" />
) : (
<Icon className="w-4 h-4" />
)}
</div>
<p
className={`text-sm font-bold ${isActive ? "text-teal-900" : "text-slate-600"}`}
>
{title}
</p>
</button>
);
};
return (
<div className="flex flex-col lg:flex-row min-h-screen">
<aside className="w-full lg:w-64 lg:fixed lg:top-14 lg:bottom-0 lg:overflow-y-auto p-4 border-r border-slate-200 bg-slate-50 z-0 hidden lg:block">
<nav className="space-y-2 pt-6">
<SectionMarker index={0} title="Textual Evidence" icon={BookOpen} />
<SectionMarker
index={1}
title="Quantitative Evidence"
icon={BarChart3}
/>
<SectionMarker index={2} title="Evidence Hunter" icon={Target} />
<SectionMarker index={3} title="Practice Questions" icon={Zap} />
</nav>
</aside>
<div className="flex-1 lg:ml-64 md:p-12 max-w-full mx-auto">
{/* Section 0 — Textual Evidence */}
<section
ref={(el) => {
sectionsRef.current[0] = el;
}}
className="min-h-screen flex flex-col justify-center mb-24 pt-20 lg:pt-0"
>
<div className="inline-flex items-center gap-2 bg-teal-100 text-teal-700 px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider mb-4 w-fit">
Information &amp; Ideas Domain 2
</div>
<h2 className="text-4xl font-extrabold text-slate-900 mb-2">
Command of Evidence
</h2>
<p className="text-lg text-slate-500 mb-8">
Move beyond the passage to apply its ideas. Two subtypes: Textual
Evidence (quotations) and Quantitative Evidence (graphs and tables).
</p>
<div className="scroll-reveal stagger-1 bg-teal-50 border border-teal-200 rounded-2xl p-5 mb-8">
<p className="text-sm text-slate-700">
<span className="font-bold text-teal-800">Overview: </span>Command
of Evidence questions ask you to move BEYOND the passage to apply
its ideas. You will identify quotations or data that illustrate,
support, or undermine a specific claim. There are two main
subtypes: Textual Evidence (using quotations or passages) and
Quantitative Evidence (using graphs and tables).
</p>
</div>
{/* 5A */}
<div className="scroll-reveal stagger-2 rounded-2xl p-6 mb-8 bg-white border border-slate-200 space-y-4">
<h3 className="text-lg font-bold text-slate-900">
Illustrating a Claim (Quotation Selection)
</h3>
<p className="text-sm text-slate-600">
These questions ask you to find the quotation from a poem, story,
or passage that best illustrates a claim stated in the question
stem. The claim is explicitly given to you your job is to match
it to the correct quotation.
</p>
<div className="bg-teal-50 border border-teal-200 rounded-xl p-4">
<p className="font-bold text-teal-800 text-sm mb-2">
3-Step Process for Illustration Questions
</p>
<div className="space-y-2">
{[
[
"1",
"RESTATE the claim in the question stem in your own words. Identify the exact quality or action it describes.",
],
[
"2",
"PREDICT what kind of language would illustrate it — positive/negative tone, specific action, direct statement?",
],
[
"3",
"ELIMINATE quotations that: (a) are too vague, (b) refer to the wrong speaker, (c) describe a different quality entirely.",
],
].map(([n, text]) => (
<div key={n} className="flex gap-2">
<span className="w-5 h-5 rounded-full bg-teal-600 text-white flex items-center justify-center text-xs font-bold shrink-0">
{n}
</span>
<p className="text-xs text-slate-700">{text}</p>
</div>
))}
</div>
</div>
<p className="font-semibold text-sm text-slate-800">
Key traps in illustration questions tap to reveal:
</p>
<RevealCardGrid
cards={ILLUSTRATION_TRAPS}
columns={2}
accentColor="teal"
/>
</div>
{/* 5B */}
<div className="scroll-reveal stagger-3 rounded-2xl p-6 mb-8 bg-white border border-slate-200 space-y-4">
<h3 className="text-lg font-bold text-slate-900">
Supporting a Claim
</h3>
<p className="text-sm text-slate-600">
Support questions ask: "Which finding would MOST DIRECTLY support
this conclusion?" The correct answer must provide new evidence
consistent with the claim it doesn't just repeat what the
passage already states.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="card-tilt bg-green-50 border border-green-200 rounded-xl p-4">
<p className="font-bold text-green-800 text-sm mb-2">
What Makes a Valid Support?
</p>
<ul className="text-xs text-slate-600 space-y-1">
<li>
• Provides a NEW example or finding, not a restatement.
</li>
<li>
• Is directly consistent with the specific mechanism
described.
</li>
<li>• Makes the claim MORE likely to be true.</li>
<li>
• Common patterns: X causes Y → new example of X causing Y;
More X → more Y → find a case where less X → less Y.
</li>
</ul>
</div>
<div className="card-tilt bg-red-50 border border-red-200 rounded-xl p-4">
<p className="font-bold text-red-800 text-sm mb-2">
What Looks Like Support But Isn't
</p>
<ul className="text-xs text-slate-600 space-y-1">
<li>
The answer discusses the right topic but a different
aspect of it.
</li>
<li>
The answer is consistent with the general field but not
the specific claim.
</li>
<li>
The answer only restates part of what the passage already
said.
</li>
<li>
The answer is factually true but would also be true
regardless of the claim.
</li>
</ul>
</div>
</div>
</div>
{/* 5C */}
<div className="scroll-reveal stagger-4 rounded-2xl p-6 mb-8 bg-white border border-slate-200 space-y-4">
<h3 className="text-lg font-bold text-slate-900">
Undermining a Claim
</h3>
<p className="text-sm text-slate-600">
Undermine questions have the same structure as support questions,
but in reverse. The correct answer must provide information that
makes the claim LESS likely to be true.
</p>
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4">
<p className="font-bold text-amber-800 text-sm mb-1">
KEY TECHNIQUE Flip the Claim
</p>
<p className="text-xs text-slate-700">
If the claim is "high metabolic rate = survival advantage," then
to undermine it you need evidence that high metabolic rate does
NOT produce survival advantage (e.g., many high-metabolic
creatures went extinct).
</p>
</div>
<p className="font-semibold text-sm text-slate-800">
Common undermine traps:
</p>
{[
"The answer is unrelated to the claim rather than contradictory to it — an unrelated finding doesn't undermine anything.",
"The answer challenges a secondary detail, not the core mechanism being tested.",
"The answer actually supports the claim but is framed in negative-sounding language.",
].map((trap, i) => (
<div
key={i}
className="flex gap-2 bg-red-50 border border-red-100 rounded-lg px-3 py-2"
>
<span className="text-red-500 font-bold shrink-0 text-xs">
</span>
<p className="text-xs text-slate-600">{trap}</p>
</div>
))}
</div>
{/* 5D */}
<div className="scroll-reveal stagger-5 rounded-2xl p-6 mb-8 bg-white border border-slate-200 space-y-4">
<h3 className="text-lg font-bold text-slate-900">
Validity of Conclusions tap to reveal each type:
</h3>
<p className="text-sm text-slate-600">
Some questions ask whether a finding is valid whether it
necessarily follows from the research described.
</p>
<RevealCardGrid
cards={VALIDITY_TYPES}
columns={2}
accentColor="teal"
/>
</div>
<div className="scroll-reveal-scale golden-rule-glow bg-teal-900 text-white rounded-2xl p-5 mb-8">
<p className="font-bold mb-1">Golden Rule Textual Evidence</p>
<p className="text-sm text-teal-100">
The question always tells you the required relationship
(illustrate / support / undermine). An answer that accurately
quotes the passage but has the WRONG relationship is still wrong.
Identify the relationship first, accuracy second.
</p>
</div>
</section>
{/* Section 1 — Quantitative Evidence */}
<section
ref={(el) => {
sectionsRef.current[1] = el;
}}
className="min-h-screen flex flex-col justify-center mb-24"
>
<h2 className="text-4xl font-extrabold text-slate-900 mb-2">
Quantitative Evidence
</h2>
<p className="text-lg text-slate-500 mb-8">
Graphs and tables the mandatory order of operations and criteria
matching technique.
</p>
{/* 5E */}
<div className="scroll-reveal stagger-1 rounded-2xl p-6 mb-8 bg-amber-50 border border-amber-200 space-y-4">
<h3 className="text-lg font-bold text-amber-900">
Non-Negotiable Order of Operations
</h3>
<p className="text-sm text-slate-700">
The most important principle:{" "}
<strong>the graphic alone is never sufficient.</strong> You must
begin with the passage and the question to know what to look for
in the graphic.
</p>
<div className="bg-white border border-amber-200 rounded-xl p-4">
<p className="font-bold text-amber-800 text-sm mb-3">
MANDATORY Order for Graph Questions
</p>
<div className="space-y-2">
{[
[
"1",
"Read the PASSAGE (especially the last sentence — this states the claim).",
],
[
"2",
"Read the QUESTION STEM carefully to identify exactly what you are being asked.",
],
[
"3",
"Extract the CRITERIA from the claim: what specific conditions must be met?",
],
[
"4",
"THEN look at the GRAPHIC with those criteria in mind.",
],
[
"5",
"Match the answer that satisfies ALL criteria — not just part of them.",
],
].map(([n, text]) => (
<div key={n} className="flex gap-2">
<span className="w-5 h-5 rounded-full bg-amber-600 text-white flex items-center justify-center text-xs font-bold shrink-0">
{n}
</span>
<p className="text-xs text-slate-700">{text}</p>
</div>
))}
</div>
</div>
<div className="bg-red-50 border border-red-200 rounded-xl p-4">
<p className="font-bold text-red-800 text-sm mb-1">
CRITICAL WARNING
</p>
<p className="text-xs text-slate-700">
Looking at the graph first is one of the most costly errors on
graph questions. Multiple answer choices will accurately
describe the graph only ONE will match the specific claim in
the passage. The graphic alone cannot tell you which one is
correct.
</p>
</div>
</div>
{/* 5F */}
<div className="scroll-reveal stagger-2 rounded-2xl p-6 mb-8 bg-white border border-slate-200 space-y-3">
<h3 className="text-lg font-bold text-slate-900">
When You Do NOT Need the Graph
</h3>
<p className="text-sm text-slate-600">
Many graph questions can be answered using only the passage and
the answer choices without looking at the graph at all.
</p>
<ul className="space-y-2 text-sm text-slate-700">
<li className="flex gap-2">
<span className="text-teal-600 font-bold shrink-0"></span>If
answer choices contain wording clearly inconsistent with the
passage's claim, eliminate them immediately.
</li>
<li className="flex gap-2">
<span className="text-teal-600 font-bold shrink-0"></span>
Answers addressing the wrong aspect of the claim (wrong time
period, wrong variable, wrong group) can be eliminated before
consulting the graph.
</li>
<li className="flex gap-2">
<span className="text-teal-600 font-bold shrink-0"></span>Once
only one answer remains that is consistent with the claim, that
is correct verifying against the graph is optional.
</li>
</ul>
<div className="bg-teal-50 border border-teal-200 rounded-xl p-4">
<p className="font-bold text-teal-800 text-xs mb-1">EXAMPLE</p>
<p className="text-xs text-slate-700">
Passage claims "print books are preferred in certain
situations." Any answer describing a situation where e-books are
preferred can be immediately eliminated without looking at the
chart.
</p>
</div>
</div>
{/* 5G */}
<div className="scroll-reveal stagger-3 rounded-2xl p-6 mb-8 bg-white border border-slate-200 space-y-4">
<h3 className="text-lg font-bold text-slate-900">
Criteria Matching for Quantitative Questions
</h3>
<p className="text-sm text-slate-600">
The most common error: choosing an answer that accurately
describes the graph but fails to match ALL criteria specified in
the claim. Build a checklist before looking at the data.
</p>
<div className="card-tilt bg-teal-50 border border-teal-200 rounded-xl p-4">
<p className="font-bold text-teal-800 text-sm mb-2">
Building a Criteria Checklist
</p>
<ul className="text-xs text-slate-600 space-y-1">
<li> List every specific condition mentioned in the claim.</li>
<li>
Example: "rebounded AND reached highest level in 60 years" =
2 separate criteria.
</li>
<li>
An answer meeting only Criterion 1 but not Criterion 2 is
wrong, even if it accurately describes the graph.
</li>
<li>
Write criteria on scratch paper before looking at graphic.
</li>
</ul>
</div>
<p className="font-semibold text-sm text-slate-800">
Common Quantitative Wrong Answers tap to reveal:
</p>
<RevealCardGrid
cards={QUANT_WRONG_ANSWERS}
columns={3}
accentColor="teal"
/>
</div>
<div className="scroll-reveal-scale golden-rule-glow bg-teal-900 text-white rounded-2xl p-5 mb-8">
<p className="font-bold mb-1">
Golden Rule Quantitative Evidence
</p>
<p className="text-sm text-teal-100">
Always read the passage and question first to extract criteria.
Multiple answer choices will accurately describe the graph only
ONE matches the specific claim. "Right data, wrong claim aspect"
is the most common wrong answer type.
</p>
</div>
</section>
{/* Section 2 — Evidence Hunter widget */}
<section
ref={(el) => {
sectionsRef.current[2] = el;
}}
className="min-h-screen flex flex-col justify-center mb-24"
>
<h2 className="text-4xl font-extrabold text-slate-900 mb-2">
Evidence Hunter
</h2>
<p className="text-lg text-slate-500 mb-8">
Find the sentence that has the exact relationship the question
requires.
</p>
<EvidenceHunterWidget
exercises={EVIDENCE_EXERCISES}
accentColor="teal"
/>
</section>
{/* Section 3 — Practice */}
<section
ref={(el) => {
sectionsRef.current[3] = el;
}}
className="min-h-screen flex flex-col justify-center mb-24"
>
<h2 className="text-4xl font-extrabold text-slate-900 mb-6">
Practice Questions
</h2>
{COMMAND_EVIDENCE_EASY.slice(0, 2).map((q) => (
<PracticeFromDataset key={q.id} question={q} color="teal" />
))}
{COMMAND_EVIDENCE_MEDIUM.slice(0, 1).map((q) => (
<PracticeFromDataset key={q.id} question={q} color="teal" />
))}
<div className="mt-8 text-center">
<button
onClick={onFinish}
className="px-6 py-3 bg-teal-900 text-white font-bold rounded-full hover:bg-teal-700 transition-colors"
>
Finish Lesson
</button>
</div>
</section>
</div>
</div>
);
};
export default EBRWCommandEvidenceLesson;

View File

@ -209,7 +209,7 @@ const EBRWCommasLesson: React.FC<LessonProps> = ({ onFinish }) => {
}: { }: {
index: number; index: number;
title: string; title: string;
icon: React.ElementType; icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
}) => { }) => {
const isActive = activeSection === index; const isActive = activeSection === index;
const isPast = activeSection > index; const isPast = activeSection > index;

View File

@ -88,7 +88,7 @@ const EBRWCraftStructureLesson: React.FC<LessonProps> = ({ onFinish }) => {
}: { }: {
index: number; index: number;
title: string; title: string;
icon: React.ElementType; icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
}) => { }) => {
const isActive = activeSection === index; const isActive = activeSection === index;
const isPast = activeSection > index; const isPast = activeSection > index;

View File

@ -0,0 +1,496 @@
import React, { useRef, useState, useEffect } from "react";
import { Check, BookOpen, Lightbulb, Zap, Target } from "lucide-react";
import { PracticeFromDataset } from "../../../components/lessons/LessonShell";
import {
CROSS_TEXT_EASY,
CROSS_TEXT_MEDIUM,
} from "../../../data/rw/cross-text-connections";
import EvidenceHunterWidget, {
type EvidenceExercise,
} from "../../../components/lessons/EvidenceHunterWidget";
import RevealCardGrid, {
type RevealCard,
} from "../../../components/lessons/RevealCardGrid";
import useScrollReveal from "../../../components/lessons/useScrollReveal";
interface LessonProps {
onFinish?: () => void;
}
/* ── Data for RevealCardGrid widgets ── */
const RESPONSE_PATTERNS: RevealCard[] = [
{
label: "Overstated / exaggerated",
content: '"It overstates the environmental impact of X."',
},
{
label: "Insufficiently supported",
content: '"The data do not support the conclusion drawn."',
},
{
label: "Incomplete / overlooks evidence",
content: '"It overlooks a crucial statistic / factor."',
},
{
label: "Logically flawed",
content: '"The reasoning does not follow from the evidence presented."',
},
];
const SCOPE_ERRORS: RevealCard[] = [
{
label: "Scope Error",
content:
"Answer is more general or more specific than what either text actually argues. The correct answer matches the EXACT scope of the relevant text.",
},
{
label: "One-Text Error",
content:
'Answer accurately reflects one text but ignores the other. In "both agree" questions, every word must apply to both passages.',
},
{
label: "Extreme Language",
content:
'Answer uses "always," "never," "all," "none" when neither author makes such an absolute claim.',
},
{
label: "Fabricated Connection",
content:
"Answer invents a relationship between the texts that neither author states or implies.",
},
];
const EVIDENCE_EXERCISES: EvidenceExercise[] = [
{
question:
"Text 1 claims social media increases political polarization. Which sentence from Text 2 most directly responds to this claim?",
passage: [
"Social media platforms are designed to maximize engagement through outrage.",
"Studies of Twitter and Facebook show users cluster in ideological echo chambers.",
"However, a 2022 meta-analysis of 31 studies found no consistent causal link between social media use and polarization.",
"The authors note that correlation between social media use and polarized views may reflect self-selection bias.",
"People who are already polarized may simply use social media more frequently.",
],
evidenceIndex: 2,
explanation:
'Sentence 3 directly challenges the claim that social media causes polarization by citing a meta-analysis finding no consistent causal link. The word "however" signals this is the point where Text 2 diverges from Text 1\'s argument.',
},
{
question:
"Both passages discuss the role of apprenticeships in education. Which sentence from Text 2 would Text 1's author most likely agree with?",
passage: [
"Modern universities emphasize abstract theory over practical skills.",
"Employers increasingly report that graduates cannot apply classroom knowledge to real problems.",
"Apprenticeship programs combine theoretical learning with supervised hands-on practice.",
"Countries with strong apprenticeship traditions, such as Germany, report lower youth unemployment.",
"Critics argue that apprenticeships limit social mobility by tracking students into specific trades.",
],
evidenceIndex: 2,
explanation:
"Sentence 3 describes apprenticeships as combining theory and practice — this is the bridge both texts share. Text 1 likely argues for integrating practical skills with theoretical knowledge, making this the point of agreement.",
},
];
const EBRWCrossTextLesson: React.FC<LessonProps> = ({ onFinish }) => {
const [activeSection, setActiveSection] = useState(0);
const sectionsRef = useRef<(HTMLElement | null)[]>([]);
useEffect(() => {
const observers: IntersectionObserver[] = [];
sectionsRef.current.forEach((el, idx) => {
if (!el) return;
const obs = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) setActiveSection(idx);
},
{ threshold: 0.3 },
);
obs.observe(el);
observers.push(obs);
});
return () => observers.forEach((o) => o.disconnect());
}, []);
useScrollReveal();
const scrollToSection = (index: number) => {
setActiveSection(index);
sectionsRef.current[index]?.scrollIntoView({ behavior: "smooth" });
};
const SectionMarker = ({
index,
title,
icon: Icon,
}: {
index: number;
title: string;
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
}) => {
const isActive = activeSection === index;
const isPast = activeSection > index;
return (
<button
onClick={() => scrollToSection(index)}
className={`flex items-center gap-3 p-3 w-full rounded-lg text-left transition-all ${isActive ? "bg-fuchsia-50" : "hover:bg-slate-50"}`}
>
<div
className={`w-8 h-8 rounded-full flex items-center justify-center shrink-0
${isActive ? "bg-fuchsia-600 text-white" : isPast ? "bg-fuchsia-400 text-white" : "bg-slate-200 text-slate-500"}`}
>
{isPast ? (
<Check className="w-4 h-4" />
) : (
<Icon className="w-4 h-4" />
)}
</div>
<p
className={`text-sm font-bold ${isActive ? "text-fuchsia-900" : "text-slate-600"}`}
>
{title}
</p>
</button>
);
};
return (
<div className="flex flex-col lg:flex-row min-h-screen">
<aside className="w-full lg:w-64 lg:fixed lg:top-14 lg:bottom-0 lg:overflow-y-auto p-4 border-r border-slate-200 bg-slate-50 z-0 hidden lg:block">
<nav className="space-y-2 pt-6">
<SectionMarker
index={0}
title="Paired Passage Strategy"
icon={BookOpen}
/>
<SectionMarker
index={1}
title="Relationship Types"
icon={Lightbulb}
/>
<SectionMarker index={2} title="Connection Finder" icon={Target} />
<SectionMarker index={3} title="Practice Questions" icon={Zap} />
</nav>
</aside>
<div className="flex-1 lg:ml-64 md:p-12 max-w-full mx-auto">
{/* ── SECTION 0: PAIRED PASSAGE STRATEGY ── */}
<section
ref={(el) => {
sectionsRef.current[0] = el;
}}
className="min-h-screen flex flex-col justify-center mb-24 pt-20 lg:pt-0"
>
<div className="inline-flex items-center gap-2 bg-fuchsia-100 text-fuchsia-700 px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider mb-4 w-fit">
Craft &amp; Structure Domain 1
</div>
<h2 className="text-4xl font-extrabold text-slate-900 mb-2">
Cross-Text Connections
</h2>
<p className="text-lg text-slate-500 mb-8">
Two short passages, one question always about the relationship
between the authors' claims. Summarize each text before reading the
question.
</p>
{/* Format & Question Stems */}
<div className="scroll-reveal stagger-1 bg-fuchsia-50 border border-fuchsia-200 rounded-2xl p-6 mb-8 space-y-4">
<h3 className="text-lg font-bold text-fuchsia-900">
Format &amp; Common Question Stems
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="card-tilt bg-white border border-fuchsia-200 rounded-xl p-4">
<p className="font-bold text-fuchsia-800 text-sm mb-2">
What to Expect
</p>
<ul className="space-y-1">
{[
"Both texts are very short — typically 50100 words each.",
"They cover the same general topic but present different perspectives.",
"Only one question accompanies the pair.",
"The most common relationship is disagreement — one challenges or qualifies the other.",
"Authors may also partially agree, or one may complicate the other's conclusion.",
].map((f) => (
<li
key={f}
className="flex items-start gap-2 text-xs text-slate-700"
>
<span className="text-fuchsia-500 shrink-0 mt-0.5">
</span>
{f}
</li>
))}
</ul>
</div>
<div className="card-tilt bg-white border border-fuchsia-200 rounded-xl p-4">
<p className="font-bold text-fuchsia-800 text-sm mb-2">
Common Question Stems
</p>
<ul className="space-y-2">
{[
'"Based on the texts, how would [Author 2] most likely describe the view in Text 1?"',
'"Based on the texts, how would [Author 2] most likely respond to what [Author 1] says about X?"',
'"Both texts discuss X. How do they differ in their treatment of it?"',
'"Which statement would [Author 1] most likely agree with?"',
].map((stem) => (
<li
key={stem}
className="text-xs text-slate-600 italic bg-fuchsia-50 rounded-lg px-2 py-1"
>
{stem}
</li>
))}
</ul>
</div>
</div>
</div>
{/* Five-Step Process */}
<div className="scroll-reveal stagger-2 rounded-2xl p-6 mb-8 bg-white border border-slate-200 space-y-4">
<h3 className="text-lg font-bold text-slate-900">
The Five-Step Process
</h3>
<div className="space-y-3">
{[
{
step: 1,
action: "Read Text 1 and identify its main point",
tip: "Write it down in 46 words on scratch paper.",
},
{
step: 2,
action: "Read Text 2 and identify its main point",
tip: "Write it down in 46 words on scratch paper.",
},
{
step: 3,
action: "Write the relationship between the two",
tip: "Disagree, partially agree, or agree with different emphasis.",
},
{
step: 4,
action:
"Answer the question in your own words before looking at options",
tip: "Base this on your written main points and the relationship.",
},
{
step: 5,
action:
"Read the answer choices and select the closest match",
tip: "Use elimination wrong charges can often be spotted from the first few words of each option.",
},
].map((s) => (
<div
key={s.step}
className="flex gap-3 bg-fuchsia-50 border border-fuchsia-100 rounded-xl p-4"
>
<span className="w-7 h-7 rounded-full bg-fuchsia-600 text-white flex items-center justify-center text-sm font-bold shrink-0">
{s.step}
</span>
<div>
<p className="font-bold text-slate-800 text-sm">
{s.action}
</p>
<p className="text-xs text-slate-500 mt-0.5">{s.tip}</p>
</div>
</div>
))}
</div>
<div className="bg-red-50 border border-red-200 rounded-xl p-4">
<p className="font-bold text-red-800 text-sm mb-1">
Critical Warning
</p>
<p className="text-xs text-slate-700">
The biggest mistake is reading both passages without writing
anything down and relying on memory. Under exam pressure, this
almost always leads to error. The notes take ten seconds and can
save a minute of confusion.
</p>
</div>
</div>
</section>
{/* ── SECTION 1: RELATIONSHIP TYPES & ELIMINATION ── */}
<section
ref={(el) => {
sectionsRef.current[1] = el;
}}
className="min-h-screen flex flex-col justify-center mb-24"
>
<h2 className="text-4xl font-extrabold text-slate-900 mb-2">
Relationship Types
</h2>
<p className="text-lg text-slate-500 mb-8">
Once the relationship is established, elimination becomes powerful —
wrong-charge answers can be ruled out immediately.
</p>
{/* 3 Relationship Types */}
<div className="scroll-reveal stagger-1 bg-fuchsia-50 border border-fuchsia-200 rounded-2xl p-6 mb-8 space-y-4">
<h3 className="text-lg font-bold text-fuchsia-900">
The 3 Relationship Types &amp; How to Eliminate
</h3>
<div className="space-y-3">
{[
{
type: "Disagree",
color: "bg-red-50 border-red-200",
badge: "bg-red-600",
desc: "Authors have conflicting views on the same topic. Text 1 argues X; Text 2 argues the opposite or Not-X.",
elim: 'Eliminate any answer that is positive (e.g., "strongly supported," "generally accurate") or neutral to the point of meaninglessness.',
},
{
type: "Partially Agree",
color: "bg-amber-50 border-amber-200",
badge: "bg-amber-600",
desc: "Authors share common ground on some aspects but diverge on others.",
elim: "Eliminate answers that treat the relationship as purely positive or purely negative the correct answer acknowledges both what they share and where they differ.",
},
{
type: "Agree",
color: "bg-green-50 border-green-200",
badge: "bg-green-600",
desc: "Authors reach the same basic conclusion, though they may approach it differently or emphasize different aspects.",
elim: "Eliminate answers that overstate the disagreement, or that are accurate for one text but not both.",
},
].map((r) => (
<div
key={r.type}
className={`card-tilt border rounded-xl p-4 ${r.color}`}
>
<div className="flex items-center gap-2 mb-2">
<span
className={`px-3 py-0.5 rounded-full text-white text-xs font-bold ${r.badge}`}
>
{r.type}
</span>
</div>
<p className="text-sm text-slate-700 mb-1">{r.desc}</p>
<p className="text-xs text-slate-500 italic">
<span className="font-bold not-italic text-slate-600">
To eliminate:{" "}
</span>
{r.elim}
</p>
</div>
))}
</div>
</div>
{/* Words to Watch + Response Patterns */}
<div className="scroll-reveal stagger-2 rounded-2xl p-6 mb-8 bg-white border border-slate-200 space-y-4">
<h3 className="text-lg font-bold text-slate-900">
Words to Watch &amp; Typical Response Patterns
</h3>
<div className="space-y-2 mb-4">
<p className="text-sm text-slate-600">
These words in answer choices typically signal incorrect
answers:
</p>
{[
[
'"strongly supported," "proven," "definitively"',
"Too positive / too absolute",
],
['"only possible in," "impossible to"', "Too extreme"],
['"highly implausible"', "Usually overstates the disagreement"],
].map(([phrase, note]) => (
<div
key={phrase}
className="flex gap-3 bg-red-50 border border-red-200 rounded-lg px-4 py-2"
>
<span className="text-red-500 font-bold shrink-0">✗</span>
<div>
<p className="text-xs font-bold text-red-800 italic">
{phrase}
</p>
<p className="text-xs text-slate-600">{note}</p>
</div>
</div>
))}
</div>
<p className="text-sm font-semibold text-slate-700 mb-2">
Typical Response Patterns — tap to reveal example phrasing:
</p>
<RevealCardGrid
cards={RESPONSE_PATTERNS}
columns={2}
accentColor="fuchsia"
/>
</div>
{/* Scope & Specificity Errors */}
<div className="scroll-reveal stagger-3 bg-fuchsia-50 border border-fuchsia-200 rounded-2xl p-6 mb-8 space-y-4">
<h3 className="text-lg font-bold text-fuchsia-900">
Scope &amp; Specificity Errors — tap to reveal each trap:
</h3>
<RevealCardGrid
cards={SCOPE_ERRORS}
columns={2}
accentColor="fuchsia"
/>
</div>
<div className="scroll-reveal-scale golden-rule-glow bg-fuchsia-900 text-white rounded-2xl p-5 mb-8">
<p className="font-bold mb-1">Golden Rule</p>
<p className="text-sm text-fuchsia-100">
Summarize each text in one sentence BEFORE reading the question.
Write it down — do not rely on memory. The question will always be
about the relationship between those two summaries. If you know
what each author claims and how they relate, the question becomes
straightforward.
</p>
</div>
</section>
{/* ── SECTION 2: WIDGET ── */}
<section
ref={(el) => {
sectionsRef.current[2] = el;
}}
className="min-h-screen flex flex-col justify-center mb-24"
>
<h2 className="text-4xl font-extrabold text-slate-900 mb-2">
Connection Finder
</h2>
<p className="text-lg text-slate-500 mb-8">
Find the sentence that directly engages with the other text's
argument.
</p>
<EvidenceHunterWidget
exercises={EVIDENCE_EXERCISES}
accentColor="fuchsia"
/>
</section>
{/* ── SECTION 3: PRACTICE ── */}
<section
ref={(el) => {
sectionsRef.current[3] = el;
}}
className="min-h-screen flex flex-col justify-center mb-24"
>
<h2 className="text-4xl font-extrabold text-slate-900 mb-6">
Practice Questions
</h2>
{CROSS_TEXT_EASY.slice(0, 2).map((q) => (
<PracticeFromDataset key={q.id} question={q} color="fuchsia" />
))}
{CROSS_TEXT_MEDIUM.slice(0, 1).map((q) => (
<PracticeFromDataset key={q.id} question={q} color="fuchsia" />
))}
<div className="mt-8 text-center">
<button
onClick={onFinish}
className="px-6 py-3 bg-fuchsia-900 text-white font-bold rounded-full hover:bg-fuchsia-700 transition-colors"
>
Finish Lesson
</button>
</div>
</section>
</div>
</div>
);
};
export default EBRWCrossTextLesson;

View File

@ -195,7 +195,7 @@ const EBRWDashesApostrophesLesson: React.FC<LessonProps> = ({ onFinish }) => {
}: { }: {
index: number; index: number;
title: string; title: string;
icon: React.ElementType; icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
}) => { }) => {
const isActive = activeSection === index; const isActive = activeSection === index;
const isPast = activeSection > index; const isPast = activeSection > index;

View File

@ -105,6 +105,7 @@ const EBRWExplicitMeaningLesson: React.FC<LessonProps> = ({ onFinish }) => {
{isPast ? ( {isPast ? (
<Check className="w-4 h-4" /> <Check className="w-4 h-4" />
) : ( ) : (
// @ts-ignore
<Icon className="w-4 h-4" /> <Icon className="w-4 h-4" />
)} )}
</div> </div>

View File

@ -105,6 +105,7 @@ const EBRWExpressionIdeasLesson: React.FC<LessonProps> = ({ onFinish }) => {
{isPast ? ( {isPast ? (
<Check className="w-4 h-4" /> <Check className="w-4 h-4" />
) : ( ) : (
// @ts-ignore
<Icon className="w-4 h-4" /> <Icon className="w-4 h-4" />
)} )}
</div> </div>

View File

@ -0,0 +1,941 @@
import React, { useRef, useState, useEffect } from "react";
import {
Check,
BookOpen,
AlertTriangle,
Zap,
Clock,
Users,
GitBranch,
} from "lucide-react";
import { PracticeFromDataset } from "../../../components/lessons/LessonShell";
import {
FORM_STRUCTURE_EASY,
FORM_STRUCTURE_MEDIUM,
} from "../../../data/rw/form-structure-sense";
import DecisionTreeWidget, {
type TreeScenario,
type TreeNode,
} from "../../../components/lessons/DecisionTreeWidget";
import RevealCardGrid, {
type RevealCard,
} from "../../../components/lessons/RevealCardGrid";
import useScrollReveal from "../../../components/lessons/useScrollReveal";
interface LessonProps {
onFinish?: () => void;
}
/* ── Data for RevealCardGrid widgets ── */
const SVA_DISGUISES: RevealCard[] = [
{
label: "Prepositional Phrase Decoy",
sublabel: "The list [of requirements] __ long.",
content: 'Cross out the phrase. "List" is singular → IS long.',
},
{
label: "Inverted Order (There is/are)",
sublabel: "There __ many reasons for the decision.",
content:
'"Many reasons" is the subject (plural) → THERE ARE. Flip: "Many reasons there are."',
},
{
label: "Gerund Subject",
sublabel: "Studying the data __ essential.",
content:
"A gerund (-ing phrase) as subject is always singular → IS essential.",
},
{
label: "Collective Nouns",
sublabel: "The committee __ voted unanimously.",
content:
"Committee = one group = singular → HAS voted. Same for team, group, family, audience, class.",
},
{
label: "Indefinite Pronouns",
sublabel: "Each of the participants __ required.",
content: "Each is always singular → IS required.",
},
{
label: "Neither/Nor Proximity",
sublabel: "Neither the principal nor the teachers __",
content:
"Or/nor: agree with the NEAREST noun → teachers (plural) → ARE responsible.",
},
];
const PRONOUN_RULES: RevealCard[] = [
{
label: "Number Agreement",
content:
'Pronoun must match its antecedent in number. "Each student should bring their own materials" is accepted on SAT for singular "they."',
},
{
label: "This/That Must Have a Clear Referent",
content:
'Always make sure "this" or "that" refers to a specific noun, not a vague idea. If unclear, replace with the noun.',
},
{
label: "Emphatic Pronouns",
content:
'These (himself/herself/themselves) can NEVER be the subject. ✗ "Himself was the only one who knew." ✓ "He himself was the only one who knew."',
},
{
label: "Missing Antecedent",
content:
'Every pronoun must refer to a specific named noun. If "they" could refer to two different nouns, replace it with the noun it means.',
},
{
label: "Person Consistency",
content:
'Do not switch between "one," "you," and "we" within the same passage. Match the pronoun person already established.',
},
];
const SVA_TREE: TreeNode = {
id: "sva-root",
question:
"Is there a prepositional phrase or interrupting clause BETWEEN the subject and verb?",
hint: 'Cross out everything introduced by "of," "in," "with," "for," "by," "including," etc., up to the next punctuation.',
yesLabel: "Yes — there is interrupting material",
noLabel: "No — subject and verb are adjacent",
yes: {
id: "cross-out",
question:
"After crossing out the interrupting material, is the true subject singular or plural?",
hint: "The true subject is the noun BEFORE the interrupting phrase. Ignore the noun inside the phrase.",
yesLabel: "Singular subject",
noLabel: "Plural subject",
yes: {
id: "singular-verb",
question: "",
result:
"✓ Use a SINGULAR verb (is, was, has, does). The interrupting phrase cannot change the subject.",
resultType: "correct",
ruleRef: 'The list [of requirements] IS long — not "are"',
},
no: {
id: "plural-verb",
question: "",
result:
"✓ Use a PLURAL verb (are, were, have, do). The interrupting phrase cannot change the subject.",
resultType: "correct",
ruleRef: 'The scientists [in the lab] ARE conducting — not "is"',
},
},
no: {
id: "no-interrupt",
question:
"Is the subject an indefinite pronoun (each, every, either, neither, anyone, everyone, someone, nobody)?",
hint: "These pronouns are always grammatically singular even when they refer to groups.",
yesLabel: "Yes — indefinite pronoun subject",
noLabel: "No — regular noun or pronoun",
yes: {
id: "indefinite",
question: "",
result:
"✓ Use a SINGULAR verb. Each, every, either, neither, anyone, everyone, someone, nobody are all grammatically singular.",
resultType: "correct",
ruleRef: 'Each of the students IS responsible — not "are"',
},
no: {
id: "regular-agree",
question: "",
result:
"✓ Match the verb to the subject normally: singular noun = singular verb; plural noun = plural verb.",
resultType: "info",
ruleRef: "Standard agreement: The dog barks / The dogs bark",
},
},
};
const TREE_SCENARIOS: TreeScenario[] = [
{
label: "SVA 1",
sentence:
"The collection of ancient manuscripts were donated to the university library.",
tree: SVA_TREE,
},
{
label: "SVA 2",
sentence:
"Neither of the proposed solutions address the root cause of the problem.",
tree: SVA_TREE,
},
{
label: "SVA 3",
sentence:
"The team of researchers have published their findings in three separate journals.",
tree: SVA_TREE,
},
];
const EBRWFormStructureSenseLesson: React.FC<LessonProps> = ({ onFinish }) => {
const [activeSection, setActiveSection] = useState(0);
const sectionsRef = useRef<(HTMLElement | null)[]>([]);
useEffect(() => {
const observers: IntersectionObserver[] = [];
sectionsRef.current.forEach((el, idx) => {
if (!el) return;
const obs = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) setActiveSection(idx);
},
{ threshold: 0.3 },
);
obs.observe(el);
observers.push(obs);
});
return () => observers.forEach((o) => o.disconnect());
}, []);
useScrollReveal();
const scrollToSection = (index: number) => {
setActiveSection(index);
sectionsRef.current[index]?.scrollIntoView({ behavior: "smooth" });
};
const SectionMarker = ({
index,
title,
icon: Icon,
}: {
index: number;
title: string;
icon: React.ElementType;
}) => {
const isActive = activeSection === index;
const isPast = activeSection > index;
return (
<button
onClick={() => scrollToSection(index)}
className={`flex items-center gap-3 p-3 w-full rounded-lg text-left transition-all ${isActive ? "bg-purple-50" : "hover:bg-slate-50"}`}
>
<div
className={`w-8 h-8 rounded-full flex items-center justify-center shrink-0
${isActive ? "bg-purple-600 text-white" : isPast ? "bg-purple-400 text-white" : "bg-slate-200 text-slate-500"}`}
>
{isPast ? (
<Check className="w-4 h-4" />
) : (
// @ts-ignore
<Icon className="w-4 h-4" />
)}
</div>
<p
className={`text-sm font-bold ${isActive ? "text-purple-900" : "text-slate-600"}`}
>
{title}
</p>
</button>
);
};
return (
<div className="flex flex-col lg:flex-row min-h-screen">
<aside className="w-full lg:w-64 lg:fixed lg:top-14 lg:bottom-0 lg:overflow-y-auto p-4 border-r border-slate-200 bg-slate-50 z-0 hidden lg:block">
<nav className="space-y-2 pt-6">
<SectionMarker
index={0}
title="Subject-Verb Agreement"
icon={BookOpen}
/>
<SectionMarker
index={1}
title="Verb Forms &amp; Tense"
icon={Clock}
/>
<SectionMarker
index={2}
title="Pronouns &amp; Apostrophes"
icon={Users}
/>
<SectionMarker
index={3}
title="Modifiers &amp; Parallel"
icon={AlertTriangle}
/>
<SectionMarker index={4} title="SVA Decision Tree" icon={GitBranch} />
<SectionMarker index={5} title="Practice Questions" icon={Zap} />
</nav>
</aside>
<div className="flex-1 lg:ml-64 md:p-12 max-w-full mx-auto">
{/* ── Section 0: Subject-Verb Agreement ── */}
<section
ref={(el) => {
sectionsRef.current[0] = el;
}}
className="min-h-screen flex flex-col justify-center mb-24 pt-20 lg:pt-0"
>
<div className="inline-flex items-center gap-2 bg-purple-100 text-purple-700 px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider mb-4 w-fit">
Standard English Conventions Domain 3
</div>
<h2 className="text-4xl font-extrabold text-slate-900 mb-2">
Form, Structure &amp; Sense
</h2>
<p className="text-lg text-slate-500 mb-8">
Seven grammar skills: subject-verb agreement, verb forms, pronouns,
apostrophes, modification, parallel structure, and comma usage.
</p>
{/* §8.1 SVA */}
<div className="scroll-reveal stagger-1 rounded-2xl p-6 mb-8 bg-purple-50 border border-purple-200 space-y-4">
<h3 className="text-lg font-bold text-purple-900">
Subject-Verb Agreement: All 6 Disguises
</h3>
<p className="text-sm text-slate-700">
The SAT hides the subject behind phrases and structural tricks.
Tap each disguise to see how to solve it:
</p>
<RevealCardGrid
cards={SVA_DISGUISES}
columns={3}
accentColor="purple"
/>
<div className="bg-white border border-purple-200 rounded-xl p-4">
<p className="font-bold text-purple-800 text-sm mb-2">
Indefinite Pronoun Quick Reference
</p>
<div className="overflow-x-auto">
<table className="w-full text-xs border-collapse rounded-xl overflow-hidden">
<thead>
<tr className="bg-purple-100">
<th className="px-3 py-1 text-left text-purple-800">
Always Singular
</th>
<th className="px-3 py-1 text-left text-purple-800">
Always Plural
</th>
<th className="px-3 py-1 text-left text-purple-800">
Context-Dependent
</th>
</tr>
</thead>
<tbody>
<tr className="bg-white">
<td className="px-3 py-2 text-slate-600">
each, every, either, neither, one, anyone, everyone,
someone, no one, nobody, somebody, everybody, anything,
everything, something, nothing
</td>
<td className="px-3 py-2 text-slate-600">
both, few, many, several, others
</td>
<td className="px-3 py-2 text-slate-600">
all, any, more, most, none, some match to the noun in
the "of" phrase: "most of the water IS" / "most of the
results ARE"
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</section>
{/* ── Section 1: Verb Forms & Tense ── */}
<section
ref={(el) => {
sectionsRef.current[1] = el;
}}
className="min-h-screen flex flex-col justify-center mb-24"
>
<h2 className="text-4xl font-extrabold text-slate-900 mb-2">
Verb Forms &amp; Tense
</h2>
<p className="text-lg text-slate-500 mb-8">
The SAT tests specific verb form errors, not just tense consistency.
</p>
{/* Tense Table */}
<div className="scroll-reveal stagger-1 rounded-2xl p-6 mb-8 bg-purple-50 border border-purple-200 space-y-4">
<h3 className="text-lg font-bold text-purple-900">
Tense Consistency
</h3>
<div className="overflow-x-auto">
<table className="w-full text-sm border-collapse rounded-xl overflow-hidden">
<thead>
<tr className="bg-purple-600 text-white">
<th className="px-3 py-2 text-left text-xs">Tense</th>
<th className="px-3 py-2 text-left text-xs">
Signal Words
</th>
<th className="px-3 py-2 text-left text-xs">Example</th>
</tr>
</thead>
<tbody>
{[
[
"Simple Present",
"now, every day, always, usually, generally",
"The study examines 500 participants.",
],
[
"Simple Past",
"yesterday, last year, in 1990, ago",
"The study examined 500 participants.",
],
[
"Present Perfect",
"since, for, recently, already, yet",
"Researchers have found new evidence.",
],
[
"Past Perfect",
"before, by the time, had already",
"By 2020, scientists had confirmed the theory.",
],
[
"Future",
"tomorrow, next year, will",
"The team will publish next month.",
],
[
"Progressive",
"currently, at the moment, was/is + -ing",
"She was collecting data when the power failed.",
],
].map(([tense, signals, ex], i) => (
<tr
key={tense}
className={i % 2 === 0 ? "bg-white" : "bg-purple-50"}
>
<td className="px-3 py-2 font-bold text-purple-800 text-xs">
{tense}
</td>
<td className="px-3 py-2 text-slate-500 text-xs">
{signals}
</td>
<td className="px-3 py-2 text-slate-700 text-xs italic">
{ex}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Past Conditional */}
<div className="scroll-reveal stagger-2 rounded-2xl p-6 mb-8 bg-white border border-slate-200 space-y-4">
<h3 className="text-lg font-bold text-slate-900">
Past Conditional &amp; Irregular Past Participles
</h3>
<div className="bg-purple-50 border border-purple-200 rounded-xl p-4">
<p className="font-bold text-purple-800 text-sm mb-2">
Past Conditional (Counterfactual)
</p>
<p className="text-xs text-slate-600 mb-2">
Use <span className="font-bold">had + past participle</span> in
the "if" clause and{" "}
<span className="font-bold">would have + past participle</span>{" "}
in the result clause.
</p>
<ul className="space-y-1 text-xs">
<li className="text-red-600">
"If she would have studied harder, she would have passed."
</li>
<li className="text-green-700">
"If she <strong>had studied</strong> harder, she{" "}
<strong>would have passed</strong>."
</li>
<li className="text-slate-400 italic mt-1">
The SAT frequently places "would have" in the if-clause
always wrong.
</li>
</ul>
</div>
<div className="overflow-x-auto">
<table className="w-full text-xs border-collapse rounded-xl overflow-hidden">
<thead>
<tr className="bg-slate-700 text-white">
<th className="px-3 py-2 text-left">Base Form</th>
<th className="px-3 py-2 text-left">Simple Past</th>
<th className="px-3 py-2 text-left">
Past Participle (with have/had)
</th>
</tr>
</thead>
<tbody>
{[
["go", "went", "gone (had gone ✓ / had went ✗)"],
["rise", "rose", "risen (had risen ✓ / had rose ✗)"],
["lie (recline)", "lay", "lain (had lain ✓)"],
["lay (place)", "laid", "laid (had laid ✓)"],
["choose", "chose", "chosen (had chosen ✓)"],
["swim", "swam", "swum (had swum ✓ / had swam ✗)"],
["begin", "began", "begun (had begun ✓ / had began ✗)"],
["see", "saw", "seen (had seen ✓ / had saw ✗)"],
].map(([base, past, participle], i) => (
<tr
key={base}
className={i % 2 === 0 ? "bg-white" : "bg-slate-50"}
>
<td className="px-3 py-2 font-bold text-purple-800">
{base}
</td>
<td className="px-3 py-2 text-slate-600">{past}</td>
<td className="px-3 py-2 text-slate-700">{participle}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4">
<p className="font-bold text-amber-800 text-sm mb-2">
To vs. -ing After Certain Verbs
</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-xs">
{[
{
label: "Verbs that take TO",
words:
"want, hope, plan, decide, need, agree, refuse, offer, expect, seem",
},
{
label: "Verbs that take -ING",
words:
"enjoy, avoid, finish, consider, deny, practice, suggest, admit, keep, quit",
},
{
label: "Meaning changes",
words:
"stop to do (pause) / stop doing (cease); remember to do / remember doing",
},
].map((v) => (
<div key={v.label}>
<p className="font-bold text-amber-700 mb-1">{v.label}</p>
<p className="text-slate-600">{v.words}</p>
</div>
))}
</div>
</div>
<div className="bg-purple-50 border border-purple-200 rounded-xl p-4">
<p className="font-bold text-purple-800 text-sm mb-2">
Passive Voice
</p>
<p className="text-xs text-slate-600 mb-3">
Passive voice reverses the usual subject-verb-object order: the
receiver of the action becomes the grammatical subject. The SAT
does not treat passive voice as inherently wrong but it tests
whether you can identify the correct form.
</p>
<ul className="space-y-1 text-xs mb-3">
<li className="text-slate-600">
<span className="font-bold">Active:</span> "The researcher
collected the data."
</li>
<li className="text-slate-600">
<span className="font-bold">Passive:</span> "The data was
collected by the researcher."
</li>
</ul>
<p className="text-xs text-slate-500 italic">
Passive is formed with a form of 'be' + past participle. On the
SAT, check whether the passive form matches the correct tense
and whether the subject-verb agreement is correct in the passive
construction.
</p>
</div>
</div>
</section>
{/* ── Section 2: Pronouns & Apostrophes ── */}
<section
ref={(el) => {
sectionsRef.current[2] = el;
}}
className="min-h-screen flex flex-col justify-center mb-24"
>
<h2 className="text-4xl font-extrabold text-slate-900 mb-2">
Pronouns &amp; Apostrophes
</h2>
<p className="text-lg text-slate-500 mb-8">
Pronoun errors are among the most commonly tested items in Form,
Structure &amp; Sense.
</p>
{/* Relative Pronouns */}
<div className="scroll-reveal stagger-1 rounded-2xl p-6 mb-8 bg-purple-50 border border-purple-200 space-y-4">
<h3 className="text-lg font-bold text-purple-900">Pronoun Rules</h3>
<div className="overflow-x-auto">
<table className="w-full text-sm border-collapse rounded-xl overflow-hidden">
<thead>
<tr className="bg-purple-600 text-white">
<th className="px-3 py-2 text-left text-xs">Pronoun</th>
<th className="px-3 py-2 text-left text-xs">Used For</th>
<th className="px-3 py-2 text-left text-xs">Example</th>
</tr>
</thead>
<tbody>
{[
[
"who / whom",
"People only",
'"the researcher who discovered…" / "the scientist whom they selected…"',
],
[
"which",
"Things / non-people (non-essential)",
'"the study, which examined 500 people…" — always gets a comma',
],
[
"that",
"Things / non-people (essential)",
'"the study that confirmed the theory" — no comma',
],
[
"where",
"Places",
'"the laboratory where the experiment occurred…"',
],
["when", "Times", '"the decade when the research began…"'],
].map(([pron, use, ex], i) => (
<tr
key={pron}
className={i % 2 === 0 ? "bg-white" : "bg-purple-50"}
>
<td className="px-3 py-2 font-bold text-purple-800 text-xs whitespace-nowrap">
{pron}
</td>
<td className="px-3 py-2 text-slate-600 text-xs">
{use}
</td>
<td className="px-3 py-2 text-slate-600 text-xs italic">
{ex}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="bg-purple-900 text-white rounded-xl p-4">
<p className="font-bold text-sm mb-1">
Who vs. Whom Substitution Test
</p>
<ul className="text-xs text-purple-100 space-y-1">
<li>
<span className="font-bold">Who</span> = subject (substitutes
he/she): "Who wrote the report?" "He wrote it."
</li>
<li>
<span className="font-bold">Whom</span> = object (substitutes
him/her): "To whom did you give it?" "You gave it to him."
</li>
</ul>
</div>
<p className="text-sm font-semibold text-slate-800">
Additional Pronoun Rules tap to reveal:
</p>
<RevealCardGrid
cards={PRONOUN_RULES}
columns={3}
accentColor="purple"
/>
</div>
{/* Apostrophes */}
<div className="scroll-reveal stagger-2 rounded-2xl p-6 mb-8 bg-white border border-slate-200 space-y-4">
<h3 className="text-lg font-bold text-slate-900">Apostrophes</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="card-tilt bg-purple-50 border border-purple-200 rounded-xl p-4">
<p className="font-bold text-purple-800 text-sm mb-2">
Noun Apostrophes (Possession)
</p>
<ul className="space-y-1 text-xs text-slate-700">
<li>
<span className="font-bold">Singular noun:</span> add 's →
the scientist's data
</li>
<li>
<span className="font-bold">Plural noun ending in s:</span>{" "}
add ' → the scientists' lab
</li>
<li>
<span className="font-bold">
Plural noun NOT ending in s:
</span>{" "}
add 's → the children's results
</li>
</ul>
</div>
<div className="card-tilt bg-purple-50 border border-purple-200 rounded-xl p-4">
<p className="font-bold text-purple-800 text-sm mb-2">
Pronoun Apostrophes (Contractions Only)
</p>
<div className="grid grid-cols-2 gap-x-2 gap-y-1 text-xs">
{[
["its", "possessive"],
["it's", "it is / it has"],
["their", "possessive"],
["they're", "they are"],
["whose", "possessive"],
["who's", "who is / who has"],
["your", "possessive"],
["you're", "you are"],
].map(([form, meaning]) => (
<div key={form} className="flex gap-1">
<span className="font-bold text-purple-700">{form}</span>
<span className="text-slate-500">= {meaning}</span>
</div>
))}
</div>
</div>
</div>
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4">
<p className="font-bold text-amber-800 text-sm mb-1">
Apostrophe Shortcut
</p>
<p className="text-xs text-slate-700">
If you can read the word as two words (it is it's), it's a
contraction use the apostrophe. Pronouns{" "}
<span className="font-bold">never</span> use apostrophes for
possession (opposite of nouns).
</p>
</div>
</div>
</section>
{/* ── Section 3: Modifiers, Parallel Structure & Commas ── */}
<section
ref={(el) => {
sectionsRef.current[3] = el;
}}
className="min-h-screen flex flex-col justify-center mb-24"
>
<h2 className="text-4xl font-extrabold text-slate-900 mb-2">
Modifiers, Parallel Structure &amp; Commas
</h2>
<p className="text-lg text-slate-500 mb-8">
Three interrelated skills that test whether elements are correctly
placed and balanced.
</p>
{/* §8.5 Modification */}
<div className="scroll-reveal stagger-1 rounded-2xl p-6 mb-8 bg-purple-50 border border-purple-200 space-y-4">
<h3 className="text-lg font-bold text-purple-900">
Modification Errors
</h3>
<div className="space-y-3">
<div className="card-tilt bg-white border border-red-200 rounded-xl p-4">
<p className="font-bold text-red-800 text-sm mb-2">
Dangling Modifier
</p>
<p className="text-xs text-slate-500 mb-2">
The modifier has no logical subject in the sentence, or the
subject it should modify does not immediately follow it.
</p>
<ul className="space-y-1 text-xs">
<li className="text-red-600">
"Running quickly, the bus was missed." The bus isn't
running.
</li>
<li className="text-green-700">
✓ "Running quickly, she missed the bus." — She was running;
subject appears right after comma.
</li>
<li className="text-amber-700 italic mt-1">
Possessive Trap: "Wishing to pass,{" "}
<strong>the student's</strong> exam" — the possessive makes
"exam" the subject. Use: "Wishing to pass,{" "}
<strong>the student</strong> reviewed"
</li>
</ul>
</div>
<div className="card-tilt bg-white border border-amber-200 rounded-xl p-4">
<p className="font-bold text-amber-800 text-sm mb-2">
Misplaced Modifier
</p>
<p className="text-xs text-slate-500 mb-2">
The modifier is positioned too far from the noun it modifies.
</p>
<ul className="space-y-1 text-xs">
<li className="text-red-600">
✗ "She only eats salad." — only implies she does nothing
else?
</li>
<li className="text-green-700">
✓ "She eats only salad." — only modifies "salad."
</li>
</ul>
</div>
<div className="card-tilt bg-white border border-slate-200 rounded-xl p-4">
<p className="font-bold text-purple-800 text-sm mb-2">
Parenthetical Modifiers — Matching Punctuation
</p>
<p className="text-xs text-slate-500 mb-2">
A non-essential phrase must be enclosed in matching
punctuation: two commas, two dashes, or two parentheses. Never
mix them.
</p>
<ul className="space-y-1 text-xs">
<li className="text-red-600">
✗ "The researcher, who had published 40 papers presented
her findings."
</li>
<li className="text-green-700">
✓ "The researcher, who had published 40 papers, presented
her findings."
</li>
<li className="text-green-700">
✓ "The researcher who had published 40 papers presented
her findings."
</li>
</ul>
</div>
</div>
</div>
{/* §8.6 Parallel Structure */}
<div className="scroll-reveal stagger-2 rounded-2xl p-6 mb-8 bg-white border border-slate-200 space-y-4">
<h3 className="text-lg font-bold text-slate-900">
Parallel Structure
</h3>
<div className="space-y-2">
{[
{
type: "Lists",
bad: "She enjoys hiking, to swim, and reading.",
good: "She enjoys hiking, swimming, and reading.",
note: "All items must be the same grammatical form.",
},
{
type: "Correlative Pairs (not onlybut also, bothand, eitheror)",
bad: "He not only speaks French but also is fluent in German.",
good: "He not only speaks French but also speaks German.",
note: 'What follows "not only" must be parallel to what follows "but also."',
},
{
type: "Comparisons",
bad: "Running is healthier than to sit all day.",
good: "Running is healthier than sitting all day.",
note: "Both sides of a comparison must be the same form.",
},
{
type: "Semicolon Lists",
bad: "The study examined diet; the exercise habits; and their stress levels.",
good: "The study examined diet; exercise habits; and stress levels.",
note: "Items separated by semicolons must be parallel.",
},
].map((p) => (
<div
key={p.type}
className="card-tilt bg-slate-50 border border-slate-200 rounded-xl p-4"
>
<p className="font-bold text-purple-800 text-xs mb-1">
{p.type}
</p>
<p className="text-xs text-red-600">✗ {p.bad}</p>
<p className="text-xs text-green-700">✓ {p.good}</p>
<p className="text-xs text-slate-400 italic mt-1">{p.note}</p>
</div>
))}
</div>
</div>
{/* §8.7 Commas */}
<div className="scroll-reveal stagger-3 rounded-2xl p-6 mb-8 bg-purple-50 border border-purple-200 space-y-4">
<h3 className="text-lg font-bold text-purple-900">Comma Rules</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="card-tilt bg-white border border-green-200 rounded-xl p-4">
<p className="font-bold text-green-800 text-sm mb-2">
When a Comma IS Required
</p>
<ul className="space-y-1 text-xs text-slate-700">
<li>
✓ After an introductory element at the start of a sentence.
</li>
<li>
✓ Before and after a non-essential (parenthetical) clause.
</li>
<li>✓ Before a FANBOYS joining two independent clauses.</li>
<li>✓ Between items in a list of three or more.</li>
<li>
✓ Between coordinate adjectives that independently modify
the noun.
</li>
</ul>
</div>
<div className="card-tilt bg-white border border-red-200 rounded-xl p-4">
<p className="font-bold text-red-800 text-sm mb-2">
When a Comma Is NOT Used
</p>
<ul className="space-y-1 text-xs text-slate-700">
<li>✗ Between a subject and its verb.</li>
<li>
✗ Before a subordinate clause that follows the main clause
("left because she was tired" — no comma before "because").
</li>
<li>
✗ Before a FANBOYS when one side is a phrase, not an IC.
</li>
<li>✗ After a FANBOYS conjunction.</li>
<li>✗ Between a verb and its object or complement.</li>
</ul>
</div>
</div>
</div>
<div className="scroll-reveal-scale golden-rule-glow bg-purple-900 text-white rounded-2xl p-5 mb-8">
<p className="font-bold mb-1">Golden Rule</p>
<p className="text-sm text-purple-100">
For SVA: always cross out the material between subject and verb.
For modifiers: the noun being modified must immediately follow the
modifying phrase. For apostrophes: pronouns never use apostrophes
for possession — only for contractions.
</p>
</div>
</section>
{/* ── Section 4: SVA Decision Tree ── */}
<section
ref={(el) => {
sectionsRef.current[4] = el;
}}
className="min-h-screen flex flex-col justify-center mb-24"
>
<h2 className="text-4xl font-extrabold text-slate-900 mb-2">
SVA Decision Tree
</h2>
<p className="text-lg text-slate-500 mb-8">
Work through subject-verb agreement step by step. Find the true
subject, then match the verb.
</p>
<DecisionTreeWidget scenarios={TREE_SCENARIOS} accentColor="purple" />
</section>
{/* ── Section 5: Practice ── */}
<section
ref={(el) => {
sectionsRef.current[5] = el;
}}
className="min-h-screen flex flex-col justify-center mb-24"
>
<h2 className="text-4xl font-extrabold text-slate-900 mb-6">
Practice Questions
</h2>
{FORM_STRUCTURE_EASY.slice(0, 2).map((q) => (
<PracticeFromDataset key={q.id} question={q} color="purple" />
))}
{FORM_STRUCTURE_MEDIUM.slice(0, 1).map((q) => (
<PracticeFromDataset key={q.id} question={q} color="purple" />
))}
<div className="mt-8 text-center">
<button
onClick={onFinish}
className="px-6 py-3 bg-purple-900 text-white font-bold rounded-full hover:bg-purple-700 transition-colors"
>
Finish Lesson
</button>
</div>
</section>
</div>
</div>
);
};
export default EBRWFormStructureSenseLesson;

View File

@ -273,6 +273,7 @@ const EBRWGraphicDisplaysLesson: React.FC<LessonProps> = ({ onFinish }) => {
{isPast ? ( {isPast ? (
<Check className="w-4 h-4" /> <Check className="w-4 h-4" />
) : ( ) : (
// @ts-ignore
<Icon className="w-4 h-4" /> <Icon className="w-4 h-4" />
)} )}
</div> </div>

View File

@ -1,60 +1,107 @@
import React, { useRef, useState, useEffect } from "react"; import React, { useRef, useState, useEffect } from "react";
import { ArrowDown, Check, BookOpen, AlertTriangle, Zap } from "lucide-react"; import { Check, BookOpen, Lightbulb, Zap, Target } from "lucide-react";
import EvidenceHunterWidget, {
type EvidenceExercise,
} from "../../../components/lessons/EvidenceHunterWidget";
import { PracticeFromDataset } from "../../../components/lessons/LessonShell"; import { PracticeFromDataset } from "../../../components/lessons/LessonShell";
import { import {
INFERENCES_EASY, INFERENCES_EASY,
INFERENCES_MEDIUM, INFERENCES_MEDIUM,
} from "../../../data/rw/inferences"; } from "../../../data/rw/inferences";
import EvidenceHunterWidget, {
type EvidenceExercise,
} from "../../../components/lessons/EvidenceHunterWidget";
import RevealCardGrid, {
type RevealCard,
} from "../../../components/lessons/RevealCardGrid";
import useScrollReveal from "../../../components/lessons/useScrollReveal";
interface LessonProps { interface LessonProps {
onFinish?: () => void; onFinish?: () => void;
} }
/* ── Data for RevealCardGrid widgets ── */
const INFERENCE_TYPES: RevealCard[] = [
{
label: "Valid / Necessary",
sublabel: "CORRECT on SAT",
content:
"Must be true if the stated evidence is true. Example: If 14% of earthquakes are supershear → 86% are NOT supershear.",
},
{
label: "Possible / Speculative",
sublabel: "WRONG on SAT",
content:
'Could be true, but might not be; goes further than the evidence. Example: "Researchers must want more funding" — not stated.',
},
{
label: "Contradicted",
sublabel: "WRONG on SAT",
content:
'Directly conflicts with stated evidence. Example: "Exercise improves fitness equally for all" when passage says otherwise.',
},
{
label: "Real-world true but unsupported",
sublabel: "WRONG on SAT",
content:
"True in reality but not implied by the passage. The SAT only rewards what the text guarantees.",
},
{
label: "Half-valid",
sublabel: "WRONG on SAT",
content:
"First part follows from the evidence, but the second part goes beyond what the evidence requires.",
},
];
const CHAIN_ERRORS: RevealCard[] = [
{
label: "Stopping too early",
content:
'Stopping at the finding rather than the conclusion (e.g., stopping at "feldspar was found" rather than following its implication for the competing theories).',
},
{
label: "Skipping a step",
content:
"Jumping to a conclusion that goes further than the chain allows by skipping an intermediate logical step.",
},
{
label: "Scope confusion",
content:
"The chain applies to one specific region or group, but the answer makes a universal claim about all regions or groups.",
},
{
label: "Time assumption",
content:
"The evidence describes a current or past state, and the answer makes a claim about a future state without justification.",
},
];
const EVIDENCE_EXERCISES: EvidenceExercise[] = [ const EVIDENCE_EXERCISES: EvidenceExercise[] = [
{ {
question: question:
"What can be inferred from this passage about the long-term effects of the policy?", "Based on the passage, which sentence provides the strongest basis for inferring that Dr. Patel's research contradicts established scientific consensus?",
passage: [ passage: [
"When the city introduced congestion pricing in 2019, many business owners predicted economic disaster.", "Dr. Patel has spent fifteen years studying migration patterns of monarch butterflies.",
"Three years later, traffic in the city center had declined by 28%, and air quality had measurably improved.", "Her field stations span the entire North American flyway, from Canada to Mexico.",
"Revenue from the pricing scheme was reinvested in public transit, increasing bus and metro frequency by 40%.", "She has published 47 peer-reviewed papers, each building on data collected across multiple seasons.",
"Business revenues in the city center rose by an average of 12% over the same period, contradicting earlier fears.", "Her most recent paper challenges the assumption that butterflies navigate primarily by magnetic fields.",
"Several other major cities are now closely studying the program as a potential model.", "Instead, she proposes that polarized light plays a more significant role than previously recognized.",
], ],
evidenceIndex: 3, evidenceIndex: 3,
explanation: explanation:
"Sentence 4 most strongly supports the inference that the policy had positive long-term effects on the local economy — directly contradicting predictions of economic harm. This is a valid inference because it follows necessarily from the evidence. Sentence 5 supports the inference that the policy was considered successful, but sentence 4 specifically addresses the economic outcome.", 'Sentence 4 is the basis for inferring contradiction with consensus. The word "challenges" indicates Dr. Patel is actively contradicting an established "assumption." This makes the inference valid — not speculation — because the passage directly states she is challenging the field.',
}, },
{ {
question: question:
"What does the passage imply about the relationship between diet and cognitive decline?", "Which sentence provides the strongest basis for inferring that the plastic bag ban had unintended consequences?",
passage: [ passage: [
"Alzheimer's disease affects more than 55 million people worldwide.", "In 2015, the city council banned plastic bags at all grocery stores.",
"In recent years, researchers have shifted focus from genetic factors alone to lifestyle factors, including diet.", "The ban was intended to reduce plastic waste in local waterways.",
"Several large-scale studies have found that individuals who follow Mediterranean-style diets — rich in vegetables, fish, and olive oil — show slower rates of cognitive decline.", "Plastic bag litter in rivers decreased by 40% in the first year.",
"However, researchers caution that correlation does not establish causation, and no single food has been proven to prevent Alzheimer's.", "However, sales of heavier-gauge trash bags increased by 350% over the same period.",
"Still, the evidence is strong enough that many neurologists now discuss dietary patterns with patients at risk.", "Environmental analysts noted that thick trash bags contain more plastic by weight than the thin bags they replaced.",
], ],
evidenceIndex: 2, evidenceIndex: 3,
explanation: explanation:
'Sentence 3 most directly supports the implication: Mediterranean diets are associated with slower cognitive decline. This is an inference the passage clearly supports. Sentence 4 is a caution about causation — it limits the inference, which is exactly why "diet prevents Alzheimer\'s" (too strong) would be wrong.', "Sentence 4 is the evidence of the unintended consequence — a massive surge in heavier plastic bag purchases. Combined with sentence 5, it implies total plastic use may have increased, the opposite of the ban's stated goal.",
},
{
question:
"What can be inferred about the scientist's attitude toward the technology?",
passage: [
"Dr. Reyes has spent the last decade studying CRISPR applications in agriculture.",
"In her 2023 report, she called the technology 'one of the most significant breakthroughs in food science in the last fifty years.'",
"She was careful, however, to note that large-scale deployment would require extensive safety testing over multiple growing seasons.",
"She also advocated for transparent public communication about how modified crops differ from conventional ones.",
"Despite her caution, her lab has continued to accelerate its own research timeline.",
],
evidenceIndex: 1,
explanation:
"Sentence 2 most directly reveals the scientist's attitude: she views CRISPR as one of the most significant breakthroughs in fifty years — clearly positive. The word \"careful\" in sentence 3 adds nuance but doesn't change her fundamental enthusiasm. An inference about her attitude should be grounded in her own words in sentence 2.",
}, },
]; ];
@ -78,9 +125,11 @@ const EBRWInferencesLesson: React.FC<LessonProps> = ({ onFinish }) => {
return () => observers.forEach((o) => o.disconnect()); return () => observers.forEach((o) => o.disconnect());
}, []); }, []);
const scrollToSection = (i: number) => { useScrollReveal();
setActiveSection(i);
sectionsRef.current[i]?.scrollIntoView({ behavior: "smooth" }); const scrollToSection = (index: number) => {
setActiveSection(index);
sectionsRef.current[index]?.scrollIntoView({ behavior: "smooth" });
}; };
const SectionMarker = ({ const SectionMarker = ({
@ -100,11 +149,13 @@ const EBRWInferencesLesson: React.FC<LessonProps> = ({ onFinish }) => {
className={`flex items-center gap-3 p-3 w-full rounded-lg text-left transition-all ${isActive ? "bg-teal-50" : "hover:bg-slate-50"}`} className={`flex items-center gap-3 p-3 w-full rounded-lg text-left transition-all ${isActive ? "bg-teal-50" : "hover:bg-slate-50"}`}
> >
<div <div
className={`w-8 h-8 rounded-full flex items-center justify-center shrink-0 ${isActive ? "bg-teal-600 text-white" : isPast ? "bg-teal-400 text-white" : "bg-slate-200 text-slate-500"}`} className={`w-8 h-8 rounded-full flex items-center justify-center shrink-0
${isActive ? "bg-teal-600 text-white" : isPast ? "bg-teal-400 text-white" : "bg-slate-200 text-slate-500"}`}
> >
{isPast ? ( {isPast ? (
<Check className="w-4 h-4" /> <Check className="w-4 h-4" />
) : ( ) : (
// @ts-ignore
<Icon className="w-4 h-4" /> <Icon className="w-4 h-4" />
)} )}
</div> </div>
@ -121,190 +172,461 @@ const EBRWInferencesLesson: React.FC<LessonProps> = ({ onFinish }) => {
<div className="flex flex-col lg:flex-row min-h-screen"> <div className="flex flex-col lg:flex-row min-h-screen">
<aside className="w-full lg:w-64 lg:fixed lg:top-14 lg:bottom-0 lg:overflow-y-auto p-4 border-r border-slate-200 bg-slate-50 z-0 hidden lg:block"> <aside className="w-full lg:w-64 lg:fixed lg:top-14 lg:bottom-0 lg:overflow-y-auto p-4 border-r border-slate-200 bg-slate-50 z-0 hidden lg:block">
<nav className="space-y-2 pt-6"> <nav className="space-y-2 pt-6">
<SectionMarker <SectionMarker index={0} title="Valid Inferences" icon={BookOpen} />
index={0}
title="Concept & Annotation"
icon={BookOpen}
/>
<SectionMarker <SectionMarker
index={1} index={1}
title="Evidence Hunter" title="Inference Patterns"
icon={AlertTriangle} icon={Lightbulb}
/> />
<SectionMarker index={2} title="Practice Quiz" icon={Zap} />
<SectionMarker index={2} title="Inference Tracker" icon={Target} />
<SectionMarker index={3} title="Practice Questions" icon={Zap} />
</nav> </nav>
</aside> </aside>
<div className="flex-1 lg:ml-64 p-6 md:p-12 max-w-4xl mx-auto"> <div className="flex-1 lg:ml-64 md:p-12 max-w-full mx-auto">
{/* Section 0: Concept & Annotation */} {/* Section 0 — Valid Inferences */}
<section <section
ref={(el) => { ref={(el) => {
sectionsRef.current[0] = el; sectionsRef.current[0] = el;
}} }}
className="min-h-screen flex flex-col justify-center mb-24 pt-20 lg:pt-0" className="min-h-screen flex flex-col justify-center mb-24 pt-20 lg:pt-0"
> >
<div className="inline-flex items-center gap-2 bg-teal-100 text-teal-700 px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider mb-4"> <div className="inline-flex items-center gap-2 bg-teal-100 text-teal-700 px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider mb-4 w-fit">
Information & Ideas Information &amp; Ideas Domain 2
</div> </div>
<h2 className="text-4xl font-extrabold text-slate-900 mb-2"> <h2 className="text-4xl font-extrabold text-slate-900 mb-2">
Inferences Inferences
</h2> </h2>
<p className="text-lg text-slate-500 mb-8"> <p className="text-lg text-slate-500 mb-8">
A valid inference is not stated but is strongly supported. It never A valid inference MUST be true based on the passage not merely
exceeds what the text supports and never uses extreme language. possible, plausible, or consistent.
</p> </p>
{/* Rule grid */} {/* Core Concept — now with RevealCardGrid */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-8"> <div className="scroll-reveal stagger-1 bg-teal-50 border border-teal-200 rounded-2xl p-6 mb-8">
{[ <h3 className="text-lg font-bold text-teal-900 mb-2">
{ The Core Concept What Makes an Inference Valid?
num: "1", </h3>
title: "Inference = Logical Extension", <p className="text-sm text-slate-700 mb-4">
body: "An inference is not stated directly. It's a conclusion that must logically follow from what the text says.", A valid inference is a statement that MUST be true if the evidence
}, is true. It cannot merely be likely, plausible, or consistent. The
{ SAT rewards only conclusions that necessarily follow from what is
num: "2", stated.
title: "Stay Close to the Text",
body: "The SAT rewards inferences that are a small, necessary step from the evidence. Avoid dramatic leaps.",
},
{
num: "3",
title: "Supported, Not Proven",
body: "A valid inference is supported by the text but not explicitly stated. It must be consistent with ALL of the passage, not just one line.",
},
{
num: "4",
title: "Eliminate Extreme Language",
body: "Inferences with 'always,' 'never,' 'all,' 'none,' 'impossible' are almost always wrong — the passage rarely proves absolutes.",
},
].map((rule) => (
<div
key={rule.num}
className="rounded-2xl border border-teal-200 bg-teal-50 p-5"
>
<div className="flex items-center gap-2 mb-2">
<span className="w-7 h-7 rounded-full bg-teal-600 text-white flex items-center justify-center text-xs font-bold shrink-0">
{rule.num}
</span>
<p className="text-sm font-bold text-teal-900">
{rule.title}
</p> </p>
<RevealCardGrid
cards={INFERENCE_TYPES}
columns={3}
accentColor="teal"
/>
</div> </div>
<p className="text-sm text-slate-700 leading-relaxed">
{rule.body} {/* 3-Step Text Completion Process */}
<div className="scroll-reveal stagger-2 rounded-2xl p-6 mb-8 bg-white border border-slate-200 space-y-4">
<h3 className="text-lg font-bold text-slate-900">
The 3-Step Text Completion Process
</h3>
<p className="text-sm text-slate-600">
Text completion questions present a short passage followed by a
blank at the end, asking "Which choice most logically completes
the text?" The correct answer NECESSARILY follows from the stated
evidence not merely the most interesting or plausible
continuation.
</p> </p>
<div className="bg-teal-50 border border-teal-200 rounded-xl p-4">
<p className="font-bold text-teal-800 text-sm mb-2">
Universal Method for All Inference Questions
</p>
<div className="space-y-2">
{[
[
"1",
"IDENTIFY the key claim or evidence (usually in the 12 sentences before the blank). Write it in 36 words on scratch paper.",
],
[
"2",
'WORK OUT the implication: "If this is true, what MUST also be true?" Do this BEFORE looking at answers. Even one word helps.',
],
[
"3",
"MATCH the answer: look for the option that says the same thing as your implication, possibly using different words, negation, or synonyms.",
],
].map(([n, text]) => (
<div key={n} className="flex gap-2">
<span className="w-5 h-5 rounded-full bg-teal-600 text-white flex items-center justify-center text-xs font-bold shrink-0">
{n}
</span>
<p className="text-xs text-slate-700">{text}</p>
</div> </div>
))} ))}
</div> </div>
{/* Static annotation visual */}
<h3 className="text-lg font-bold text-slate-800 mb-3">
Valid vs. Invalid Inference Annotated
</h3>
<div className="space-y-3 mb-8">
<div className="rounded-xl bg-teal-100 border border-teal-200 p-4">
<p className="text-xs font-bold text-teal-700 uppercase tracking-wider mb-1">
Stated in the text
</p>
<p className="text-sm text-slate-800">
"The researcher found that sleep-deprived students scored 15%
lower on memory tests."
</p>
</div> </div>
<div className="rounded-xl bg-green-100 border border-green-200 p-4"> <div className="bg-amber-50 border border-amber-200 rounded-xl p-4">
<p className="text-xs font-bold text-green-700 uppercase tracking-wider mb-1"> <p className="font-bold text-amber-800 text-sm mb-1">
Valid inference WHY THIS MATTERS
</p> </p>
<p className="text-sm text-slate-800"> <p className="text-xs text-slate-700">
Sleep deprivation negatively affects memory performance. If you look at answer choices before working out the
</p> implication, you are vulnerable to speculation traps answers
</div> that sound plausible because they extend the idea in a
<div className="rounded-xl bg-orange-100 border border-orange-200 p-4"> reasonable direction, but go further than the evidence requires.
<p className="text-xs font-bold text-orange-700 uppercase tracking-wider mb-1">
Invalid inference
</p>
<p className="text-sm text-slate-800">
"Sleep is the most important factor in academic performance."
Too broad, not proven by one study.
</p> </p>
</div> </div>
</div> </div>
{/* Trap callout */}
<div className="rounded-2xl bg-red-50 border border-red-200 p-5 mb-8">
<p className="text-sm font-bold text-red-800 mb-2">
Inference Trap
</p>
<p className="text-sm text-slate-700 leading-relaxed">
A choice that is plausible in real life but goes BEYOND what the
passage can actually support. Always ask: "Can I prove this using
only what the passage says?"
</p>
</div>
{/* Golden rule */}
<div className="rounded-2xl bg-teal-900 p-6 mb-8">
<p className="text-xs font-bold text-teal-300 uppercase tracking-wider mb-2">
Golden Rule
</p>
<p className="text-white font-semibold leading-relaxed">
Inferences are the smallest logical step the text allows. If the
inference requires outside knowledge or an additional assumption,
it's wrong.
</p>
</div>
<button
onClick={() => scrollToSection(1)}
className="mt-4 group flex items-center text-teal-600 font-bold hover:text-teal-800 transition-colors"
>
Next: Evidence Hunter{" "}
<ArrowDown className="ml-2 w-5 h-5 group-hover:translate-y-1 transition-transform" />
</button>
</section> </section>
{/* Section 1: Evidence Hunter */} {/* Section 1 — Inference Patterns */}
<section <section
ref={(el) => { ref={(el) => {
sectionsRef.current[1] = el; sectionsRef.current[1] = el;
}} }}
className="min-h-screen flex flex-col justify-center mb-24" className="min-h-screen flex flex-col justify-center mb-24"
> >
<div className="inline-flex items-center gap-2 bg-teal-100 text-teal-700 px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider mb-4">
Interactive Practice
</div>
<h2 className="text-4xl font-extrabold text-slate-900 mb-2"> <h2 className="text-4xl font-extrabold text-slate-900 mb-2">
Evidence Hunter Inference Patterns
</h2> </h2>
<p className="text-lg text-slate-500 mb-8"> <p className="text-lg text-slate-500 mb-8">
For each passage, click the sentence that most strongly supports the The four core patterns, speculation traps, double negatives, and
inference asked. Think: which sentence does the most work for this multi-step chains.
conclusion?
</p> </p>
<EvidenceHunterWidget {/* 6A — 4 Core Patterns */}
exercises={EVIDENCE_EXERCISES} <div className="scroll-reveal stagger-1 rounded-2xl p-6 mb-8 bg-teal-50 border border-teal-200 space-y-4">
<h3 className="text-lg font-bold text-teal-900">
The Four Core Valid Inference Patterns
</h3>
<div className="space-y-4">
{[
{
num: 1,
pattern: "Negation / Contrapositive",
rule: "If only X has property Y, then everything that is not-X must lack property Y. This works in both directions.",
examples: [
"If 14% of earthquakes are supershear events → 86% are NOT supershear events.",
"If a phenomenon occurs ONLY during slow-wave sleep → it does NOT occur during REM sleep.",
"If researchers focused mostly on land → most data comes from land, therefore they may have undercounted non-land events.",
],
},
{
num: 2,
pattern: "Relative Comparison",
rule: "If X is more than Y, you can restate this as Y is less than X. If X is the most, you can infer that all others are less than X.",
examples: [
"If muscular contractions when lowering weights are MOST effective → contractions when raising weights are LESS effective.",
"If big brown bats emit the most cries → all other species emit fewer cries.",
"Restatement direction: if the claim is A > B, a valid answer might say B < A.",
],
},
{
num: 3,
pattern: "Logical Elimination",
rule: "When possibilities are listed and most are ruled out, the remaining possibility is the valid inference.",
examples: [
"Researchers studied supershear earthquakes mostly on land → they did not study underwater earthquakes → many supershear earthquakes likely occur underwater.",
"Two theories for Mars's crust: (a) magma ocean or (b) different origin. If feldspar (associated with b) is found → theory (a) alone does not explain the crust.",
],
},
{
num: 4,
pattern: "Causal / Consequential Extension",
rule: "If X causes Y, then applying X to a new situation should produce Y. Removing X should reduce Y.",
examples: [
"If replay during slow-wave sleep consolidates memory → dancers who sleep several hours right after learning a routine should remember it better two weeks later.",
"If hyperglycemia (high blood sugar) reduces exercise response → a drug that lowers blood sugar should improve exercise response.",
"Apply the mechanism to a new group, new time period, or new scenario — but STAY within the same causal framework.",
],
},
].map((p) => (
<div
key={p.num}
className="card-tilt bg-white border border-slate-200 rounded-xl p-5"
>
<div className="flex items-center gap-2 mb-2">
<span className="w-6 h-6 rounded-full bg-teal-600 text-white flex items-center justify-center text-xs font-bold shrink-0">
{p.num}
</span>
<p className="font-bold text-teal-900">{p.pattern}</p>
</div>
<p className="text-sm text-slate-700 mb-2">{p.rule}</p>
<ul className="space-y-1">
{p.examples.map((ex, i) => (
<li
key={i}
className="text-xs text-slate-500 bg-slate-50 rounded px-2 py-1 italic"
>
{ex}
</li>
))}
</ul>
</div>
))}
</div>
</div>
{/* 6B — Speculation Traps */}
<div className="scroll-reveal stagger-2 rounded-2xl p-6 mb-8 bg-white border border-slate-200 space-y-4">
<h3 className="text-lg font-bold text-slate-900">
Speculation Traps
</h3>
<p className="text-sm text-slate-600">
Speculation traps are the most common wrong answer type in text
completions. They go one plausible step too far beyond what the
evidence necessarily implies.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="card-tilt bg-red-50 border border-red-200 rounded-xl p-4">
<p className="font-bold text-red-800 text-sm mb-2">
How to Identify a Speculation Trap
</p>
<ul className="text-xs text-slate-600 space-y-1">
<li> The answer could be true but doesn't have to be.</li>
<li>
• The answer introduces a new assumption not grounded in the
passage.
</li>
<li>
• The answer requires you to imagine a scenario the passage
doesn't describe.
</li>
<li>
The answer goes from "this happens" to "this is the
best/only/most effective way."
</li>
</ul>
</div>
<div className="card-tilt bg-teal-50 border border-teal-200 rounded-xl p-4">
<p className="font-bold text-teal-800 text-sm mb-2">
Worked Example
</p>
<p className="text-xs text-slate-600 mb-2 italic">
Evidence: Sea turtle conservation focuses on protecting
hatchlings after they emerge.
</p>
<ul className="text-xs space-y-1">
<li className="text-green-700">
VALID: Conservation focuses less on hatchlings before they
emerge.
</li>
<li className="text-red-600">
TRAP: Protecting hatchlings after emergence is the only
effective method.
</li>
<li className="text-red-600">
TRAP: Pre-emergence protection is more effective.
</li>
</ul>
</div>
</div>
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4">
<p className="font-bold text-amber-800 text-sm mb-1">RULE</p>
<p className="text-xs text-slate-700">
The word "only" in an answer choice is almost always a sign of
over-speculation. Very few things on the SAT are literally "the
only way." Be very suspicious of absolute claims in answer
choices.
</p>
</div>
</div>
{/* 6C — Double Negatives & Second Meanings */}
<div className="scroll-reveal stagger-3 rounded-2xl p-6 mb-8 bg-white border border-slate-200 space-y-4">
<h3 className="text-lg font-bold text-slate-900">
Double Negatives and Second Meanings in Answer Choices
</h3>
<p className="text-sm text-slate-600">
Inference answer choices frequently use double negatives (which
create positive meanings) or words in their second meanings. These
are designed to make correct answers look wrong to careless
readers.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="card-tilt bg-teal-50 border border-teal-200 rounded-xl p-4">
<p className="font-bold text-teal-800 text-sm mb-2">
Double Negative Translations
</p>
<ul className="text-xs text-slate-600 space-y-1">
<li> "Not impossible" = possible</li>
<li> "Not unimportant" = important</li>
<li> "Not unlike" = similar</li>
<li> "Less harmful" = milder, but still harmful</li>
<li> Decode completely before evaluating the answer.</li>
<li className="italic text-teal-700">
Strategy: Replace "not un-X" with "X" and "not im-X" with
"X".
</li>
</ul>
</div>
<div className="card-tilt bg-teal-50 border border-teal-200 rounded-xl p-4">
<p className="font-bold text-teal-800 text-sm mb-2">
Second Meanings to Know
</p>
<ul className="text-xs text-slate-600 space-y-1">
<li>
"Qualify" = limit the scope of a claim (not: meet
requirements)
</li>
<li> "Sound" = valid, reliable (not: audio)</li>
<li> "Check" = restrain, control (not: verify)</li>
<li>
"Economy" = thrift, efficiency (not: the financial system)
</li>
<li> "Reserve" = hold off on (not: book in advance)</li>
<li className="italic text-teal-700">
When a simple common word appears as an answer, suspect a
second meaning.
</li>
</ul>
</div>
</div>
</div>
{/* 6D — Multi-Step Logical Chains */}
<div className="scroll-reveal stagger-4 rounded-2xl p-6 mb-8 bg-white border border-slate-200 space-y-4">
<h3 className="text-lg font-bold text-slate-900">
Multi-Step Logical Chains
</h3>
<p className="text-sm text-slate-600">
Some text completion questions require you to follow 23 logical
steps before arriving at the conclusion. Students most often lose
points here by stopping one step too early or introducing an extra
assumption.
</p>
<div className="bg-slate-50 border border-slate-200 rounded-xl p-4">
<p className="font-bold text-slate-800 text-sm mb-2">
Multi-Step Chain: Worked Example (Mars Crust)
</p>
<div className="space-y-1 text-xs text-slate-700">
<p>
<span className="font-bold">Step 1:</span> Two theories exist
for the first Martian crust: (a) all-encompassing magma ocean,
(b) different origin with high silica.
</p>
<p>
<span className="font-bold">Step 2:</span> Researchers find
feldspar (associated with high-silica lava flows) in 9
locations on Mars.
</p>
<p>
<span className="font-bold">Step 3:</span> Feldspar is
evidence for Theory (b) different origin with high silica.
</p>
<p>
<span className="font-bold">Step 4:</span> If Theory (b)
accounts for some locations, Theory (a) (magma ocean) could
not have been ALL-ENCOMPASSING.
</p>
<p className="text-green-700 font-bold mt-2">
Valid Conclusion: The magma ocean was not all-encompassing.
</p>
<p className="text-red-600 italic">
Speculation Trap: "Portions of Mars' surface were never
covered by a crust." (no evidence for this)
</p>
</div>
</div>
<p className="font-semibold text-sm text-slate-800">
Common errors in multi-step chains:
</p>
<RevealCardGrid
cards={CHAIN_ERRORS}
columns={2}
accentColor="teal" accentColor="teal"
/> />
</div>
<button {/* 6E — Scratch Paper */}
onClick={() => scrollToSection(2)} <div className="scroll-reveal stagger-5 rounded-2xl p-6 mb-8 bg-teal-50 border border-teal-200 space-y-4">
className="mt-12 group flex items-center text-teal-600 font-bold hover:text-teal-800 transition-colors" <h3 className="text-lg font-bold text-teal-900">
> Using Scratch Paper for Inference Questions
Next: Practice Quiz{" "} </h3>
<ArrowDown className="ml-2 w-5 h-5 group-hover:translate-y-1 transition-transform" /> <p className="text-sm text-slate-700">
</button> For text completions, scratch paper is not optional it is
essential. Writing down even a brief summary of the key claim and
your predicted answer protects you from being seduced by
plausible-sounding wrong answers.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="card-tilt bg-white border border-teal-100 rounded-xl p-4">
<p className="font-bold text-teal-800 text-sm mb-2">
What to Write
</p>
<ul className="text-xs text-slate-600 space-y-1">
<li> 36 word summary of the key claim.</li>
<li>
Arrow indicating direction: "X → Y" or "less X = more Y".
</li>
<li>
Your predicted answer in 35 words before looking at
choices.
</li>
<li>
For multi-step chains: number each step (1) (2) (3).
</li>
</ul>
</div>
<div className="card-tilt bg-white border border-teal-100 rounded-xl p-4">
<p className="font-bold text-teal-800 text-sm mb-2">
What This Prevents
</p>
<ul className="text-xs text-slate-600 space-y-1">
<li>
Choosing a speculative answer because it sounded good.
</li>
<li>
Forgetting the specific constraint after reading 4 answer
choices.
</li>
<li> Losing track of the logical chain halfway through.</li>
<li>
Selecting half-valid answers that address only part of the
claim.
</li>
</ul>
</div>
</div>
</div>
<div className="scroll-reveal-scale golden-rule-glow bg-teal-900 text-white rounded-2xl p-5 mb-8">
<p className="font-bold mb-1">Golden Rule</p>
<p className="text-sm text-teal-100">
Test every answer with: "Does the passage GUARANTEE this is true?"
If you can imagine a scenario where the passage is correct but
this answer is still false, eliminate it. Only the answer that
MUST be true is correct. "Only," "best," and "most effective" in
answer choices = almost always over-speculation.
</p>
</div>
</section> </section>
{/* Section 2: Practice Quiz */} {/* Section 2 — Inference Tracker widget */}
<section <section
ref={(el) => { ref={(el) => {
sectionsRef.current[2] = el; sectionsRef.current[2] = el;
}} }}
className="min-h-screen flex flex-col justify-center mb-24" className="min-h-screen flex flex-col justify-center mb-24"
>
<h2 className="text-4xl font-extrabold text-slate-900 mb-2">
Inference Tracker
</h2>
<p className="text-lg text-slate-500 mb-8">
Click the sentence that provides the strongest basis for the
inference. Which sentence GUARANTEES the conclusion?
</p>
<EvidenceHunterWidget
exercises={EVIDENCE_EXERCISES}
accentColor="teal"
/>
</section>
{/* Section 3 — Practice */}
<section
ref={(el) => {
sectionsRef.current[3] = el;
}}
className="min-h-screen flex flex-col justify-center mb-24"
> >
<h2 className="text-4xl font-extrabold text-slate-900 mb-6"> <h2 className="text-4xl font-extrabold text-slate-900 mb-6">
Practice Quiz Practice Questions
</h2> </h2>
{INFERENCES_EASY.slice(0, 2).map((q) => ( {INFERENCES_EASY.slice(0, 2).map((q) => (
<PracticeFromDataset key={q.id} question={q} color="teal" /> <PracticeFromDataset key={q.id} question={q} color="teal" />
@ -317,7 +639,7 @@ const EBRWInferencesLesson: React.FC<LessonProps> = ({ onFinish }) => {
onClick={onFinish} onClick={onFinish}
className="px-6 py-3 bg-teal-900 text-white font-bold rounded-full hover:bg-teal-700 transition-colors" className="px-6 py-3 bg-teal-900 text-white font-bold rounded-full hover:bg-teal-700 transition-colors"
> >
Finish Lesson Finish Lesson
</button> </button>
</div> </div>
</section> </section>

View File

@ -102,6 +102,7 @@ const EBRWMainIdeaLesson: React.FC<LessonProps> = ({ onFinish }) => {
{isPast ? ( {isPast ? (
<Check className="w-4 h-4" /> <Check className="w-4 h-4" />
) : ( ) : (
// @ts-ignore
<Icon className="w-4 h-4" /> <Icon className="w-4 h-4" />
)} )}
</div> </div>

View File

@ -208,7 +208,7 @@ const EBRWPronounsLesson: React.FC<LessonProps> = ({ onFinish }) => {
}: { }: {
index: number; index: number;
title: string; title: string;
icon: React.ElementType; icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
}) => { }) => {
const isActive = activeSection === index; const isActive = activeSection === index;
const isPast = activeSection > index; const isPast = activeSection > index;

View File

@ -0,0 +1,634 @@
import React, { useRef, useState, useEffect } from "react";
import {
Check,
BookOpen,
Lightbulb,
Zap,
Target,
AlertTriangle,
} from "lucide-react";
import { PracticeFromDataset } from "../../../components/lessons/LessonShell";
import {
RHETORICAL_EASY,
RHETORICAL_MEDIUM,
} from "../../../data/rw/rhetorical-synthesis";
import EvidenceHunterWidget, {
type EvidenceExercise,
} from "../../../components/lessons/EvidenceHunterWidget";
import RevealCardGrid, {
type RevealCard,
} from "../../../components/lessons/RevealCardGrid";
import useScrollReveal from "../../../components/lessons/useScrollReveal";
interface LessonProps {
onFinish?: () => void;
}
/* ── Data for RevealCardGrid widgets ── */
const RHETORICAL_GOALS: RevealCard[] = [
{
label: "Introduce a Topic",
sublabel: '"introduce," "background," "context"',
content: "Present a new subject with context. No strong argument yet.",
},
{
label: "Make a Comparison",
sublabel: '"compare," "contrast," "differ," "between"',
content:
"Explicitly contrast OR compare two things. Must mention BOTH and their relationship.",
},
{
label: "Emphasize Similarity",
sublabel: '"similarity," "common," "alike," "both"',
content:
'Highlight what two things have IN COMMON. Both must appear; "both" or "similarly" usually required.',
},
{
label: "Illustrate with Example",
sublabel: '"example," "illustrate," "such as"',
content:
"Use a specific case to demonstrate a general principle. Must contain a concrete example.",
},
{
label: "State a Limitation",
sublabel: '"limitation," "drawback," "only," "cannot"',
content:
"Acknowledge a restriction, exception, or weakness of a claim or finding.",
},
{
label: "Present Cause-Effect",
sublabel: '"cause," "result," "lead to," "effect"',
content:
"Show a causal chain: X leads to Y, or X results in Y. The relationship must be directional.",
},
{
label: "Provide Supporting Evidence",
sublabel: '"support," "evidence," "data," "findings"',
content:
"Give data, statistics, or findings that back up a claim already made.",
},
{
label: "Acknowledge Opposing View",
sublabel: '"opposing," "counterargument," "critics"',
content:
'Present a counterargument before making your own point. "Critics argue…" structure.',
},
];
const WRONG_PATTERNS: RevealCard[] = [
{
label: "Wrong Goal",
sublabel: "Check this FIRST",
content:
"Achieves a different purpose from what the question asks. Most common wrong answer type — check before verifying facts.",
},
{
label: "Accurate But Irrelevant",
content:
"Uses real facts from the notes, but from the wrong notes — not the ones that serve the stated goal.",
},
{
label: "Distorted Fact",
content:
"Changes a number, reverses a direction, or misrepresents what a note says. Always verify facts word by word.",
},
{
label: "Incompatible Notes Combined",
content:
"Joins two notes that cannot logically work together, producing individually accurate but collectively incoherent sentences.",
},
{
label: "Wrong Scope",
content:
"Too broad (covers more than goal requires) or too narrow (picks a detail that misses the stated purpose).",
},
{
label: "Outside Information",
content:
"Claims something not found in any provided notes — even if generally true in the real world. If you can't point to the note, eliminate it.",
},
];
const EVIDENCE_EXERCISES: EvidenceExercise[] = [
{
question:
"A student wants to EMPHASIZE A SIMILARITY between two renewable energy sources. Which sentence best accomplishes this goal?",
passage: [
"Wind energy capacity in the US grew by 14% in 2022.",
"Solar energy capacity in the US also grew by 14% in 2022.",
"Wind turbines require more land per megawatt than solar panels.",
"Both wind and solar energy produce no direct carbon emissions during operation.",
"Government subsidies for renewable energy totaled $15 billion in 2022.",
],
evidenceIndex: 3,
explanation:
'Sentence 4 directly emphasizes a shared characteristic of both sources. The word "both" signals similarity, and "no direct carbon emissions" is the specific trait they share — this is exactly what the goal requires.',
},
{
question:
"A student wants to ACKNOWLEDGE AN OPPOSING VIEW about urban farming before presenting their own argument. Which sentence best accomplishes this goal?",
passage: [
"Urban farming initiatives have expanded in over 200 American cities.",
"Critics argue that urban farms cannot produce food at a scale sufficient to affect food insecurity.",
"Urban farms do provide fresh produce in food deserts, regardless of total volume.",
"Community gardens have been shown to strengthen neighborhood social ties.",
"The average urban farm yields 5 times more produce per square foot than conventional farms.",
],
evidenceIndex: 1,
explanation:
'Sentence 2 explicitly presents a counterargument with "Critics argue..." — the standard structure for acknowledging an opposing view. It sets up the concession the student needs before making their own point.',
},
];
const EBRWRhetoricalSynthesisLesson: React.FC<LessonProps> = ({ onFinish }) => {
const [activeSection, setActiveSection] = useState(0);
const sectionsRef = useRef<(HTMLElement | null)[]>([]);
useEffect(() => {
const observers: IntersectionObserver[] = [];
sectionsRef.current.forEach((el, idx) => {
if (!el) return;
const obs = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) setActiveSection(idx);
},
{ threshold: 0.3 },
);
obs.observe(el);
observers.push(obs);
});
return () => observers.forEach((o) => o.disconnect());
}, []);
useScrollReveal();
const scrollToSection = (index: number) => {
setActiveSection(index);
sectionsRef.current[index]?.scrollIntoView({ behavior: "smooth" });
};
const SectionMarker = ({
index,
title,
icon: Icon,
}: {
index: number;
title: string;
icon: React.ElementType;
}) => {
const isActive = activeSection === index;
const isPast = activeSection > index;
return (
<button
onClick={() => scrollToSection(index)}
className={`flex items-center gap-3 p-3 w-full rounded-lg text-left transition-all ${isActive ? "bg-rose-50" : "hover:bg-slate-50"}`}
>
<div
className={`w-8 h-8 rounded-full flex items-center justify-center shrink-0
${isActive ? "bg-rose-600 text-white" : isPast ? "bg-rose-400 text-white" : "bg-slate-200 text-slate-500"}`}
>
{isPast ? (
<Check className="w-4 h-4" />
) : (
// @ts-ignore
<Icon className="w-4 h-4" />
)}
</div>
<p
className={`text-sm font-bold ${isActive ? "text-rose-900" : "text-slate-600"}`}
>
{title}
</p>
</button>
);
};
return (
<div className="flex flex-col lg:flex-row min-h-screen">
<aside className="w-full lg:w-64 lg:fixed lg:top-14 lg:bottom-0 lg:overflow-y-auto p-4 border-r border-slate-200 bg-slate-50 z-0 hidden lg:block">
<nav className="space-y-2 pt-6">
<SectionMarker index={0} title="The Core Concept" icon={BookOpen} />
<SectionMarker
index={1}
title="Goals &amp; Strategy"
icon={AlertTriangle}
/>
<SectionMarker index={2} title="Worked Example" icon={Target} />
<SectionMarker index={3} title="Goal Matcher" icon={Lightbulb} />
<SectionMarker index={4} title="Practice Questions" icon={Zap} />
</nav>
</aside>
<div className="flex-1 lg:ml-64 md:p-12 max-w-full mx-auto">
{/* ── Section 0: The Core Concept ── */}
<section
ref={(el) => {
sectionsRef.current[0] = el;
}}
className="min-h-screen flex flex-col justify-center mb-24 pt-20 lg:pt-0"
>
<div className="inline-flex items-center gap-2 bg-rose-100 text-rose-700 px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider mb-4 w-fit">
Expression of Ideas Domain 4
</div>
<h2 className="text-4xl font-extrabold text-slate-900 mb-2">
Rhetorical Synthesis
</h2>
<p className="text-lg text-slate-500 mb-8">
Every answer is judged on two things: does it achieve the stated
goal, and does every fact come from the notes? Failing either test
means wrong no exceptions.
</p>
{/* §9.1 — What Makes an Answer Correct */}
<div className="scroll-reveal stagger-1 rounded-2xl p-6 mb-8 bg-rose-50 border border-rose-200 space-y-4">
<h3 className="text-lg font-bold text-rose-900">
The Two-Criteria Rule
</h3>
<p className="text-sm text-slate-700">
Unlike most SAT questions, Rhetorical Synthesis does not ask you
to find the "best" answer in a general sense. It asks you to find
the answer that satisfies exactly two requirements simultaneously.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="card-tilt bg-white border border-rose-200 rounded-xl p-4">
<p className="font-bold text-rose-900 text-sm mb-2">
Criterion 1 GOAL
</p>
<p className="text-xs text-slate-700">
The answer must accomplish exactly the rhetorical purpose
stated in the question no more, no less. An answer that does
something different from the stated goal is automatically
wrong, even if every fact is accurate.
</p>
</div>
<div className="card-tilt bg-white border border-rose-200 rounded-xl p-4">
<p className="font-bold text-rose-900 text-sm mb-2">
Criterion 2 ACCURACY
</p>
<p className="text-xs text-slate-700">
Every fact in the answer must come directly from the provided
notes nothing added, nothing distorted. Even one wrong
number or reversed relationship eliminates an answer, even if
the goal is otherwise met.
</p>
</div>
</div>
<div className="bg-rose-100 border border-rose-300 rounded-xl p-4">
<p className="text-xs text-slate-700">
<span className="font-bold text-rose-900">
The key insight:
</span>{" "}
These two criteria work independently. You can fail on goal
while passing accuracy. You can fail on accuracy while passing
goal. Only the answer that passes both is correct.
</p>
</div>
</div>
{/* §9.2 — Question Anatomy */}
<div className="scroll-reveal stagger-2 rounded-2xl p-6 mb-8 bg-white border border-slate-200 space-y-4">
<h3 className="text-lg font-bold text-slate-900">
Question Anatomy
</h3>
<p className="text-sm text-slate-600">
Every Rhetorical Synthesis question has the same four-part
structure. Each part plays a specific role in determining the
correct answer.
</p>
<div className="overflow-x-auto">
<table className="w-full text-sm border-collapse rounded-xl overflow-hidden">
<thead>
<tr className="bg-rose-600 text-white">
<th className="px-3 py-2 text-left text-xs">Component</th>
<th className="px-3 py-2 text-left text-xs">
What It Contains
</th>
<th className="px-3 py-2 text-left text-xs">
What You Need From It
</th>
</tr>
</thead>
<tbody>
{[
[
"Context Header",
'"While researching a topic, a student took the following notes:"',
"Sets the topic domain. Establishes what field the notes come from.",
],
[
"Notes (24 bullets)",
"Factual information: data, definitions, examples, findings",
"The ONLY source of accurate facts. Every claim in the correct answer must trace back here.",
],
[
"Stated Goal",
'"The student wants to [X]. Which choice best accomplishes this goal?"',
"Read this FIRST — before notes, before answer choices. It tells you exactly what the correct answer must do.",
],
[
"Four Answer Choices",
"Sentences combining facts from the notes",
"Wrong answers fail the goal, distort facts, add outside information, or combine incompatible notes.",
],
].map(([comp, contains, need], i) => (
<tr
key={comp}
className={i % 2 === 0 ? "bg-white" : "bg-rose-50"}
>
<td className="px-3 py-2 font-bold text-rose-800 text-xs whitespace-nowrap">
{comp}
</td>
<td className="px-3 py-2 text-slate-600 text-xs italic">
{contains}
</td>
<td className="px-3 py-2 text-slate-700 text-xs">
{need}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</section>
{/* ── Section 1: Goals & Strategy ── */}
<section
ref={(el) => {
sectionsRef.current[1] = el;
}}
className="min-h-screen flex flex-col justify-center mb-24"
>
<h2 className="text-4xl font-extrabold text-slate-900 mb-2">
Goals &amp; Strategy
</h2>
<p className="text-lg text-slate-500 mb-8">
The eight rhetorical goals, the 5-step method, and the six ways
wrong answers fail.
</p>
{/* §9.3 — The 8 Goals */}
<div className="scroll-reveal stagger-1 rounded-2xl p-6 mb-8 bg-rose-50 border border-rose-200 space-y-4">
<h3 className="text-lg font-bold text-rose-900">
The 8 Rhetorical Goals tap to reveal what each answer must do:
</h3>
<p className="text-sm text-slate-700">
The stated goal will always be one of these eight types. Knowing
them lets you immediately identify what structural features the
correct answer must contain.
</p>
<RevealCardGrid
cards={RHETORICAL_GOALS}
columns={4}
accentColor="rose"
/>
</div>
{/* §9.5 — The 5-Step Strategy */}
<div className="scroll-reveal stagger-2 rounded-2xl p-6 mb-8 bg-white border border-slate-200 space-y-4">
<h3 className="text-lg font-bold text-slate-900">
The 5-Step Strategy
</h3>
<div className="space-y-2">
{[
{
step: 1,
action: "Read the GOAL first",
tip: 'Before reading notes or answer choices, read the stated goal. Write the goal type in 34 words on scratch paper: e.g., "comparison — key difference." The goal determines everything else.',
},
{
step: 2,
action: "Read the notes with the goal in mind",
tip: "Identify which 12 notes are relevant to your goal. Cross out off-topic notes — they exist only as material for wrong answers.",
},
{
step: 3,
action: "Predict what the correct answer must contain",
tip: 'Before looking at choices, write a quick sketch: "must mention both species + a difference." Even a rough prediction blocks you from being swayed by sophisticated wrong answers.',
},
{
step: 4,
action: "Eliminate wrong goals first",
tip: "Any answer that achieves a DIFFERENT goal from the one stated is wrong — eliminate it before checking any facts. This alone usually removes 23 choices immediately.",
},
{
step: 5,
action: "Verify accuracy in the remaining answers",
tip: "Read each remaining answer word by word against the notes. One wrong number, reversed relationship, or unsupported claim eliminates the answer.",
},
].map((s) => (
<div
key={s.step}
className="flex gap-3 bg-rose-50 border border-rose-100 rounded-xl px-4 py-3"
>
<span className="w-6 h-6 rounded-full bg-rose-600 text-white flex items-center justify-center text-xs font-bold shrink-0">
{s.step}
</span>
<div>
<p className="font-bold text-slate-800 text-sm">
{s.action}
</p>
<p className="text-xs text-slate-500 mt-0.5">{s.tip}</p>
</div>
</div>
))}
</div>
</div>
{/* §9.4 — Wrong Answer Patterns */}
<div className="scroll-reveal stagger-3 rounded-2xl p-6 mb-8 bg-white border border-slate-200 space-y-4">
<h3 className="text-lg font-bold text-slate-900">
Wrong Answer Patterns tap to reveal each trap:
</h3>
<p className="text-sm text-slate-600">
Every wrong answer fails in one of these six predictable ways.
Naming the failure pattern immediately tells you why to eliminate
it.
</p>
<RevealCardGrid
cards={WRONG_PATTERNS}
columns={3}
accentColor="rose"
/>
</div>
<div className="scroll-reveal-scale golden-rule-glow bg-rose-900 text-white rounded-2xl p-5 mb-8">
<p className="font-bold mb-1">Golden Rule</p>
<p className="text-sm text-rose-100">
Read the GOAL before reading anything else. An accurate answer
that achieves the wrong goal is wrong. An answer that achieves the
right goal with one distorted fact is also wrong. Both criteria
must be met.
</p>
</div>
</section>
{/* ── Section 2: Worked Example ── */}
<section
ref={(el) => {
sectionsRef.current[2] = el;
}}
className="min-h-screen flex flex-col justify-center mb-24"
>
<h2 className="text-4xl font-extrabold text-slate-900 mb-2">
Worked Example
</h2>
<p className="text-lg text-slate-500 mb-8">
A complete question with full analysis of why each answer succeeds
or fails against the two criteria.
</p>
<div className="scroll-reveal stagger-1 rounded-2xl p-6 mb-8 bg-slate-50 border border-slate-200 space-y-4">
<p className="text-xs font-bold text-slate-500 uppercase tracking-wider">
While researching a topic, a student took the following notes:
</p>
<ul className="space-y-2">
{[
"Komodo dragons (Varanus komodoensis) are the world's largest living lizard species, reaching up to 3 meters in length.",
"Komodo dragons are found only on five islands in Indonesia: Komodo, Rinca, Flores, Gili Motang, and Padar.",
"Komodo dragons have a venomous bite that prevents blood clotting in prey, causing prey to weaken from blood loss.",
"The IUCN lists Komodo dragons as Endangered, with an estimated 1,3832,531 individuals remaining in the wild.",
"Nile monitor lizards, native to Africa, are also large lizards but are not venomous and grow to only 2 meters.",
].map((note, i) => (
<li key={i} className="flex gap-2 text-sm text-slate-700">
<span className="text-rose-500 font-bold shrink-0">
{i + 1}.
</span>
{note}
</li>
))}
</ul>
<div className="bg-rose-100 border border-rose-300 rounded-xl p-3">
<p className="text-sm font-bold text-rose-900">
The student wants to COMPARE the Komodo dragon with another
large lizard species by highlighting a key difference. Which
choice best accomplishes this goal?
</p>
</div>
<div className="space-y-3">
{[
{
letter: "A",
correct: false,
text: "Komodo dragons, the world's largest living lizards at 3 meters, are endangered, with fewer than 2,531 individuals remaining.",
analysis:
"✗ Wrong goal. Only one species is mentioned — no comparison is made. The goal explicitly requires comparing Komodo dragons WITH another species.",
},
{
letter: "B",
correct: true,
text: "Unlike Nile monitor lizards, which are not venomous and reach only 2 meters, Komodo dragons possess a venomous bite and can grow up to 3 meters.",
analysis:
"✓ Correct. Both species appear. A key difference is highlighted (venomous vs. not venomous, 3m vs. 2m). All facts trace directly to Notes 1 and 5. Goal ✓, Accuracy ✓.",
},
{
letter: "C",
correct: false,
text: "Komodo dragons are venomous lizards found on five Indonesian islands, and their numbers have declined to fewer than 2,531 in the wild.",
analysis:
"✗ Wrong goal. Only one species mentioned — no comparison. The facts are accurate, but the goal is not achieved.",
},
{
letter: "D",
correct: false,
text: "Both Komodo dragons and Nile monitor lizards are large reptiles, with Komodo dragons endangered and Nile monitors thriving across Africa.",
analysis:
'✗ Two failures. First, the goal asks for a KEY DIFFERENCE, not similarity — "both are large reptiles" emphasizes similarity. Second, "Nile monitors thriving" appears in no note — outside information.',
},
].map((opt) => (
<div
key={opt.letter}
className={`rounded-xl p-4 border ${opt.correct ? "bg-green-50 border-green-300" : "bg-red-50 border-red-200"}`}
>
<div className="flex gap-3 items-start">
<span
className={`w-7 h-7 rounded-full flex items-center justify-center text-sm font-bold shrink-0 ${opt.correct ? "bg-green-600 text-white" : "bg-red-500 text-white"}`}
>
{opt.letter}
</span>
<div>
<p className="text-sm text-slate-800 italic mb-2">
"{opt.text}"
</p>
<p className="text-xs text-slate-600">{opt.analysis}</p>
</div>
</div>
</div>
))}
</div>
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4">
<p className="font-bold text-amber-900 text-sm mb-1">Takeaways</p>
<ul className="space-y-1 text-xs text-slate-700">
<li>
"Compare by highlighting a key difference" = two species
must appear AND a difference must be stated.
</li>
<li>
A and C both used accurate facts but both failed the goal
test. Eliminated without checking accuracy.
</li>
<li>
D failed on both criteria: wrong goal type (similarity
instead of difference) AND outside information.
</li>
<li>
B is the only answer that passes both criteria: goal (two
species, key difference stated), accuracy (all facts from
Notes 1 and 5).
</li>
</ul>
</div>
</div>
</section>
{/* ── Section 3: Goal Matcher Widget ── */}
<section
ref={(el) => {
sectionsRef.current[3] = el;
}}
className="min-h-screen flex flex-col justify-center mb-24"
>
<h2 className="text-4xl font-extrabold text-slate-900 mb-2">
Goal Matcher
</h2>
<p className="text-lg text-slate-500 mb-8">
Find the sentence that best achieves the stated rhetorical goal
using only facts from the notes.
</p>
<EvidenceHunterWidget
exercises={EVIDENCE_EXERCISES}
accentColor="rose"
/>
</section>
{/* ── Section 4: Practice ── */}
<section
ref={(el) => {
sectionsRef.current[4] = el;
}}
className="min-h-screen flex flex-col justify-center mb-24"
>
<h2 className="text-4xl font-extrabold text-slate-900 mb-6">
Practice Questions
</h2>
{RHETORICAL_EASY.slice(0, 2).map((q) => (
<PracticeFromDataset key={q.id} question={q} color="rose" />
))}
{RHETORICAL_MEDIUM.slice(0, 1).map((q) => (
<PracticeFromDataset key={q.id} question={q} color="rose" />
))}
<div className="mt-8 text-center">
<button
onClick={onFinish}
className="px-6 py-3 bg-rose-900 text-white font-bold rounded-full hover:bg-rose-700 transition-colors"
>
Finish Lesson
</button>
</div>
</section>
</div>
</div>
);
};
export default EBRWRhetoricalSynthesisLesson;

View File

@ -189,7 +189,7 @@ const EBRWSemicolonsColonsLesson: React.FC<LessonProps> = ({ onFinish }) => {
}: { }: {
index: number; index: number;
title: string; title: string;
icon: React.ElementType; icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
}) => { }) => {
const isActive = activeSection === index; const isActive = activeSection === index;
const isPast = activeSection > index; const isPast = activeSection > index;

View File

@ -214,7 +214,7 @@ const EBRWSentenceStructureLesson: React.FC<LessonProps> = ({ onFinish }) => {
}: { }: {
index: number; index: number;
title: string; title: string;
icon: React.ElementType; icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
}) => { }) => {
const isActive = activeSection === index; const isActive = activeSection === index;
const isPast = activeSection > index; const isPast = activeSection > index;

View File

@ -203,7 +203,7 @@ const EBRWSubjectVerbLesson: React.FC<LessonProps> = ({ onFinish }) => {
}: { }: {
index: number; index: number;
title: string; title: string;
icon: React.ElementType; icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
}) => { }) => {
const isActive = activeSection === index; const isActive = activeSection === index;
const isPast = activeSection > index; const isPast = activeSection > index;

View File

@ -0,0 +1,941 @@
import React, { useRef, useState, useEffect } from "react";
import { Check, BookOpen, Lightbulb, Zap, Star, BarChart3 } from "lucide-react";
import { PracticeFromDataset } from "../../../components/lessons/LessonShell";
import {
TEXT_STRUCTURE_EASY,
TEXT_STRUCTURE_MEDIUM,
} from "../../../data/rw/text-structure-purpose";
import EvidenceHunterWidget, {
type EvidenceExercise,
} from "../../../components/lessons/EvidenceHunterWidget";
import DataClaimWidget, {
type DataExercise,
} from "../../../components/lessons/DataClaimWidget";
import RevealCardGrid from "../../../components/lessons/RevealCardGrid";
import useScrollReveal from "../../../components/lessons/useScrollReveal";
interface LessonProps {
onFinish?: () => void;
}
const EVIDENCE_EXERCISES: EvidenceExercise[] = [
{
question: "What is the main point of this passage?",
passage: [
"Scientists have long assumed that deep-sea creatures live in perpetual darkness.",
"However, a 2023 study by marine biologist Dr. Yuen found bioluminescent signals in fish previously believed to be blind.",
"The fish not only detect light but appear to use it for communication.",
"These findings suggest that vision may be far more widespread in the deep ocean than previously thought.",
"Future expeditions will need to reconsider how they classify deep-sea species.",
],
evidenceIndex: 3,
explanation:
'Sentence 4 states the "So What" — the surprising new conclusion about how widespread vision is. It is the main point, not a supporting detail. The word "suggest" introduces the author\'s key takeaway.',
},
{
question:
"Which sentence best identifies the author's PRIMARY PURPOSE in writing this passage?",
passage: [
"The Harlem Renaissance of the 1920s produced some of the most innovative jazz in American history.",
"Composers like Duke Ellington and Louis Armstrong transformed the harmonic language of popular music.",
"Yet historians have traditionally focused on the literary figures of this era while neglecting the musicians.",
"This essay argues that jazz musicians were the true cultural architects of the Harlem Renaissance.",
"By examining their influence on poetry and visual art, we can restore them to their rightful place in history.",
],
evidenceIndex: 3,
explanation:
'Sentence 4 contains the thesis — "This essay argues" is a classic purpose-signaling phrase that tells you both the PURPOSE (to argue) and the CLAIM (jazz musicians were the true architects). The other sentences provide context or support.',
},
];
const GRAPH_EXERCISES: DataExercise[] = [
{
title: "Bar — Book Format Preferences",
chart: {
type: "bar",
title: "Preferred Reading Format by Age Group (% of respondents)",
yLabel: "% of respondents",
xLabel: "Age Group",
unit: "%",
source: "National Reading Survey, 2023",
series: [
{
name: "Print Books",
data: [
{ label: "1824", value: 32 },
{ label: "2534", value: 41 },
{ label: "3549", value: 55 },
{ label: "5064", value: 68 },
{ label: "65+", value: 78 },
],
},
{
name: "E-books",
data: [
{ label: "1824", value: 48 },
{ label: "2534", value: 39 },
{ label: "3549", value: 30 },
{ label: "5064", value: 22 },
{ label: "65+", value: 12 },
],
},
],
},
claims: [
{
text: "Preference for print books increases with age across all groups shown.",
verdict: "supported",
explanation:
"Print preference rises at each age bracket: 32% → 41% → 55% → 68% → 78%. Every step increases, so this is directly supported.",
},
{
text: "Adults aged 65+ prefer print books because they find e-readers difficult to use.",
verdict: "neither",
explanation:
"The graph shows WHAT people prefer, not WHY. Difficulty with e-readers is a plausible explanation, but nothing in the data addresses reasons for preference.",
},
{
text: "E-books are more popular than print books among every age group under 35.",
verdict: "contradicted",
explanation:
'Ages 1824: E-books (48%) > Print (32%) — true. But ages 2534: E-books (39%) < Print (41%) — false. Since the claim says "every age group under 35," one counterexample is enough to contradict it. Always check ALL data points when a claim uses "every" or "all."',
},
],
},
{
title: "Line — Library Visits",
chart: {
type: "line",
title: "Monthly Library Visits per Capita (20182023)",
yLabel: "Visits per capita",
xLabel: "Year",
unit: "",
source: "American Library Association Annual Report",
series: [
{
name: "In-Person Visits",
data: [
{ label: "2018", value: 4.2 },
{ label: "2019", value: 4.0 },
{ label: "2020", value: 1.1 },
{ label: "2021", value: 2.3 },
{ label: "2022", value: 3.1 },
{ label: "2023", value: 3.4 },
],
},
{
name: "Digital Access",
data: [
{ label: "2018", value: 1.8 },
{ label: "2019", value: 2.1 },
{ label: "2020", value: 5.6 },
{ label: "2021", value: 4.9 },
{ label: "2022", value: 4.5 },
{ label: "2023", value: 4.3 },
],
},
],
},
claims: [
{
text: "Digital access surpassed in-person visits starting in 2020 and remained higher through 2023.",
verdict: "supported",
explanation:
"In 2020, Digital (5.6) > In-Person (1.1). This pattern continues: 2021 (4.9 > 2.3), 2022 (4.5 > 3.1), 2023 (4.3 > 3.4). Directly supported throughout every year from 2020 onward.",
},
{
text: "The pandemic caused the decline in in-person library visits in 2020.",
verdict: "neither",
explanation:
"The graph shows a sharp drop in in-person visits in 2020, but the data itself does not identify the CAUSE. While the pandemic is a plausible explanation, the graph provides no information about causes — only trends.",
},
{
text: "In-person visits in 2023 had fully recovered to pre-2020 levels.",
verdict: "contradicted",
explanation:
"2023 in-person visits = 3.4. Pre-2020 levels were 4.2 (2018) and 4.0 (2019). Since 3.4 < 4.0, visits had NOT fully recovered. Directly contradicted.",
},
],
},
];
const FUNCTION_WORDS = [
{
label: "Positive / Supportive",
content:
"advance, affirm, bolster, defend, exemplify, illustrate, provide evidence for, substantiate, praise, propose, support",
},
{
label: "Negative / Critical",
content:
"challenge, contradict, criticize, debate, deny, dismiss, question, refute, undermine, warn, raise concerns about",
},
{
label: "Neutral — Describe",
content:
"characterize, convey, depict, describe, discuss, dramatize, portray, present, represent, show, trace",
},
{
label: "Neutral — Emphasize",
content:
"call attention to, emphasize, highlight, reinforce, reiterate, underscore",
},
{
label: "Neutral — Explain",
content:
"account for, clarify, define, explicate, explain, identify, indicate, specify",
},
{
label: "Neutral — Analyze",
content:
"analyze, consider, develop, explore, hypothesize, imply, reflect on, speculate, suggest",
},
{
label: "Compare / Contrast",
content: "compare, contrast, distinguish between, draw a parallel between",
},
{
label: "Qualify",
content:
"acknowledge, concede, downplay, minimize, qualify — note: qualify means limit or refine, not verify",
},
];
const WRONG_ANSWER_PATTERNS = [
{
label: "Off-topic",
content:
"Mentions something real but not discussed in the passage or relevant lines",
},
{
label: "Too broad",
content:
"Passage discusses one specific thing; answer generalizes to the whole category",
},
{
label: "Too extreme",
content:
"Includes words like always, never, only, completely, impossible, proven",
},
{
label: "Half-right, half-wrong",
content:
"Contains accurate language but also one incorrect element — one wrong word kills the whole answer",
},
{
label: "Could be true",
content:
"Plausible or factually accurate, but not supported by the specific passage",
},
{
label: "Passage-true but not local",
content:
"Accurate for the passage as a whole but not for the specific lines referenced",
},
];
const EBRWTextStructurePurposeLesson: React.FC<LessonProps> = ({
onFinish,
}) => {
const [activeSection, setActiveSection] = useState(0);
const sectionsRef = useRef<(HTMLElement | null)[]>([]);
useEffect(() => {
const observers: IntersectionObserver[] = [];
sectionsRef.current.forEach((el, idx) => {
if (!el) return;
const obs = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) setActiveSection(idx);
},
{ threshold: 0.3 },
);
obs.observe(el);
observers.push(obs);
});
return () => observers.forEach((o) => o.disconnect());
}, []);
useScrollReveal();
const scrollToSection = (index: number) => {
setActiveSection(index);
sectionsRef.current[index]?.scrollIntoView({ behavior: "smooth" });
};
const SectionMarker = ({
index,
title,
icon: Icon,
}: {
index: number;
title: string;
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
}) => {
const isActive = activeSection === index;
const isPast = activeSection > index;
return (
<button
onClick={() => scrollToSection(index)}
className={`flex items-center gap-3 p-3 w-full rounded-lg text-left transition-all ${isActive ? "bg-fuchsia-50" : "hover:bg-slate-50"}`}
>
<div
className={`w-8 h-8 rounded-full flex items-center justify-center shrink-0
${isActive ? "bg-fuchsia-600 text-white" : isPast ? "bg-fuchsia-400 text-white" : "bg-slate-200 text-slate-500"}`}
>
{isPast ? (
<Check className="w-4 h-4" />
) : (
<Icon className="w-4 h-4" />
)}
</div>
<p
className={`text-sm font-bold ${isActive ? "text-fuchsia-900" : "text-slate-600"}`}
>
{title}
</p>
</button>
);
};
return (
<div className="flex flex-col lg:flex-row min-h-screen">
<aside className="w-full lg:w-64 lg:fixed lg:top-14 lg:bottom-0 lg:overflow-y-auto p-4 border-r border-slate-200 bg-slate-50 z-0 hidden lg:block">
<nav className="space-y-2 pt-6">
<SectionMarker
index={0}
title="Pronouns &amp; Main Point"
icon={BookOpen}
/>
<SectionMarker
index={1}
title="Old/New &amp; Passage Types"
icon={Star}
/>
<SectionMarker
index={2}
title="Function &amp; Evidence"
icon={BarChart3}
/>
<SectionMarker index={3} title="Passage Hunter" icon={Lightbulb} />
<SectionMarker index={4} title="Practice Questions" icon={Zap} />
</nav>
</aside>
<div className="flex-1 lg:ml-64 md:p-12 w-full mx-auto">
{/* ── SECTION 0: PRONOUNS & MAIN POINT ── */}
<section
ref={(el) => {
sectionsRef.current[0] = el;
}}
className="min-h-screen flex flex-col justify-center mb-24 pt-20 lg:pt-0"
>
<div className="inline-flex items-center gap-2 bg-fuchsia-100 text-fuchsia-700 px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider mb-4 w-fit">
Craft &amp; Structure Domain 1
</div>
<h2 className="text-4xl font-extrabold text-slate-900 mb-2">
Text Structure &amp; Purpose
</h2>
<p className="text-lg text-slate-500 mb-8">
Understanding not just what a passage says but how it is organized
and why the two foundational skills are tracking references and
finding the main point.
</p>
{/* Pronouns & Compression Nouns */}
<div className="bg-fuchsia-50 border border-fuchsia-200 rounded-2xl p-6 mb-8 space-y-4 scroll-reveal">
<h3 className="text-lg font-bold text-fuchsia-900">
Pronouns &amp; Compression Nouns
</h3>
<p className="text-sm text-slate-700">
SAT passages routinely use pronouns (it, they, this, that, these)
and abstract "compression" nouns (phenomenon, claim, notion,
approach) to refer back to ideas already introduced. Inability to
trace these references is a major source of comprehension error.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="bg-white border border-fuchsia-200 rounded-xl p-4">
<p className="font-bold text-fuchsia-800 text-sm mb-2">
Pronoun Rules
</p>
<ul className="space-y-1">
{[
"Always back up to find the referent — it may be in a previous sentence or paragraph.",
"Match singular pronouns to singular referents; plural pronouns to plural referents.",
"When multiple nouns of the same type appear, a pronoun becomes ambiguous — re-read carefully.",
"It and its can refer to different nouns in the same paragraph — track each separately.",
].map((r) => (
<li
key={r}
className="flex items-start gap-2 text-xs text-slate-600"
>
<span className="text-fuchsia-500 shrink-0 mt-0.5">
</span>
{r}
</li>
))}
</ul>
</div>
<div className="bg-white border border-fuchsia-200 rounded-xl p-4">
<p className="font-bold text-fuchsia-800 text-sm mb-2">
Compression Nouns
</p>
<p className="text-xs text-slate-600 mb-2">
Words like{" "}
<em>
phenomenon, observation, claim, assertion, notion, approach,
finding
</em>{" "}
compress an entire idea into one phrase.
</p>
<ul className="space-y-1">
{[
'"this + noun": "this enhanced convenience" compresses the entire previous sentence.',
'"the former / the latter": former = first mentioned; latter = last mentioned.',
"The referent may be 35 lines before the compression noun — always read backwards.",
].map((r) => (
<li
key={r}
className="flex items-start gap-2 text-xs text-slate-600"
>
<span className="text-fuchsia-500 shrink-0 mt-0.5">
</span>
{r}
</li>
))}
</ul>
</div>
</div>
<div className="bg-fuchsia-100 border border-fuchsia-200 rounded-xl p-4">
<p className="text-sm text-slate-700">
<span className="font-bold text-fuchsia-800">Strategy: </span>
When you encounter a pronoun or compression noun, stop. Back up
to the beginning of the sentence where the referent was
introduced. Never start reading mid-sentence when searching for
a referent.
</p>
</div>
</div>
{/* Topics & Main Point */}
<div className="rounded-2xl p-6 mb-8 bg-white border border-slate-200 space-y-4 scroll-reveal stagger-1">
<h3 className="text-lg font-bold text-slate-900">
Identifying Topics and Main Points
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="bg-slate-50 border border-slate-200 rounded-xl p-4">
<p className="font-bold text-slate-700 text-sm mb-1">
The Topic
</p>
<p className="text-xs text-slate-600 mb-2">
The primary subject or focus of the passage the person,
thing, or idea it centers on. Typically appears in the first
sentence or two.
</p>
<p className="text-xs text-slate-500 italic">
Correct answers to main-idea questions always refer to the
topic. Many wrong answers are wrong specifically because they
are off-topic.
</p>
</div>
<div className="bg-fuchsia-50 border border-fuchsia-200 rounded-xl p-4">
<p className="font-bold text-fuchsia-800 text-sm mb-1">
The Main Point
</p>
<p className="text-xs text-slate-600 mb-2">
The primary argument the author wants to convey. It answers:{" "}
<em>so what?</em> It tells us why the topic matters.
</p>
<div className="flex items-center gap-2 mt-2">
<span className="text-xs bg-fuchsia-600 text-white px-2 py-0.5 rounded font-bold">
Topic + So What? = Main Point
</span>
</div>
</div>
</div>
<div className="bg-white border border-slate-200 rounded-xl p-4">
<p className="font-bold text-slate-700 text-sm mb-2">
Where to Find the Main Point
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{[
"Most commonly in the first sentence or two",
"Often restated or reinforced in the last sentence",
"Signaled by: the point is, key, central, essential, significant, or italics",
"Often located near dashes, colons, or major transitions (however, therefore, in fact)",
].map((p) => (
<div
key={p}
className="flex items-start gap-2 text-xs text-slate-600"
>
<span className="text-fuchsia-500 shrink-0 mt-0.5"></span>
{p}
</div>
))}
</div>
</div>
<div className="bg-slate-50 border border-slate-200 rounded-lg p-3">
<p className="text-xs text-slate-600">
<span className="font-bold text-slate-800">
What to write down:{" "}
</span>
A 36 word compressed summary on scratch paper. Example: "New
evidence overturns Clovis theory." This prevents you from
forgetting the point when navigating confusing answer choices.
</p>
</div>
</div>
</section>
{/* ── SECTION 1: OLD/NEW & PASSAGE TYPES ── */}
<section
ref={(el) => {
sectionsRef.current[1] = el;
}}
className="min-h-screen flex flex-col justify-center mb-24"
>
<h2 className="text-4xl font-extrabold text-slate-900 mb-2">
Old/New &amp; Passage Types
</h2>
<p className="text-lg text-slate-500 mb-8">
The most important structural pattern in SAT science passages and
how main points differ across fiction, non-fiction, and poetry.
</p>
{/* Old/New Framework */}
<div className="bg-fuchsia-50 border border-fuchsia-200 rounded-2xl p-6 mb-8 space-y-4 scroll-reveal">
<h3 className="text-lg font-bold text-fuchsia-900">
The Old Idea / New Idea Framework
</h3>
<p className="text-sm text-slate-700">
One of the most important structural patterns in SAT passages:
people used to believe X, but new evidence shows Y is actually
true. Recognizing old-idea signals allows you to predict the main
point before the author states it.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="bg-white border border-slate-200 rounded-xl p-4">
<p className="font-bold text-red-700 text-sm mb-2">
Old Idea Signal Phrases
</p>
<ul className="space-y-1">
{[
'"Some / many (scientists) believe..." — "some" especially implies the author disagrees',
'"It is commonly thought that..." / "Accepted wisdom holds that..."',
'"Traditionally it was believed..." / "For decades, scientists thought..."',
'"In the past..."',
].map((s) => (
<li
key={s}
className="text-xs text-slate-600 flex items-start gap-1"
>
<span className="shrink-0"></span>
{s}
</li>
))}
</ul>
</div>
<div className="bg-white border border-slate-200 rounded-xl p-4">
<p className="font-bold text-green-700 text-sm mb-2">
New Idea Signal Phrases
</p>
<ul className="space-y-1">
{[
"However, but in fact, actually, in reality",
'"It now seems / researchers now think..."',
'"Recently, it has been found that..." / "New research suggests..."',
'"But is it really the case that...?" (rhetorical question)',
].map((s) => (
<li
key={s}
className="text-xs text-slate-600 flex items-start gap-1"
>
<span className="shrink-0"></span>
{s}
</li>
))}
</ul>
</div>
</div>
<div className="bg-fuchsia-900 text-white rounded-xl p-4">
<p className="font-bold text-sm mb-1">Strategy</p>
<p className="text-xs text-fuchsia-100">
Write <em>Old = [3-word summary] | New = [3-word summary]</em>{" "}
on scratch paper before answering questions. The main point is
almost always the NEW idea. If you identify the old idea, you
can predict the new idea before reading it.
</p>
</div>
</div>
{/* Passage Types */}
<div className="rounded-2xl p-6 mb-8 bg-white border border-slate-200 space-y-4 scroll-reveal stagger-1">
<h3 className="text-lg font-bold text-slate-900">
Main Point for Different Passage Types
</h3>
<div className="space-y-3">
<div className="bg-slate-50 border border-slate-200 rounded-xl p-4 card-tilt">
<p className="font-bold text-slate-700 text-sm mb-1">
Non-Fiction (Science, Social Science, Humanities)
</p>
<p className="text-xs text-slate-600">
Main points are usually stated directly, often in the first
and last sentences. The old idea / new idea framework applies
most frequently here.
</p>
</div>
<div className="bg-slate-50 border border-slate-200 rounded-xl p-4 card-tilt">
<p className="font-bold text-slate-700 text-sm mb-2">
Fiction Passages
</p>
<p className="text-xs text-slate-600 mb-2">
Fiction passages do not make arguments but focus on particular
traits, qualities, or situations of characters or places. Key
information tends to appear at the beginning and end of the
excerpt.
</p>
<ul className="space-y-1">
{[
"Do not interpret or speculate beyond what is literally stated.",
"Focus on the literal actions of characters and the specific words describing them.",
"Pay attention to unusual punctuation, strong language, and major transitions.",
"Characters' speech often signals the key idea, especially near the end.",
].map((r) => (
<li
key={r}
className="flex items-start gap-2 text-xs text-slate-500"
>
<span className="text-fuchsia-400 shrink-0 mt-0.5">
</span>
{r}
</li>
))}
</ul>
</div>
<div className="bg-slate-50 border border-slate-200 rounded-xl p-4 card-tilt">
<p className="font-bold text-slate-700 text-sm mb-2">
Poetry Passages
</p>
<p className="text-xs text-slate-600 mb-2">
The literal meaning may be conveyed indirectly through images
or metaphors, but students must stay within the bounds of the
poem and avoid over-interpretation.
</p>
<ul className="space-y-1">
{[
"Identify the two main sections (often divided at the midpoint).",
"Determine what each section is about at the most literal level.",
"Look for the contrast or development between sections — that relationship usually contains the main idea.",
"Avoid attributing complex philosophical meaning unless unambiguously supported by specific words.",
].map((r) => (
<li
key={r}
className="flex items-start gap-2 text-xs text-slate-500"
>
<span className="text-fuchsia-400 shrink-0 mt-0.5">
</span>
{r}
</li>
))}
</ul>
</div>
</div>
</div>
</section>
{/* ── SECTION 2: FUNCTION, EVIDENCE & GRAPHS ── */}
<section
ref={(el) => {
sectionsRef.current[2] = el;
}}
className="min-h-screen flex flex-col justify-center mb-24"
>
<h2 className="text-4xl font-extrabold text-slate-900 mb-2">
Function, Evidence &amp; Graphs
</h2>
<p className="text-lg text-slate-500 mb-8">
Three advanced question types all require identifying the claim
precisely before answering, then playing positive/negative to
eliminate.
</p>
{/* Reading for Function */}
<div className="bg-fuchsia-50 border border-fuchsia-200 rounded-2xl p-6 mb-8 space-y-4 scroll-reveal">
<h3 className="text-lg font-bold text-fuchsia-900">
Reading for Function
</h3>
<p className="text-sm text-slate-700">
Function questions ask not <em>what</em> a sentence says but{" "}
<em>why</em> it says it what rhetorical role that content plays.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="bg-white border border-fuchsia-100 rounded-xl p-4">
<p className="font-bold text-fuchsia-800 text-sm mb-2">
Main Point vs. Primary Purpose
</p>
<div className="space-y-2">
<div>
<p className="text-xs font-bold text-fuchsia-700">
Main Point
</p>
<p className="text-xs text-slate-600">
The central CLAIM what the author is arguing. Usually
stated directly.
</p>
</div>
<div>
<p className="text-xs font-bold text-fuchsia-700">
Primary Purpose
</p>
<p className="text-xs text-slate-600">
The rhetorical GOAL what the author is doing
(explaining, emphasizing, arguing). Often more general or
abstract.
</p>
</div>
</div>
</div>
<div className="bg-white border border-fuchsia-100 rounded-xl p-4">
<p className="font-bold text-fuchsia-800 text-sm mb-2">
5-Step Method for Function Questions
</p>
<ol className="space-y-1">
{[
"Determine whether the question asks about a sentence or the whole passage.",
"Read one to two sentences before and after the underlined portion.",
"Ask: What point is this sentence supporting?",
"Play positive/negative — eliminate answers with the wrong charge.",
"Match the remaining choice to what the passage is actually doing.",
].map((step, i) => (
<li
key={i}
className="flex items-start gap-2 text-xs text-slate-600"
>
<span className="w-4 h-4 rounded-full bg-fuchsia-600 text-white flex items-center justify-center text-xs shrink-0 mt-0.5">
{i + 1}
</span>
{step}
</li>
))}
</ol>
</div>
</div>
<RevealCardGrid
cards={FUNCTION_WORDS}
columns={2}
accentColor="fuchsia"
/>
<div className="bg-red-50 border border-red-200 rounded-lg p-3">
<p className="text-xs text-red-800">
Eliminate extreme verbs immediately:{" "}
<em>prove, celebrate, condemn, discredit, mock, scoff at</em>.
Passages almost never contain sufficient evidence to
definitively prove or disprove anything.
</p>
</div>
</div>
{/* Text Completions & Support/Undermine */}
<div className="rounded-2xl p-6 mb-8 bg-white border border-slate-200 space-y-4 scroll-reveal stagger-1">
<h3 className="text-lg font-bold text-slate-900">
Text Completions &amp; Support / Undermine
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="bg-fuchsia-50 border border-fuchsia-200 rounded-xl p-4">
<p className="font-bold text-fuchsia-800 text-sm mb-2">
Text Completions
</p>
<p className="text-xs text-slate-600 mb-2">
All necessary information is present in the passage. The
inference required is small resist speculation.
</p>
<ol className="space-y-1">
{[
"Carefully read the claim just before the blank.",
"Restate it in your own words (36 words).",
"Work out the single most logical implication.",
"Find the answer that matches — possibly rephrased.",
].map((s, i) => (
<li key={i} className="flex gap-2 text-xs text-slate-700">
<span className="w-4 h-4 rounded-full bg-fuchsia-600 text-white flex items-center justify-center text-xs shrink-0">
{i + 1}
</span>
{s}
</li>
))}
</ol>
</div>
<div className="bg-fuchsia-50 border border-fuchsia-200 rounded-xl p-4">
<p className="font-bold text-fuchsia-800 text-sm mb-2">
Support &amp; Undermine
</p>
<p className="text-xs text-slate-600 mb-2">
Students must identify statements that strengthen or weaken a
given claim. These tend to appear among the hardest questions.
</p>
<ol className="space-y-1">
{[
"Identify and paraphrase the claim precisely.",
"Predict upfront what kind of information would support or weaken it.",
"Match your prediction — wrong answers will be off-topic or address a different claim.",
].map((s, i) => (
<li key={i} className="flex gap-2 text-xs text-slate-700">
<span className="w-4 h-4 rounded-full bg-fuchsia-600 text-white flex items-center justify-center text-xs shrink-0">
{i + 1}
</span>
{s}
</li>
))}
</ol>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="bg-green-50 border border-green-200 rounded-xl p-4">
<p className="font-bold text-green-800 text-sm mb-1">Support</p>
<p className="text-xs text-slate-600">
Correct answer provides evidence consistent with the claim
same entities or phenomena, results in the same direction. Not
off-topic, not tangential.
</p>
</div>
<div className="bg-red-50 border border-red-200 rounded-xl p-4">
<p className="font-bold text-red-800 text-sm mb-1">Undermine</p>
<p className="text-xs text-slate-600">
Correct answer contradicts or weakens the claim. Most common
pattern: claim says "X causes Y"; correct answer shows X is
present but Y did not follow.
</p>
</div>
</div>
</div>
{/* Graphs & Answer Patterns */}
<div className="bg-fuchsia-50 border border-fuchsia-200 rounded-2xl p-6 mb-8 space-y-4 scroll-reveal stagger-2">
<h3 className="text-lg font-bold text-fuchsia-900">
Graphs, Charts &amp; Wrong Answer Patterns
</h3>
<p className="text-sm text-slate-700">
Graph-based questions follow predictable patterns and can often be
answered without extensively examining the graph.
</p>
<div className="space-y-2">
{[
{
step: 1,
text: "Read the passage first — especially the last sentence. This states the claim.",
},
{
step: 2,
text: "Read the question carefully to identify the exact focus.",
},
{
step: 3,
text: "Eliminate answer choices using the passage before looking at the graph.",
},
{
step: 4,
text: "Confirm the remaining option against the graph.",
},
].map((s) => (
<div
key={s.step}
className="flex gap-3 bg-white border border-fuchsia-100 rounded-lg p-3"
>
<span className="w-5 h-5 rounded-full bg-fuchsia-600 text-white flex items-center justify-center text-xs font-bold shrink-0">
{s.step}
</span>
<p className="text-xs text-slate-700">{s.text}</p>
</div>
))}
</div>
<RevealCardGrid
cards={WRONG_ANSWER_PATTERNS}
columns={2}
accentColor="fuchsia"
/>
</div>
{/* Graph Practice */}
<div className="rounded-2xl p-6 mb-8 bg-white border border-slate-200 space-y-4">
<h3 className="text-lg font-bold text-slate-900">
Graph Practice Evaluate Claims Against Data
</h3>
<p className="text-sm text-slate-600">
Apply the 4-step process: read the claim, extract criteria, check
the graph, and judge whether the data supports, contradicts, or
neither proves the claim.
</p>
<DataClaimWidget
exercises={GRAPH_EXERCISES}
accentColor="fuchsia"
/>
</div>
<div className="bg-fuchsia-900 text-white rounded-2xl p-5 mb-8 scroll-reveal-scale golden-rule-glow">
<p className="font-bold mb-1">Golden Rule</p>
<p className="text-sm text-fuchsia-100">
The main point is almost always the sentence that introduces NEW
information look for signal words like "however," "but," or "in
contrast." The OLD information (background, history, common
belief) is context, not the point. For function questions: play
positive/negative first, then match the function verb to what the
passage is actually doing.
</p>
</div>
</section>
{/* ── SECTION 3: WIDGET ── */}
<section
ref={(el) => {
sectionsRef.current[3] = el;
}}
className="min-h-screen flex flex-col justify-center mb-24"
>
<h2 className="text-4xl font-extrabold text-slate-900 mb-2">
Passage Hunter
</h2>
<p className="text-lg text-slate-500 mb-8">
Click the sentence that best answers each question. Look for the "So
What" and purpose signal words.
</p>
<EvidenceHunterWidget
exercises={EVIDENCE_EXERCISES}
accentColor="fuchsia"
/>
</section>
{/* ── SECTION 4: PRACTICE ── */}
<section
ref={(el) => {
sectionsRef.current[4] = el;
}}
className="min-h-screen flex flex-col justify-center mb-24"
>
<h2 className="text-4xl font-extrabold text-slate-900 mb-6">
Practice Questions
</h2>
{TEXT_STRUCTURE_EASY.slice(0, 2).map((q) => (
<PracticeFromDataset key={q.id} question={q} color="fuchsia" />
))}
{TEXT_STRUCTURE_MEDIUM.slice(0, 1).map((q) => (
<PracticeFromDataset key={q.id} question={q} color="fuchsia" />
))}
<div className="mt-8 text-center">
<button
onClick={onFinish}
className="px-6 py-3 bg-fuchsia-900 text-white font-bold rounded-full hover:bg-fuchsia-700 transition-colors"
>
Finish Lesson
</button>
</div>
</section>
</div>
</div>
);
};
export default EBRWTextStructurePurposeLesson;

File diff suppressed because it is too large Load Diff

View File

@ -211,7 +211,7 @@ const EBRWVerbsLesson: React.FC<LessonProps> = ({ onFinish }) => {
}: { }: {
index: number; index: number;
title: string; title: string;
icon: React.ElementType; icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
}) => { }) => {
const isActive = activeSection === index; const isActive = activeSection === index;
const isPast = activeSection > index; const isPast = activeSection > index;

Some files were not shown because too many files have changed in this diff Show More