Add SVG icons and TypeScript configuration files

- Introduced a new favicon.svg file for the application.
- Added icons.svg containing various SVG symbols for UI components.
- Created tsconfig.app.json for frontend TypeScript configuration with ES2023 target and bundler mode.
- Established tsconfig.json to reference app and node configurations.
- Implemented tsconfig.node.json for Node.js specific TypeScript settings.
This commit is contained in:
LaurensBartelds committed 2026-04-21 22:19:06 +02:00
1 parent 8420e6cae0
commit be69db04a5
21 files changed
+6872

No files matched your search

+17
View File
@@ -0,0 +1,17 @@
{
"permissions": {
"allow": [
"Bash(npm install *)",
"Bash(npm run *)",
"Bash(git add *)",
"Bash(git commit -m ' *)",
"Bash(npm list *)",
"WebSearch",
"Bash(npx tsc *)",
"Bash(git commit *)",
"Bash(npx vitest *)",
"Bash(node -e ' *)",
"Bash(node --input-type=module)"
]
}
}
@@ -0,0 +1,35 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>BrainWave — Practice Exam</title>
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Fraunces:ital,wght@0,400;0,500;1,400&family=JetBrains+Mono:wght@400;500&display=swap"
rel="stylesheet"
/>
<!-- KaTeX -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css" integrity="sha384-nB0miv6/jRmo5UMMR1wu3Gz6NLsoTkbqJghGIsx//Rlm+ZU03BU6SQNC66uf4l5+" crossorigin="anonymous" />
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js" integrity="sha384-7zkQWkzuo3B5mTepMUcHkMB5jZaolc2xDwL6VFqjFALcbeS9Ggm/Yr2r3Dy4lfFg" crossorigin="anonymous"></script>
<link rel="stylesheet" href="styles.css" />
<!-- React + Babel -->
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
</head>
<body>
<div id="root"></div>
<script src="app/utils.js"></script>
<script type="text/babel" data-presets="react" src="app/questions.jsx"></script>
<script type="text/babel" data-presets="react" src="app/screens.jsx"></script>
<script type="text/babel" data-presets="react" src="app/app.jsx"></script>
</body>
</html>
+300
View File
@@ -0,0 +1,300 @@
# Handoff: BrainWave Practice Exam Platform
## Overview
BrainWave is a web-based practice exam platform that lets a user upload a JSON file describing an exam and take it interactively. It supports subjects from mathematics (with full LaTeX rendering) to language learning (translation questions) to general knowledge (multiple choice, essays, etc.). Results are scored automatically where possible, explained in a review screen, and persisted locally so users can resume in-progress exams and see a history of past attempts.
## About the Design Files
The files in this bundle are **design references created in HTML** — a working prototype showing the intended look, behavior, and data model, not production code to copy directly. The task is to **recreate these HTML designs in the target codebase's existing environment** (React, Vue, Next.js, SvelteKit, native mobile, etc.) using its established patterns, component library, and state-management conventions. If no environment exists yet, pick the framework best-suited to the project and implement the designs there.
The prototype is implemented as a single-page React app using in-browser Babel for convenience; production should use a real build pipeline.
## Fidelity
**High-fidelity (hifi).** The mocks are pixel-level specifications with final colors, typography, spacing, and interactions. All eight question renderers, the results/review flow, history, persistence, and keyboard shortcuts are implemented and working. Recreate the UI pixel-perfectly using the codebase's existing libraries and patterns.
---
## Screens / Views
### 1. Landing / Upload
**Purpose:** User lands here to load an exam. Supports drag-and-drop JSON upload, curated sample exams, resume of in-progress attempt, and past-attempt history.
**Layout:**
- Max width 1080px, centered; padding `72px 32px 120px`.
- Eyebrow label (uppercase monospace, 11px, letter-spacing 0.12em).
- H1 title — Fraunces serif, clamp(40px, 6vw, 68px), line-height 1.02, letter-spacing -0.028em. Last two words wrapped in `<em>` and colored `var(--accent)`.
- Subtitle paragraph, 17px, max-width 58ch, color `var(--ink-2)`.
- **Two-column grid** (1.2fr / 1fr, gap 48px):
- Left: Dropzone + error banner + resume card + supported-types list.
- Right: "Try a sample" section with 3 sample cards.
- Below the grid (when history exists): "Past attempts" list.
**Components:**
- **Dropzone**: 1.5px dashed border, radius 14px, padding `40px 32px`, background `var(--paper-2)`. On hover/drag-over: border → accent, background → `var(--accent-weak)`. Contains a 40×40 icon (upward arrow inside rounded square), h3 (16px, weight 500), caption (13.5px, `var(--ink-3)`).
- **Resume card**: 1px border, radius 10px, padding 16px, `var(--paper-2)` background. Title + subcaption + "Resume →" primary button.
- **Sample card**: flex row, gap 16px, 1px border, radius 10px, padding 16px. 36×36 mark tile (serif glyph), title (14.5px, weight 500), meta (12.5px, `var(--ink-3)`), "→" arrow on the right.
- **History row**: 4-column grid `1fr auto auto auto`, 14px items separated by 1px bottom borders. Title + timestamp caption, elapsed time (monospace), score pill (pass/fail colored), "Review" ghost button.
### 2. Exam Runner
**Purpose:** Take the exam, one question at a time, with a navigator sidebar and metadata rail.
**Layout:** 3-column grid `280px 1fr 280px`, full height minus topbar (57px).
- **Sidebar (left, sticky):** Exam title (Fraunces serif, 22px) + subtitle + "Questions" section label + nav list + progress bar + Submit/Exit buttons.
- **Main (center):** Max-width 920px, padding `48px 64px 120px`. Question header, prompt, media, renderer, optional inline feedback block, footer with nav buttons + keyboard-hints strip.
- **Meta rail (right, sticky):** Elapsed, Remaining (if timed), Points possible, Mode, Author — each in a "meta block" with uppercase mono label and Fraunces serif value.
**Components:**
- **Topbar:** 14px × 28px padding, sticky, 1px bottom border. 22×22 brand mark (1.5px border square with inset accent), brand name "BrainWave" (weight 600, -0.02em), faint "/ practice" qualifier. Right side: current exam title + Exit button.
- **Nav item:** 3-column grid `28px 1fr auto`, padding `7px 10px`, radius 6px. Mono 2-digit number + category/type title + 7×7 status dot. Dot colors: `var(--line-2)` (blank), `var(--accent)` (answered), `var(--warn)` (flagged), `var(--correct)` (correct in immediate mode), `var(--wrong)` (wrong in immediate mode).
- **Question header:** flex row, 16px gap, 32px bottom margin, 24px padding-bottom, 1px bottom border. Mono "Question 01 / 07" + category chip (12px, mono, `var(--paper-3)` bg, 4px radius, 2×8 padding) + right-aligned points label.
- **Question prompt:** Fraunces serif, 26px, line-height 1.35, -0.015em, weight 400. Renders LaTeX inline.
- **Question footer:** flex row, 48px top margin, 32px padding-top, 1px top border. Previous / Flag / (Check answer) / Next or Submit buttons. Spacer between middle and right groups.
### 3. Question Renderers (8 types)
All renderers share these hooks: `value` (current answer), `onChange(next)`, `locked` (read-only), `graded` (show correctness coloring).
**multiple-choice / multiple-select:**
- Vertical list of `.choice` buttons. 1px border, radius 10px, padding `16px 20px`, 15px text, line-height 1.5.
- 22×22 round marker (square for multi-select), 1.5px border.
- When selected: marker fills with `var(--ink)`; inner 8px dot (or checkmark for square).
- Mono "choice-key" (A–D) between marker and text.
- Graded-correct: border & bg → `var(--correct)` / `var(--correct-weak)`. Graded-wrong: `var(--wrong)` / `var(--wrong-weak)`.
**short-answer / translation:**
- Single `<input class="input">` — 100% width, padding `10px 12px`, 1px border, radius 10px. Focus: border → accent, 3px focus ring.
- For translation: subtitle "Answer in {targetLanguage}" in faint mono, 11px.
**math-input:**
- Monospace input + "Preview" block underneath showing live KaTeX render of the LaTeX the user types.
- Preview block: `var(--paper-2)` bg, 1px border, radius 10px, padding `14px 16px`, min-height 48px, left-aligned mono uppercase label + rendered output.
**essay:**
- Textarea, min-height 120px, 16px padding, radius 10px.
- Below: word count + "Self-graded · see rubric on review". If `q.minWords` set and below threshold, word count is colored `var(--wrong)`.
**match:**
- 2-column grid, 32px gap. Left = "Items", right = "Matches".
- Click a left item → activates it (`var(--accent)` border + 3px accent-weak ring). Click a right item → creates pairing; paired items show a small badge with the other side's index letter/number.
- Click paired left to clear it; click paired right to clear pairing.
**order:**
- Draggable list. Each item: 14×16 padding, 1px border, radius 10px. Shows `⋮⋮` handle + mono position number + text + up/down buttons (ghost, small).
- While dragging: opacity 0.4. Drop target highlighted with accent-colored top or bottom border.
- Initial order is a deterministic shuffle seeded from the question id.
### 4. Results / Review
**Purpose:** Summary of performance + per-category breakdown + detailed review of every question.
**Layout:** Max-width 960px, centered, padding `64px 32px 120px`.
- Eyebrow label → "Exam complete · {title}" (or "Reviewing past attempt · ...").
- H1 title — Fraunces, 56px, -0.025em, line-height 1.05. "You scored <em>{pct}%</em>" with pct in accent. Subline in 0.5em size, `var(--ink-3)`, announces pass/fail against `passingScore` (default 60).
- **Score grid:** 3 equal columns (`1fr 1fr 1fr`), 1px top and bottom borders with internal 1px separators. Each cell: uppercase mono label + Fraunces value (48px) + mono unit suffix.
- **Category breakdown** (if more than one category): 3-col grid rows `180px 1fr 60px` — label + 4px bar + right-aligned percent.
- **Review list:** Collapsible items, 1px border, radius 10px, 16px margin-between. Header: mono number + truncated prompt + status pill (correct/wrong/ungraded) + +/− toggle. Open body: re-renders full question with `locked graded`, shows explanation and rubric.
- Footer buttons: Retake exam (primary) + Back to home (ghost).
### 5. Tweaks Panel (floating)
Fixed bottom-right, 280px wide, 1px border, 10px radius, `var(--shadow-md)`.
- Header: uppercase mono "Tweaks" + close button.
- Body rows: each row has a mono label + segmented control (`.tweak-control` background with pill-selected active button).
- Controls: Theme (Light/Dark), Text size (S/M/L), Feedback (Auto/Immediate/End).
---
## Interactions & Behavior
### Upload
- File input accepts `application/json`.
- On drop or select: read file → `JSON.parse` → `validateExam()` → `loadExam()`.
- `validateExam` checks for `title`, non-empty `questions` array, and every question having `id`, `type`, `prompt`.
- Error banner renders in `var(--wrong-weak)` with `var(--wrong)` border for 1px/8px radius.
### Exam flow
- On load, state initializes `{ currentIdx: 0, answers: {}, flags: {}, revealed: {}, startedAt: Date.now(), feedbackMode }`.
- `feedbackMode` resolves: if Tweak is "auto", use `exam.feedbackMode` (default "end"); otherwise use Tweak override.
- Every state change `saveState({exam,state})` to localStorage under `brainwave.exam.state`.
- Timer (`exam.timeLimitMinutes`): counts down from `timeLimitMinutes*60_000 - elapsed`. Last 60s: value colored `var(--wrong)`. At 0: auto-submit.
- Submit: compute graded snapshot, append to `brainwave.exam.history` (capped 30 entries), clear active-state key, advance to Results.
### Grading
- **multiple-choice**: user answer === `q.answer` (string id).
- **multiple-select**: unordered array equality.
- **short-answer / translation**: normalize both using `{caseSensitive, ignorePunctuation, ignoreAccents}` options, then compare against `[answer, ...acceptedForms]`.
- **math-input**: strip whitespace, `\left/\right`, `\,`, braces around single chars, lowercase — then exact match against accepted forms.
- **match**: every key in `q.answer` must have matching user pairing.
- **order**: ordered array equality.
- **essay**: ungraded by default; optional `selfGrade` (0–1) yields partial points.
### Keyboard
- `←`/`k`: previous question.
- `→`/`j`: next question.
- `f`: toggle flag.
- `1`–`9`: select choice for multiple-choice questions.
- Disabled when focus is inside `<input>`/`<textarea>`.
### Persistence
- `localStorage["brainwave.exam.state"]`: active exam snapshot (exam JSON + state).
- `localStorage["brainwave.exam.history"]`: array of completed attempts, newest first, capped at 30.
### Tweaks protocol
- Defaults wrapped in `/*EDITMODE-BEGIN*/{...}/*EDITMODE-END*/` JSON marker so host can rewrite.
- On change: `window.parent.postMessage({type:'__edit_mode_set_keys', edits:{...}},'*')`.
- Listens for `__activate_edit_mode` / `__deactivate_edit_mode`.
- On mount, posts `__edit_mode_available`.
---
## State Management
**Top-level app state:**
- `screen`: `'landing' | 'exam' | 'results' | 'review'`
- `exam`: parsed JSON object (or null)
- `state`: `{ currentIdx, answers: Record<qId, any>, flags: Record<qId, boolean>, revealed: Record<qId, boolean>, startedAt: number, completedAt?: number, feedbackMode: 'immediate'|'end' }`
- `history`: array of completed-attempt entries
- `tweaks`: `{ theme, fontSize, questionLayout, feedbackMode }`
- `tweaksOpen`: boolean
**Data fetching:** None beyond the initial JSON upload. Sample exams are static JSON files bundled with the app.
---
## Design Tokens
### Colors (OKLCH, with fallback hex for reference)
```
--paper oklch(0.985 0.004 80) # warm white background
--paper-2 oklch(0.965 0.005 80) # subtle tint
--paper-3 oklch(0.945 0.006 80) # stronger tint
--ink oklch(0.22 0.012 260) # primary text / primary button bg
--ink-2 oklch(0.36 0.012 260) # secondary text
--ink-3 oklch(0.52 0.012 260) # muted
--ink-4 oklch(0.68 0.008 260) # faint
--line oklch(0.9 0.006 80) # default 1px border
--line-2 oklch(0.86 0.008 80) # stronger border
--line-3 oklch(0.78 0.01 80) # hover border
--accent oklch(0.55 0.15 265) # indigo accent
--accent-weak oklch(0.94 0.03 265) # accent background
--accent-ink oklch(0.38 0.14 265) # accent text on weak bg
--correct oklch(0.58 0.13 150) # green
--correct-weak oklch(0.95 0.03 150)
--wrong oklch(0.58 0.18 25) # red-orange
--wrong-weak oklch(0.96 0.03 25)
--warn oklch(0.72 0.15 75) # amber (flag)
```
Dark theme: swap neutrals (paper ↔ ink scale), accent stays vivid at `oklch(0.72 0.13 265)`.
### Typography
- Sans: **Inter**, weights 400/500/600, features `ss01` + `cv11`.
- Serif: **Fraunces**, italics available; used for headlines and question prompts.
- Mono: **JetBrains Mono**, feature `zero` enabled.
- Base body: 15px / 1.55, letter-spacing -0.005em.
- Serif title scale: 22 / 26 / 28 / 48 / 56 / clamp(40–68) — letter-spacing -0.015 to -0.028em.
- Uppercase mono labels: 10.5–11px, text-transform uppercase, letter-spacing 0.12em, weight 500.
### Spacing
- Core scale: 4, 6, 8, 10, 12, 14, 16, 20, 24, 28, 32, 48, 64, 72px.
- Rail padding: `24px 20px`. Main content padding: `48px 64px 120px`.
- Card padding: 16–20px. Button padding: `9px 14px` (sm: `6px 10px`).
### Border radius
- `--r-xs`: 4px
- `--r-sm`: 6px
- `--r-md`: 10px (default for buttons, inputs, cards)
- `--r-lg`: 14px (dropzone, major panels)
- Pills: 999px.
### Shadows
- `--shadow-sm`: `0 1px 0 oklch(0.9 0.005 80 / 0.6)`
- `--shadow-md`: `0 1px 2px oklch(0 0 0 / 0.04), 0 4px 16px oklch(0 0 0 / 0.04)`
### Motion
- All transitions 100–200ms with `ease`.
- Category-bar width transitions at 400ms on initial render.
- No complex animations; everything is a simple state change.
---
## Assets
- **Fonts** are loaded from Google Fonts (Inter, Fraunces, JetBrains Mono). Swap for self-hosted equivalents in production.
- **KaTeX** (0.16.11) loaded from jsDelivr CDN. Bundle locally in production.
- **Sample exam images**: one Unsplash URL in `mixed-demo.json` (city skyline) — replace with app-owned assets.
- **Icons**: all UI marks (brand mark, upload icon, status dots, drag handle `⋮⋮`, etc.) are pure CSS or Unicode glyphs — no icon-library dependency.
---
## JSON Schema
Users upload a JSON file matching this shape (see `docs/schema.md` in the prototype for a longer version):
```json
{
"title": "string (required)",
"subtitle": "string",
"author": "string",
"timeLimitMinutes": 45,
"feedbackMode": "end" | "immediate",
"passingScore": 70,
"categories": ["Topic 1", "Topic 2"],
"questions": [
{
"id": "q1",
"type": "multiple-choice",
"category": "Topic 1",
"points": 2,
"prompt": "Question text. LaTeX via $...$ and $$...$$",
"explanation": "Shown after grading",
"image": { "src": "url", "alt": "...", "caption": "..." },
"audio": { "src": "url" },
"code": { "language": "python", "content": "..." },
"table": { "headers": ["..."], "rows": [["..."]] },
"svg": "<svg>...</svg>",
"choices": [{ "id": "a", "text": "..." }],
"answer": "a"
}
]
}
```
**Answer shape by type:**
- `multiple-choice`: string id
- `multiple-select`: array of ids
- `short-answer` / `translation`: string + `acceptedForms[]` + `caseSensitive` / `ignorePunctuation` / `ignoreAccents`
- `math-input`: LaTeX string + `acceptedForms[]`
- `essay`: no answer; optional `rubric[]` + `minWords`
- `match`: `left[]`, `right[]`, `answer: { [leftId]: rightId }`
- `order`: `items[]` and `answer[]` (ordered ids)
See the three sample files (`calculus-101.json`, `spanish-a2.json`, `mixed-demo.json`) for complete worked examples covering every question type and media block.
---
## Files
Bundled in this handoff (copied from the prototype):
- `BrainWave.html` — HTML shell with font + KaTeX + React loading
- `styles.css` — full design-token / component stylesheet
- `app/utils.js` — normalization, grading, formatting, LaTeX rendering helper, persistence
- `app/questions.jsx` — the 8 question renderers + media block
- `app/screens.jsx` — Landing, ExamRunner, Results components
- `app/app.jsx` — top-level App + Tweaks panel
- `samples/calculus-101.json` — math exam sample
- `samples/spanish-a2.json` — language exam sample
- `samples/mixed-demo.json` — mixed-types demo
- `docs/schema.md` — JSON schema reference
---
## Implementation Notes for Claude Code
1. **Framework choice:** React (with Next.js, Remix, or Vite) is the most direct port. Vue + Pinia or SvelteKit would also work well given the state is mostly local.
2. **LaTeX:** Use the real `katex` npm package, not a CDN. `react-katex` is a convenient wrapper.
3. **Persistence:** Replace raw localStorage with a typed wrapper (e.g. Zustand's `persist` middleware or a custom hook). Consider IndexedDB if users should store large exam JSONs.
4. **Validation:** Replace hand-rolled `validateExam` with a schema library (Zod, Valibot). Define the exam schema as the source of truth.
5. **Grading:** Keep grading logic pure and unit-test it — each question type's `gradeQuestion` branch is a good test seam.
6. **Drag-and-drop ordering:** HTML5 DnD works but is fiddly. Swap for `@dnd-kit/sortable` or `react-sortable-hoc` in production.
7. **Math input:** Consider a proper math editor (MathLive, MathQuill) so users don't have to type raw LaTeX.
8. **Accessibility:** Audit focus order, ARIA roles for radio/checkbox groups, timer live-region, and keyboard access for match/order questions.
9. **Internationalization:** Strings are hard-coded in the prototype. Extract to a resource file before shipping.
10. **Theming:** The token system is CSS-variable-based; it maps cleanly to any CSS-in-JS or design-token pipeline (Tailwind, Panda, vanilla-extract).
+228
View File
@@ -0,0 +1,228 @@
/* global React, ReactDOM */
// ==============================================================
// app.jsx — main controller + Tweaks
// ==============================================================
var { useState, useEffect, useCallback } = React;
const TWEAKS = /*EDITMODE-BEGIN*/{
"theme": "light",
"fontSize": "medium",
"questionLayout": "standard",
"feedbackMode": "auto"
}/*EDITMODE-END*/;
const App = () => {
const [screen, setScreen] = useState('landing'); // landing | exam | results | review
const [exam, setExam] = useState(null);
const [state, setState] = useState(null);
const [history, setHistory] = useState(() => window.loadHistory());
const [tweaks, setTweaks] = useState(TWEAKS);
const [tweaksOpen, setTweaksOpen] = useState(false);
// Apply theme/font tweaks
useEffect(() => {
document.documentElement.dataset.theme = tweaks.theme;
const map = { small: '14px', medium: '15px', large: '17px' };
document.body.style.fontSize = map[tweaks.fontSize] || '15px';
document.documentElement.dataset.layout = tweaks.questionLayout;
}, [tweaks]);
// Persist state on change during exam
useEffect(() => {
if (screen === 'exam' && exam && state) {
window.saveState({ exam, state });
}
}, [exam, state, screen]);
// Edit mode protocol
useEffect(() => {
const handler = (e) => {
if (e.data?.type === '__activate_edit_mode') setTweaksOpen(true);
if (e.data?.type === '__deactivate_edit_mode') setTweaksOpen(false);
};
window.addEventListener('message', handler);
window.parent.postMessage({ type: '__edit_mode_available' }, '*');
return () => window.removeEventListener('message', handler);
}, []);
const updateTweak = (key, value) => {
setTweaks((t) => {
const next = { ...t, [key]: value };
window.parent.postMessage({ type: '__edit_mode_set_keys', edits: { [key]: value } }, '*');
return next;
});
};
const loadExam = (data) => {
const finalFeedbackMode = tweaks.feedbackMode === 'auto' ? (data.feedbackMode || 'end') : tweaks.feedbackMode;
const initialState = {
currentIdx: 0,
answers: {},
flags: {},
revealed: {},
startedAt: Date.now(),
feedbackMode: finalFeedbackMode,
};
setExam(data);
setState(initialState);
setScreen('exam');
};
const resume = () => {
const saved = window.loadState();
if (saved?.exam && saved?.state) {
setExam(saved.exam);
setState(saved.state);
setScreen('exam');
}
};
const submit = useCallback(() => {
if (!exam || !state) return;
const completedAt = Date.now();
const elapsedMs = completedAt - state.startedAt;
const graded = exam.questions.map((q) => window.gradeQuestion(q, state.answers[q.id]));
const gradedCount = graded.filter((r) => r.status !== 'ungraded').length;
const correctCount = graded.filter((r) => r.correct).length;
const totalPoints = exam.questions.reduce((a, q) => a + (q.points || 1), 0);
const scorePoints = graded.reduce((a, r) => a + r.points, 0);
const historyEntry = {
id: `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
title: exam.title,
completedAt,
elapsedMs,
gradedCount,
correctCount,
totalPoints,
scorePoints,
passingScore: exam.passingScore || 60,
exam, // full snapshot so we can re-review
state: { ...state, completedAt },
};
window.appendHistory(historyEntry);
setHistory(window.loadHistory());
window.clearState();
setState((s) => ({ ...s, completedAt }));
setScreen('results');
}, [exam, state]);
const exit = () => {
setScreen('landing');
setExam(null);
setState(null);
};
const restart = () => {
if (!exam) return;
loadExam(exam);
};
// Replay a past attempt
window.__replayAttempt = (entry) => {
setExam(entry.exam);
setState(entry.state);
setScreen('review');
};
const clearAllHistory = () => {
if (confirm('Clear all past attempts?')) {
window.clearHistory();
setHistory([]);
}
};
return (
<div className="shell">
<header className="topbar">
<div className="brand">
<span className="brand-mark" />
<span>BrainWave</span>
<span className="faint mono" style={{ fontSize: 11, marginLeft: 4 }}>/ practice</span>
</div>
<div className="topbar-meta">
{screen === 'exam' && exam && (
<>
<span className="mono" style={{ fontSize: 12 }}>{exam.title}</span>
<button className="btn btn-ghost btn-sm" onClick={exit}>Exit</button>
</>
)}
</div>
</header>
{screen === 'landing' && (
<Landing
onLoadExam={loadExam}
history={history}
onResume={resume}
onClearHistory={clearAllHistory}
/>
)}
{screen === 'exam' && exam && state && (
<ExamRunner
exam={exam}
state={state}
setState={setState}
onSubmit={submit}
onExit={exit}
/>
)}
{(screen === 'results' || screen === 'review') && exam && state && (
<Results
exam={exam}
state={state}
onRestart={restart}
onExit={exit}
reviewOnly={screen === 'review'}
/>
)}
{tweaksOpen && <TweaksPanel tweaks={tweaks} update={updateTweak} onClose={() => setTweaksOpen(false)} />}
</div>
);
};
// ---------- Tweaks Panel ----------
const TweaksPanel = ({ tweaks, update, onClose }) => {
const Seg = ({ tkey, options }) => (
<div className="tweak-control">
{options.map((opt) => (
<button
key={opt.value}
className={`tweak-seg ${tweaks[tkey] === opt.value ? 'active' : ''}`}
onClick={() => update(tkey, opt.value)}
>
{opt.label}
</button>
))}
</div>
);
return (
<div className="tweaks-panel">
<div className="tweaks-header">
<span>Tweaks</span>
<button className="btn btn-ghost btn-sm" onClick={onClose} style={{ padding: '2px 6px' }}>×</button>
</div>
<div className="tweaks-body">
<div className="tweak-row">
<div className="tweak-label">Theme</div>
<Seg tkey="theme" options={[{ value: 'light', label: 'Light' }, { value: 'dark', label: 'Dark' }]} />
</div>
<div className="tweak-row">
<div className="tweak-label">Text size</div>
<Seg tkey="fontSize" options={[{ value: 'small', label: 'S' }, { value: 'medium', label: 'M' }, { value: 'large', label: 'L' }]} />
</div>
<div className="tweak-row">
<div className="tweak-label">Feedback</div>
<Seg tkey="feedbackMode" options={[
{ value: 'auto', label: 'Auto' },
{ value: 'immediate', label: 'Immediate' },
{ value: 'end', label: 'End' },
]} />
</div>
</div>
</div>
);
};
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
@@ -0,0 +1,386 @@
/* global React */
// ==============================================================
// questions.jsx — renderers for every question type
// ==============================================================
var { useState, useEffect, useRef, useMemo } = React;
// ---------- Media blocks (image, code, table, audio) ----------
const QuestionMedia = ({ q }) => (
<>
{q.image && (
<figure className="q-media">
<img src={q.image.src} alt={q.image.alt || ''} />
{q.image.caption && <figcaption className="q-media-caption">{q.image.caption}</figcaption>}
</figure>
)}
{q.audio && (
<div className="q-audio">
<audio controls src={q.audio.src}>Your browser does not support audio.</audio>
</div>
)}
{q.code && (
<pre
className="q-code"
dangerouslySetInnerHTML={{ __html: window.highlightCode(q.code.content, q.code.language) }}
/>
)}
{q.table && (
<table className="q-table">
<thead>
<tr>{q.table.headers.map((h, i) => <th key={i}>{h}</th>)}</tr>
</thead>
<tbody>
{q.table.rows.map((row, i) => (
<tr key={i}>{row.map((cell, j) => <td key={j}>{cell}</td>)}</tr>
))}
</tbody>
</table>
)}
{q.svg && <div className="q-media" dangerouslySetInnerHTML={{ __html: q.svg }} />}
</>
);
// ---------- Multiple Choice ----------
const MultipleChoice = ({ q, value, onChange, locked, graded }) => (
<div className="choices" role="radiogroup">
{q.choices.map((c, i) => {
const isSelected = value === c.id;
let cls = 'choice';
if (isSelected) cls += ' selected';
if (graded) {
if (c.id === q.answer) cls += ' correct';
else if (isSelected) cls += ' wrong';
}
return (
<button
key={c.id}
className={cls}
onClick={() => !locked && onChange(c.id)}
disabled={locked}
role="radio"
aria-checked={isSelected}
>
<span className="choice-marker" />
<span className="choice-key">{String.fromCharCode(65 + i)}</span>
<window.Tex>{c.text}</window.Tex>
</button>
);
})}
</div>
);
// ---------- Multiple Select ----------
const MultipleSelect = ({ q, value = [], onChange, locked, graded }) => {
const toggle = (id) => {
if (locked) return;
onChange(value.includes(id) ? value.filter((v) => v !== id) : [...value, id]);
};
const answerSet = new Set(q.answer || []);
return (
<div className="choices">
{q.choices.map((c, i) => {
const isSelected = value.includes(c.id);
let cls = 'choice';
if (isSelected) cls += ' selected';
if (graded) {
if (answerSet.has(c.id)) cls += ' correct';
else if (isSelected) cls += ' wrong';
}
return (
<button key={c.id} className={cls} onClick={() => toggle(c.id)} disabled={locked}>
<span className="choice-marker square" />
<span className="choice-key">{String.fromCharCode(65 + i)}</span>
<window.Tex>{c.text}</window.Tex>
</button>
);
})}
</div>
);
};
// ---------- Short Answer ----------
const ShortAnswer = ({ q, value = '', onChange, locked }) => (
<div className="answer-input-wrap">
<input
className="input"
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="Type your answer…"
disabled={locked}
autoComplete="off"
spellCheck={q.type === 'translation'}
/>
{q.type === 'translation' && q.targetLanguage && (
<div className="faint mono" style={{ fontSize: 11 }}>
Answer in {q.targetLanguage}
</div>
)}
</div>
);
// ---------- Math Input ----------
const MathInput = ({ q, value = '', onChange, locked }) => {
const [preview, setPreview] = useState('');
useEffect(() => {
if (!value) { setPreview(''); return; }
try {
setPreview(window.katex.renderToString(value, { throwOnError: false, displayMode: false }));
} catch {
setPreview('');
}
}, [value]);
return (
<div className="answer-input-wrap">
<input
className="input mono"
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="LaTeX, e.g. 2x\ln x + x"
disabled={locked}
autoComplete="off"
spellCheck={false}
/>
<div className="math-preview">
<span className="math-preview-label">Preview</span>
{preview
? <span dangerouslySetInnerHTML={{ __html: preview }} />
: <span className="faint" style={{ fontSize: 13 }}>Live LaTeX preview appears here</span>}
</div>
</div>
);
};
// ---------- Essay ----------
const Essay = ({ q, value = {}, onChange, locked }) => {
const text = typeof value === 'string' ? value : (value.text || '');
const wordCount = text.trim() ? text.trim().split(/\s+/).length : 0;
const minOK = !q.minWords || wordCount >= q.minWords;
return (
<div className="answer-input-wrap" style={{ maxWidth: 720 }}>
<textarea
className="textarea"
value={text}
onChange={(e) => onChange({ ...value, text: e.target.value })}
placeholder="Write your response…"
disabled={locked}
rows={10}
/>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12 }} className="mono faint">
<span>{wordCount} words {q.minWords && <span style={{ color: minOK ? 'var(--ink-3)' : 'var(--wrong)' }}>· min {q.minWords}</span>}</span>
<span>Self-graded · see rubric on review</span>
</div>
</div>
);
};
// ---------- Match ----------
const Match = ({ q, value = {}, onChange, locked, graded }) => {
const [activeLeft, setActiveLeft] = useState(null);
const reverseMap = useMemo(() => {
const m = {};
Object.entries(value).forEach(([l, r]) => { m[r] = l; });
return m;
}, [value]);
const handleRight = (rightId) => {
if (locked) return;
if (activeLeft) {
const next = { ...value };
// clear any existing pairing for this left or right
Object.keys(next).forEach((k) => { if (k === activeLeft || next[k] === rightId) delete next[k]; });
next[activeLeft] = rightId;
onChange(next);
setActiveLeft(null);
} else if (reverseMap[rightId]) {
// click right to clear its pairing
const next = { ...value };
delete next[reverseMap[rightId]];
onChange(next);
}
};
const handleLeft = (leftId) => {
if (locked) return;
if (value[leftId]) {
// click a paired left → clear it
const next = { ...value };
delete next[leftId];
onChange(next);
return;
}
setActiveLeft(activeLeft === leftId ? null : leftId);
};
const badge = (id, fromLeft) => {
if (fromLeft) {
const r = value[id];
if (!r) return null;
const idx = q.right.findIndex((x) => x.id === r);
return <span className="match-pair-badge">{String.fromCharCode(65 + idx)}</span>;
}
const l = reverseMap[id];
if (!l) return null;
const idx = q.left.findIndex((x) => x.id === l);
return <span className="match-pair-badge">{idx + 1}</span>;
};
const statusCls = (leftId, rightId) => {
if (!graded) return '';
const correctPair = q.answer[leftId];
if (correctPair && value[leftId] === correctPair) return ' correct';
if (value[leftId]) return ' wrong';
return '';
};
return (
<div className="match-grid">
<div className="match-col">
<div className="match-col-label">Items</div>
{q.left.map((item, i) => {
let cls = 'match-item';
if (activeLeft === item.id) cls += ' active';
else if (value[item.id]) cls += ' paired';
cls += statusCls(item.id);
return (
<button key={item.id} className={cls} onClick={() => handleLeft(item.id)} disabled={locked}>
<span style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<span className="mono faint" style={{ fontSize: 11 }}>{i + 1}</span>
<window.Tex>{item.text}</window.Tex>
</span>
{badge(item.id, true)}
</button>
);
})}
</div>
<div className="match-col">
<div className="match-col-label">Matches</div>
{q.right.map((item, i) => {
let cls = 'match-item';
if (reverseMap[item.id]) cls += ' paired';
// for graded: color right item based on whether the left it's paired with is correct
if (graded && reverseMap[item.id]) {
const l = reverseMap[item.id];
if (q.answer[l] === item.id) cls += ' correct';
else cls += ' wrong';
}
return (
<button key={item.id} className={cls} onClick={() => handleRight(item.id)} disabled={locked || (!activeLeft && !reverseMap[item.id])}>
<span style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<span className="mono faint" style={{ fontSize: 11 }}>{String.fromCharCode(65 + i)}</span>
<window.Tex>{item.text}</window.Tex>
</span>
{badge(item.id, false)}
</button>
);
})}
</div>
</div>
);
};
// ---------- Order (drag to reorder) ----------
const OrderQuestion = ({ q, value, onChange, locked, graded }) => {
// initialize value as shuffled item ids on first render
const initial = useMemo(() => {
if (Array.isArray(value) && value.length === q.items.length) return value;
const ids = q.items.map((i) => i.id);
// deterministic shuffle using question id as seed
const seed = q.id.split('').reduce((a, c) => a + c.charCodeAt(0), 0);
const out = [...ids];
for (let i = out.length - 1; i > 0; i--) {
const j = (seed * (i + 7)) % (i + 1);
[out[i], out[j]] = [out[j], out[i]];
}
return out;
}, [q.id]);
useEffect(() => {
if (!Array.isArray(value) || value.length !== q.items.length) {
onChange(initial);
}
}, []);
const list = Array.isArray(value) && value.length === q.items.length ? value : initial;
const [dragIdx, setDragIdx] = useState(null);
const [overIdx, setOverIdx] = useState(null);
const move = (from, to) => {
if (from === to || from < 0 || to < 0) return;
const next = [...list];
const [item] = next.splice(from, 1);
next.splice(to, 0, item);
onChange(next);
};
const onDragStart = (idx) => (e) => {
setDragIdx(idx);
e.dataTransfer.effectAllowed = 'move';
};
const onDragOver = (idx) => (e) => {
e.preventDefault();
setOverIdx(idx);
};
const onDrop = (idx) => (e) => {
e.preventDefault();
if (dragIdx != null && dragIdx !== idx) move(dragIdx, idx);
setDragIdx(null); setOverIdx(null);
};
const itemById = (id) => q.items.find((i) => i.id === id);
return (
<div className="order-list">
{list.map((id, idx) => {
let cls = 'order-item';
if (dragIdx === idx) cls += ' dragging';
if (overIdx === idx && dragIdx !== idx) cls += dragIdx > idx ? ' drop-before' : ' drop-after';
if (graded) {
cls += q.answer[idx] === id ? ' correct' : ' wrong';
}
return (
<div
key={id}
className={cls}
draggable={!locked}
onDragStart={onDragStart(idx)}
onDragOver={onDragOver(idx)}
onDrop={onDrop(idx)}
onDragEnd={() => { setDragIdx(null); setOverIdx(null); }}
>
<span className="order-handle">⋮⋮</span>
<span className="order-num">{idx + 1}</span>
<span className="order-text"><window.Tex>{itemById(id)?.text || ''}</window.Tex></span>
{!locked && (
<div style={{ display: 'flex', gap: 2 }}>
<button className="btn btn-ghost btn-sm" onClick={() => move(idx, idx - 1)} disabled={idx === 0} aria-label="Move up">↑</button>
<button className="btn btn-ghost btn-sm" onClick={() => move(idx, idx + 1)} disabled={idx === list.length - 1} aria-label="Move down">↓</button>
</div>
)}
</div>
);
})}
</div>
);
};
// ---------- Dispatcher ----------
const QuestionRenderer = ({ q, value, onChange, locked, graded }) => {
const props = { q, value, onChange, locked, graded };
switch (q.type) {
case 'multiple-choice': return <MultipleChoice {...props} />;
case 'multiple-select': return <MultipleSelect {...props} />;
case 'short-answer':
case 'translation': return <ShortAnswer {...props} />;
case 'math-input': return <MathInput {...props} />;
case 'essay': return <Essay {...props} />;
case 'match': return <Match {...props} />;
case 'order': return <OrderQuestion {...props} />;
default:
return <div className="muted">Unsupported question type: <code>{q.type}</code></div>;
}
};
Object.assign(window, { QuestionMedia, QuestionRenderer });
@@ -0,0 +1,458 @@
/* global React */
// ==============================================================
// screens.jsx — Landing, Exam, Results
// ==============================================================
var { useState, useEffect, useRef, useMemo, useCallback } = React;
// ---------- LANDING ----------
const Landing = ({ onLoadExam, history, onResume, onClearHistory }) => {
const [err, setErr] = useState(null);
const [dragOver, setDragOver] = useState(false);
const fileRef = useRef(null);
const handleFile = async (file) => {
setErr(null);
try {
const text = await file.text();
const data = JSON.parse(text);
const msg = window.validateExam(data);
if (msg) throw new Error(msg);
onLoadExam(data);
} catch (e) {
setErr(`Could not load: ${e.message}`);
}
};
const loadSample = async (path) => {
setErr(null);
try {
const r = await fetch(path);
const data = await r.json();
onLoadExam(data);
} catch (e) {
setErr(`Could not load sample: ${e.message}`);
}
};
const samples = [
{ path: 'samples/calculus-101.json', title: 'Calculus I — Midterm Practice', meta: '7 questions · 45 min · LaTeX math', mark: '∫' },
{ path: 'samples/spanish-a2.json', title: 'Spanish A2 — Vocabulary & Grammar', meta: '6 questions · 20 min · Translation', mark: 'ñ' },
{ path: 'samples/mixed-demo.json', title: 'Mixed Demo — Every Question Type', meta: '4 questions · Untimed · Media', mark: '◇' },
];
const resumable = window.loadState();
return (
<div className="landing">
<div className="landing-eyebrow">BrainWave · practice exam platform</div>
<h1 className="landing-title">Upload an exam. <em>Practice anything.</em></h1>
<p className="landing-sub">
Drop in a JSON file to run a practice exam — math with full LaTeX, language translation, multiple choice, matching, ordering, essays, and more. Progress saves automatically.
</p>
<div className="landing-grid">
<div>
<div
className={`dropzone ${dragOver ? 'drag-over' : ''}`}
onClick={() => fileRef.current?.click()}
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onDrop={(e) => {
e.preventDefault(); setDragOver(false);
const f = e.dataTransfer.files?.[0];
if (f) handleFile(f);
}}
>
<div className="dropzone-icon" />
<h3>Drop your exam JSON here</h3>
<p>or click to browse · max 5 MB</p>
<input
ref={fileRef}
type="file"
accept="application/json,.json"
style={{ display: 'none' }}
onChange={(e) => e.target.files?.[0] && handleFile(e.target.files[0])}
/>
</div>
{err && <div style={{ marginTop: 12, padding: '10px 14px', border: '1px solid var(--wrong)', borderRadius: 8, background: 'var(--wrong-weak)', color: 'var(--wrong)', fontSize: 13 }}>{err}</div>}
{resumable && (
<div style={{ marginTop: 20, padding: 16, border: '1px solid var(--line-2)', borderRadius: 10, background: 'var(--paper-2)', display: 'flex', alignItems: 'center', gap: 16 }}>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 500, fontSize: 14 }}>Resume in-progress exam</div>
<div className="muted" style={{ fontSize: 12.5, marginTop: 2 }}>{resumable.exam.title} · {Object.keys(resumable.answers || {}).length}/{resumable.exam.questions.length} answered</div>
</div>
<button className="btn btn-primary btn-sm" onClick={onResume}>Resume →</button>
</div>
)}
<div style={{ marginTop: 20, fontSize: 12.5, color: 'var(--ink-3)', lineHeight: 1.7 }}>
<div className="mono" style={{ textTransform: 'uppercase', letterSpacing: '0.12em', fontSize: 10.5, marginBottom: 6 }}>Supported types</div>
multiple-choice · multiple-select · short-answer · translation · math-input (LaTeX) · essay · match · order
</div>
</div>
<div className="samples">
<h4>Try a sample</h4>
{samples.map((s) => (
<div key={s.path} className="sample" onClick={() => loadSample(s.path)}>
<div className="sample-mark serif" style={{ fontSize: 18 }}>{s.mark}</div>
<div className="sample-body">
<div className="sample-title">{s.title}</div>
<div className="sample-meta">{s.meta}</div>
</div>
<div className="sample-arrow">→</div>
</div>
))}
<a
href="docs/schema.md"
target="_blank"
style={{ display: 'block', marginTop: 16, fontSize: 12.5, color: 'var(--ink-3)', textDecoration: 'none', borderBottom: '1px solid var(--line-2)', paddingBottom: 2, width: 'fit-content' }}
>
View JSON schema →
</a>
</div>
</div>
{history.length > 0 && (
<div className="history">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 16 }}>
<h4 style={{ fontSize: 11, fontFamily: 'var(--font-mono)', textTransform: 'uppercase', letterSpacing: '0.12em', color: 'var(--ink-3)', margin: 0, fontWeight: 500 }}>Past attempts</h4>
<button className="btn btn-ghost btn-sm" onClick={onClearHistory}>Clear history</button>
</div>
{history.map((h) => {
const pct = h.totalPoints ? Math.round((h.scorePoints / h.totalPoints) * 100) : 0;
const passed = pct >= (h.passingScore || 60);
return (
<div key={h.id} className="history-row">
<div>
<div className="history-title">{h.title}</div>
<div className="muted" style={{ fontSize: 12 }}>{window.formatDate(h.completedAt)} · {h.correctCount}/{h.gradedCount} correct</div>
</div>
<div className="mono muted" style={{ fontSize: 12 }}>{window.formatTime(h.elapsedMs)}</div>
<div className={`score-pill ${passed ? 'pass' : 'fail'}`}>{pct}%</div>
<button className="btn btn-ghost btn-sm" onClick={() => window.__replayAttempt?.(h)}>Review</button>
</div>
);
})}
</div>
)}
</div>
);
};
// ---------- EXAM (runner) ----------
const ExamRunner = ({ exam, state, setState, onSubmit, onExit }) => {
const { currentIdx, answers, flags, startedAt, feedbackMode } = state;
const q = exam.questions[currentIdx];
const total = exam.questions.length;
const [revealed, setRevealed] = useState(() => !!state.revealed?.[q.id]);
useEffect(() => {
setRevealed(!!state.revealed?.[q.id]);
}, [currentIdx, q.id, state.revealed]);
const setAnswer = (val) => {
setState((s) => ({ ...s, answers: { ...s.answers, [q.id]: val } }));
};
const goTo = (i) => setState((s) => ({ ...s, currentIdx: Math.max(0, Math.min(total - 1, i)) }));
const toggleFlag = () => setState((s) => ({ ...s, flags: { ...s.flags, [q.id]: !s.flags[q.id] } }));
const checkAnswer = () => {
setState((s) => ({ ...s, revealed: { ...(s.revealed || {}), [q.id]: true } }));
setRevealed(true);
};
// Timer
const [now, setNow] = useState(Date.now());
useEffect(() => {
const t = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(t);
}, []);
const elapsedMs = now - startedAt;
const remainingMs = exam.timeLimitMinutes ? exam.timeLimitMinutes * 60_000 - elapsedMs : null;
const timeUrgent = remainingMs != null && remainingMs < 60_000;
useEffect(() => {
if (remainingMs != null && remainingMs <= 0) onSubmit();
}, [remainingMs, onSubmit]);
// keyboard shortcuts
useEffect(() => {
const handler = (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
if (e.key === 'ArrowRight' || e.key === 'j') goTo(currentIdx + 1);
if (e.key === 'ArrowLeft' || e.key === 'k') goTo(currentIdx - 1);
if (e.key === 'f') toggleFlag();
if (/^[1-9]$/.test(e.key) && q.type === 'multiple-choice') {
const idx = parseInt(e.key, 10) - 1;
if (q.choices[idx]) setAnswer(q.choices[idx].id);
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [currentIdx, q]);
const graded = feedbackMode === 'immediate' && revealed;
const answered = answers[q.id] != null && (!Array.isArray(answers[q.id]) || answers[q.id].length > 0);
const result = graded ? window.gradeQuestion(q, answers[q.id]) : null;
// Navigator status
const navStatus = (qq) => {
if (feedbackMode === 'immediate' && state.revealed?.[qq.id]) {
const r = window.gradeQuestion(qq, answers[qq.id]);
if (r.status === 'correct') return 'correct';
if (r.status === 'wrong') return 'wrong';
}
if (flags[qq.id]) return 'flagged';
if (answers[qq.id] != null && (!Array.isArray(answers[qq.id]) || answers[qq.id].length > 0)) return 'answered';
return '';
};
const answeredCount = exam.questions.filter((qq) => navStatus(qq) === 'answered' || navStatus(qq) === 'correct' || navStatus(qq) === 'wrong').length;
return (
<div className="exam-shell">
{/* Sidebar */}
<aside className="sidebar">
<h2 className="sidebar-title">{exam.title}</h2>
{exam.subtitle && <p className="sidebar-sub">{exam.subtitle}</p>}
<div className="sidebar-section-label">Questions</div>
<div className="nav-list">
{exam.questions.map((qq, i) => {
const st = navStatus(qq);
return (
<button
key={qq.id}
className={`nav-item ${i === currentIdx ? 'active' : ''}`}
onClick={() => goTo(i)}
>
<span className="nav-item-num">{String(i + 1).padStart(2, '0')}</span>
<span className="nav-item-title">
{qq.category ? `${qq.category} · ` : ''}{qq.type.replace('-', ' ')}
</span>
<span className={`nav-dot ${st}`} />
</button>
);
})}
</div>
<div className="sidebar-section-label">Progress</div>
<div className="mono" style={{ fontSize: 13, color: 'var(--ink-2)' }}>
{answeredCount} of {total} answered
</div>
<div className="progress-bar"><div style={{ width: `${(answeredCount / total) * 100}%` }} /></div>
<div style={{ marginTop: 32, display: 'flex', flexDirection: 'column', gap: 8 }}>
<button className="btn btn-primary" onClick={onSubmit}>Submit exam</button>
<button className="btn btn-ghost btn-sm" onClick={onExit}>Exit to home</button>
</div>
</aside>
{/* Main */}
<main className="main">
<header className="question-header">
<span className="q-index">Question {String(currentIdx + 1).padStart(2, '0')} / {String(total).padStart(2, '0')}</span>
{q.category && <span className="q-category">{q.category}</span>}
<span className="q-points">{q.points || 1} {q.points === 1 ? 'pt' : 'pts'}</span>
</header>
<window.Tex tag="div" className="q-prompt">{q.prompt}</window.Tex>
<window.QuestionMedia q={q} />
<window.QuestionRenderer
q={q}
value={answers[q.id]}
onChange={setAnswer}
locked={graded && q.type !== 'essay'}
graded={graded}
/>
{graded && result && q.type !== 'essay' && (
<div className={`feedback ${result.correct ? 'correct' : 'wrong'}`}>
<div className="feedback-label">{result.correct ? 'Correct' : 'Not quite'}</div>
<div className="feedback-body">
{!result.correct && q.answer && q.type !== 'match' && q.type !== 'order' && (
<p><strong>Answer:</strong> <window.Tex>{Array.isArray(q.answer) ? q.answer.map(id => q.choices?.find(c => c.id === id)?.text || id).join(', ') : (q.choices?.find(c => c.id === q.answer)?.text || q.answer)}</window.Tex></p>
)}
{q.explanation && <p><window.Tex>{q.explanation}</window.Tex></p>}
</div>
</div>
)}
<footer className="exam-footer">
<button className="btn" onClick={() => goTo(currentIdx - 1)} disabled={currentIdx === 0}>← Previous</button>
<button className="btn btn-ghost" onClick={toggleFlag}>
{flags[q.id] ? '⚑ Flagged' : '⚐ Flag for review'}
</button>
<span className="spacer" />
{feedbackMode === 'immediate' && !revealed && q.type !== 'essay' && answered && (
<button className="btn btn-primary" onClick={checkAnswer}>Check answer</button>
)}
{currentIdx < total - 1 ? (
<button className="btn btn-primary" onClick={() => goTo(currentIdx + 1)}>Next →</button>
) : (
<button className="btn btn-primary" onClick={onSubmit}>Submit exam</button>
)}
</footer>
<div style={{ marginTop: 48, fontSize: 11.5, color: 'var(--ink-4)', display: 'flex', gap: 16, flexWrap: 'wrap' }}>
<span><span className="kbd">←</span> <span className="kbd">→</span> navigate</span>
<span><span className="kbd">F</span> flag</span>
{q.type === 'multiple-choice' && <span><span className="kbd">1</span>–<span className="kbd">{q.choices.length}</span> select</span>}
</div>
</main>
{/* Meta rail */}
<aside className="meta-rail">
<div className="meta-block">
<div className="meta-label">Elapsed</div>
<div className="meta-value mono">{window.formatTime(elapsedMs)}</div>
</div>
{remainingMs != null && (
<div className="meta-block">
<div className="meta-label">Remaining</div>
<div className={`meta-value mono ${timeUrgent ? 'timer-urgent' : ''}`}>{window.formatTime(Math.max(0, remainingMs))}</div>
</div>
)}
<div className="meta-block">
<div className="meta-label">Points possible</div>
<div className="meta-value mono">{exam.questions.reduce((a, q) => a + (q.points || 1), 0)}</div>
</div>
<div className="meta-block">
<div className="meta-label">Mode</div>
<div className="meta-value mono" style={{ fontSize: 13 }}>
{feedbackMode === 'immediate' ? 'Immediate feedback' : 'Grade at end'}
</div>
</div>
{exam.author && (
<div className="meta-block">
<div className="meta-label">Author</div>
<div style={{ fontSize: 14 }}>{exam.author}</div>
</div>
)}
</aside>
</div>
);
};
// ---------- RESULTS ----------
const Results = ({ exam, state, onRestart, onExit, reviewOnly }) => {
const { answers, startedAt, completedAt } = state;
const elapsedMs = (completedAt || Date.now()) - startedAt;
const graded = exam.questions.map((q) => ({
q,
userAnswer: answers[q.id],
result: window.gradeQuestion(q, answers[q.id]),
}));
const totalPoints = exam.questions.reduce((a, q) => a + (q.points || 1), 0);
const scorePoints = graded.reduce((a, g) => a + g.result.points, 0);
const gradedCount = graded.filter((g) => g.result.status !== 'ungraded').length;
const correctCount = graded.filter((g) => g.result.correct).length;
const pct = totalPoints ? Math.round((scorePoints / totalPoints) * 100) : 0;
const passing = exam.passingScore || 60;
const passed = pct >= passing;
// Categories breakdown
const byCat = {};
graded.forEach((g) => {
const cat = g.q.category || 'Uncategorized';
if (!byCat[cat]) byCat[cat] = { earned: 0, possible: 0, correct: 0, total: 0 };
byCat[cat].possible += g.q.points || 1;
byCat[cat].earned += g.result.points;
byCat[cat].total += 1;
if (g.result.correct) byCat[cat].correct += 1;
});
const [openIdx, setOpenIdx] = useState(null);
return (
<div className="results">
<div className="results-eyebrow">
{reviewOnly ? 'Reviewing past attempt' : 'Exam complete'} · {exam.title}
</div>
<h1 className="results-title">
You scored <em>{pct}%</em><br />
<span style={{ fontSize: '0.5em', color: 'var(--ink-3)' }}>
{passed ? `Above the ${passing}% passing mark.` : `Below the ${passing}% passing mark. Keep practicing.`}
</span>
</h1>
<div className="score-grid">
<div className="score-cell">
<div className="score-cell-label">Points</div>
<div className="score-cell-value">{scorePoints}<span className="score-cell-unit">/ {totalPoints}</span></div>
</div>
<div className="score-cell" style={{ paddingLeft: 24 }}>
<div className="score-cell-label">Correct</div>
<div className="score-cell-value">{correctCount}<span className="score-cell-unit">/ {gradedCount}</span></div>
</div>
<div className="score-cell" style={{ paddingLeft: 24 }}>
<div className="score-cell-label">Time</div>
<div className="score-cell-value mono" style={{ fontSize: 36 }}>{window.formatTime(elapsedMs)}</div>
</div>
</div>
{Object.keys(byCat).length > 1 && (
<div className="category-breakdown">
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, textTransform: 'uppercase', letterSpacing: '0.12em', color: 'var(--ink-3)', marginBottom: 16, fontWeight: 500 }}>By category</div>
{Object.entries(byCat).map(([cat, c]) => {
const catPct = c.possible ? Math.round((c.earned / c.possible) * 100) : 0;
return (
<div key={cat} className="category-row">
<div style={{ fontWeight: 500 }}>{cat}</div>
<div className="category-bar"><div style={{ width: `${catPct}%` }} /></div>
<div className="category-score">{catPct}%</div>
</div>
);
})}
</div>
)}
<h2 className="review-heading">Review</h2>
{graded.map((g, i) => (
<div key={g.q.id} className={`review-item ${openIdx === i ? 'open' : ''}`}>
<div className="review-item-header" onClick={() => setOpenIdx(openIdx === i ? null : i)}>
<span className="review-num">{String(i + 1).padStart(2, '0')}</span>
<span className="review-title"><window.Tex>{g.q.prompt.replace(/\$\$([^$]+)\$\$/g, '[math]').replace(/\$([^$]+)\$/g, '[math]').slice(0, 90)}</window.Tex>{g.q.prompt.length > 90 ? '…' : ''}</span>
<span className={`review-status ${g.result.status}`}>{g.result.status}</span>
<span style={{ color: 'var(--ink-4)' }}>{openIdx === i ? '−' : '+'}</span>
</div>
{openIdx === i && (
<div className="review-body">
<div className="q-prompt" style={{ fontSize: 20, marginBottom: 20 }}>
<window.Tex>{g.q.prompt}</window.Tex>
</div>
<window.QuestionMedia q={g.q} />
<window.QuestionRenderer q={g.q} value={g.userAnswer} onChange={() => {}} locked graded />
{g.q.explanation && (
<div className="feedback" style={{ marginTop: 24 }}>
<div className="feedback-label">Explanation</div>
<div className="feedback-body"><window.Tex>{g.q.explanation}</window.Tex></div>
</div>
)}
{g.q.rubric && (
<div style={{ marginTop: 20, fontSize: 13.5 }}>
<div className="meta-label" style={{ marginBottom: 8 }}>Rubric (self-grade)</div>
<ul style={{ paddingLeft: 18, margin: 0, lineHeight: 1.7, color: 'var(--ink-2)' }}>
{g.q.rubric.map((r, j) => <li key={j}><window.Tex>{r}</window.Tex></li>)}
</ul>
</div>
)}
</div>
)}
</div>
))}
<div style={{ marginTop: 48, display: 'flex', gap: 12 }}>
{!reviewOnly && <button className="btn btn-primary" onClick={onRestart}>Retake exam</button>}
<button className="btn" onClick={onExit}>Back to home</button>
</div>
</div>
);
};
Object.assign(window, { Landing, ExamRunner, Results });
+234
View File
@@ -0,0 +1,234 @@
/* global window */
// ==============================================================
// utils.js — pure helpers: normalization, grading, formatting
// ==============================================================
const normalizeString = (s, opts = {}) => {
if (s == null) return '';
let out = String(s);
if (!opts.caseSensitive) out = out.toLowerCase();
if (opts.ignoreAccents !== false) {
out = out.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
}
if (opts.ignorePunctuation !== false) {
out = out.replace(/[.,/#!$%^&*;:{}=\-_`~()¿?¡!'"]/g, '');
}
out = out.replace(/\s+/g, ' ').trim();
return out;
};
const normalizeLatex = (s) => {
if (s == null) return '';
return String(s)
.replace(/\s+/g, '')
.replace(/\\left|\\right/g, '')
.replace(/\\,|\\;|\\:|\\!|\\ /g, '')
.replace(/\{([^{}])\}/g, '$1') // strip braces around single chars
.replace(/\\cdot/g, '*')
.replace(/\\times/g, '*')
.replace(/\\ln/g, 'ln')
.replace(/\\log/g, 'log')
.toLowerCase();
};
const arraysEqualUnordered = (a, b) => {
if (!Array.isArray(a) || !Array.isArray(b)) return false;
if (a.length !== b.length) return false;
const sa = [...a].sort();
const sb = [...b].sort();
return sa.every((v, i) => v === sb[i]);
};
const arraysEqualOrdered = (a, b) => {
if (!Array.isArray(a) || !Array.isArray(b)) return false;
if (a.length !== b.length) return false;
return a.every((v, i) => v === b[i]);
};
// ---- grading ----
const gradeQuestion = (q, userAnswer) => {
if (userAnswer == null) return { status: 'unanswered', correct: false, points: 0 };
switch (q.type) {
case 'multiple-choice': {
const ok = userAnswer === q.answer;
return { status: ok ? 'correct' : 'wrong', correct: ok, points: ok ? (q.points || 1) : 0 };
}
case 'multiple-select': {
const ok = arraysEqualUnordered(userAnswer || [], q.answer || []);
return { status: ok ? 'correct' : 'wrong', correct: ok, points: ok ? (q.points || 1) : 0 };
}
case 'short-answer':
case 'translation': {
const opts = {
caseSensitive: q.caseSensitive === true,
ignorePunctuation: q.ignorePunctuation !== false,
ignoreAccents: q.ignoreAccents !== false,
};
const target = normalizeString(userAnswer, opts);
const accepted = [q.answer, ...(q.acceptedForms || [])]
.filter(Boolean)
.map((a) => normalizeString(a, opts));
const ok = accepted.includes(target);
return { status: ok ? 'correct' : 'wrong', correct: ok, points: ok ? (q.points || 1) : 0 };
}
case 'math-input': {
const target = normalizeLatex(userAnswer);
const accepted = [q.answer, ...(q.acceptedForms || [])].filter(Boolean).map(normalizeLatex);
const ok = accepted.includes(target);
return { status: ok ? 'correct' : 'wrong', correct: ok, points: ok ? (q.points || 1) : 0 };
}
case 'match': {
const answer = q.answer || {};
const given = userAnswer || {};
const keys = Object.keys(answer);
if (keys.length === 0) return { status: 'unanswered', correct: false, points: 0 };
const allOk = keys.every((k) => given[k] === answer[k]);
return { status: allOk ? 'correct' : 'wrong', correct: allOk, points: allOk ? (q.points || 1) : 0 };
}
case 'order': {
const ok = arraysEqualOrdered(userAnswer || [], q.answer || []);
return { status: ok ? 'correct' : 'wrong', correct: ok, points: ok ? (q.points || 1) : 0 };
}
case 'essay': {
// self-graded; default to ungraded
if (userAnswer && userAnswer.selfGrade != null) {
const g = userAnswer.selfGrade;
return { status: g >= 0.5 ? 'correct' : 'wrong', correct: g >= 0.5, points: Math.round((q.points || 1) * g) };
}
return { status: 'ungraded', correct: false, points: 0 };
}
default:
return { status: 'unanswered', correct: false, points: 0 };
}
};
// ---- time formatting ----
const formatTime = (ms) => {
if (ms == null || ms < 0) ms = 0;
const totalSec = Math.floor(ms / 1000);
const h = Math.floor(totalSec / 3600);
const m = Math.floor((totalSec % 3600) / 60);
const s = totalSec % 60;
const pad = (n) => String(n).padStart(2, '0');
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${pad(m)}:${pad(s)}`;
};
const formatDate = (ts) => {
const d = new Date(ts);
const now = new Date();
const diff = now - d;
if (diff < 60_000) return 'just now';
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
};
// ---- LaTeX rendering (safe wrapper around KaTeX) ----
// Renders inline $...$ and display $$...$$ within a plain string.
const renderLatex = (text) => {
if (!text) return '';
if (!window.katex) return escapeHtml(text);
const parts = [];
let rest = String(text);
// First handle $$...$$ (display)
const displayRe = /\$\$([\s\S]+?)\$\$/g;
rest = rest.replace(displayRe, (_, tex) => {
try {
return `<span class="katex-display-wrap">${window.katex.renderToString(tex, { displayMode: true, throwOnError: false })}</span>`;
} catch {
return `<code>${escapeHtml(tex)}</code>`;
}
});
// Then $...$ (inline)
const inlineRe = /\$([^$\n]+?)\$/g;
rest = rest.replace(inlineRe, (_, tex) => {
try {
return window.katex.renderToString(tex, { displayMode: false, throwOnError: false });
} catch {
return `<code>${escapeHtml(tex)}</code>`;
}
});
return rest;
};
const escapeHtml = (s) =>
String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
// Component that renders a string with LaTeX + preserves newlines as <br>
const Tex = ({ children, tag: Tag = 'span', className }) => {
const html = String(children || '').split('\n').map(renderLatex).join('<br/>');
return React.createElement(Tag, {
className,
dangerouslySetInnerHTML: { __html: html },
});
};
// ---- simple syntax highlighter (for code blocks) ----
const highlightCode = (code, lang) => {
const lines = escapeHtml(code);
const kw = /\b(function|return|if|else|const|let|var|for|while|import|export|from|class|new|this|def|print|in|not|and|or|True|False|None|null|undefined|true|false)\b/g;
const str = /(&quot;[^&]*?&quot;|&#39;[^&]*?&#39;|`[^`]*?`)/g;
const num = /\b(\d+(?:\.\d+)?)\b/g;
const com = /(\/\/[^\n]*|#[^\n]*)/g;
const fn = /\b([a-zA-Z_][a-zA-Z0-9_]*)\s*(?=\()/g;
return lines
.replace(com, '<span class="cm-comment">$1</span>')
.replace(str, '<span class="cm-string">$1</span>')
.replace(kw, '<span class="cm-keyword">$1</span>')
.replace(num, '<span class="cm-number">$1</span>')
.replace(fn, '<span class="cm-fn">$1</span>');
};
// ---- persistence ----
const STORAGE_KEY = 'brainwave.exam.state';
const HISTORY_KEY = 'brainwave.exam.history';
const loadState = () => {
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null'); } catch { return null; }
};
const saveState = (state) => {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } catch {}
};
const clearState = () => { try { localStorage.removeItem(STORAGE_KEY); } catch {} };
const loadHistory = () => {
try { return JSON.parse(localStorage.getItem(HISTORY_KEY) || '[]'); } catch { return []; }
};
const appendHistory = (entry) => {
try {
const hist = loadHistory();
hist.unshift(entry);
localStorage.setItem(HISTORY_KEY, JSON.stringify(hist.slice(0, 30)));
} catch {}
};
const clearHistory = () => { try { localStorage.removeItem(HISTORY_KEY); } catch {} };
// ---- validate exam JSON ----
const validateExam = (data) => {
if (!data || typeof data !== 'object') return 'Not a JSON object';
if (!data.title) return 'Missing "title"';
if (!Array.isArray(data.questions) || data.questions.length === 0) return 'Missing "questions" array';
for (const q of data.questions) {
if (!q.id) return `Question missing id`;
if (!q.type) return `Question "${q.id}" missing type`;
if (!q.prompt) return `Question "${q.id}" missing prompt`;
}
return null;
};
// Export to window
Object.assign(window, {
normalizeString, normalizeLatex,
arraysEqualUnordered, arraysEqualOrdered,
gradeQuestion, formatTime, formatDate,
renderLatex, escapeHtml, Tex, highlightCode,
loadState, saveState, clearState,
loadHistory, appendHistory, clearHistory,
validateExam,
});
@@ -0,0 +1,49 @@
# Practice Exam JSON Schema
Upload a JSON file with this shape to load an exam.
## Top-level
```json
{
"title": "string (required)",
"subtitle": "string (optional)",
"author": "string (optional)",
"timeLimitMinutes": 45, // null or number
"feedbackMode": "end" | "immediate",
"passingScore": 70, // percent
"categories": ["Topic 1", "Topic 2"],
"questions": [ /* see below */ ]
}
```
## Common question fields
```json
{
"id": "q1", // required, unique
"type": "multiple-choice", // see types below
"category": "Topic 1",
"points": 2, // default 1
"prompt": "Question text. LaTeX via $...$ and $$...$$",
"explanation": "Shown after grading",
"image": { "src": "url", "alt": "...", "caption": "..." },
"audio": { "src": "url" },
"code": { "language": "python", "content": "..." },
"table": { "headers": ["..."], "rows": [["..."]] },
"svg": "<svg>...</svg>"
}
```
## Question types
| type | answer shape |
|------|--------------|
| `multiple-choice` | `"a"` — single choice id |
| `multiple-select` | `["a", "c"]` — array of choice ids |
| `short-answer` | `"Paris"` with `acceptedForms`, `caseSensitive`, `ignorePunctuation`, `ignoreAccents` |
| `math-input` | LaTeX string; compared against `answer` + `acceptedForms` |
| `translation` | String; same options as short-answer plus `sourceLanguage`, `targetLanguage` |
| `essay` | No `answer`; optional `rubric` array + `minWords`. Self-graded. |
| `match` | `{ "l1": "r2", "l2": "r4" }` — left→right mapping |
| `order` | `["s1", "s2", "s3"]` — ordered item ids |
@@ -0,0 +1,110 @@
{
"title": "Calculus I — Midterm Practice",
"subtitle": "Limits, derivatives, and applications",
"author": "Dept. of Mathematics",
"timeLimitMinutes": 45,
"feedbackMode": "end",
"passingScore": 70,
"categories": ["Limits", "Derivatives", "Applications"],
"questions": [
{
"id": "q1",
"type": "multiple-choice",
"category": "Limits",
"points": 2,
"prompt": "Evaluate $\\displaystyle\\lim_{x \\to 0} \\frac{\\sin(3x)}{x}$.",
"choices": [
{ "id": "a", "text": "$0$" },
{ "id": "b", "text": "$1$" },
{ "id": "c", "text": "$3$" },
{ "id": "d", "text": "$\\infty$" }
],
"answer": "c",
"explanation": "Using the standard limit $\\lim_{u\\to 0}\\frac{\\sin u}{u}=1$ with $u=3x$ gives $3\\cdot 1 = 3$."
},
{
"id": "q2",
"type": "multiple-select",
"category": "Derivatives",
"points": 3,
"prompt": "Which of the following functions are differentiable at $x=0$? Select all that apply.",
"choices": [
{ "id": "a", "text": "$f(x) = |x|$" },
{ "id": "b", "text": "$f(x) = x^2 \\sin(1/x)$ with $f(0)=0$" },
{ "id": "c", "text": "$f(x) = \\sqrt{x}$" },
{ "id": "d", "text": "$f(x) = e^x$" }
],
"answer": ["b", "d"],
"explanation": "$|x|$ has a corner, $\\sqrt{x}$ is not defined for $x<0$. Both $x^2\\sin(1/x)$ (extended) and $e^x$ are differentiable at 0."
},
{
"id": "q3",
"type": "math-input",
"category": "Derivatives",
"points": 3,
"prompt": "Compute $\\dfrac{d}{dx}\\left[x^2 \\ln x\\right]$. Enter your answer in LaTeX.",
"answer": "2x\\ln x + x",
"acceptedForms": ["2x\\ln(x) + x", "x(2\\ln x + 1)", "x + 2x\\ln x"],
"explanation": "By the product rule: $2x\\ln x + x^2 \\cdot \\frac{1}{x} = 2x\\ln x + x$."
},
{
"id": "q4",
"type": "short-answer",
"category": "Limits",
"points": 2,
"prompt": "What is the value of $\\displaystyle\\lim_{x \\to \\infty}\\left(1 + \\frac{1}{x}\\right)^x$? Give the standard symbol.",
"answer": "e",
"acceptedForms": ["E", "e", " e "],
"caseSensitive": false,
"explanation": "This is the definition of Euler's number, $e \\approx 2.71828$."
},
{
"id": "q5",
"type": "order",
"category": "Applications",
"points": 3,
"prompt": "Order these steps to find the absolute maximum of $f$ on $[a,b]$ (first → last):",
"items": [
{ "id": "s1", "text": "Confirm $f$ is continuous on $[a,b]$" },
{ "id": "s2", "text": "Find critical points in $(a,b)$" },
{ "id": "s3", "text": "Evaluate $f$ at critical points and endpoints" },
{ "id": "s4", "text": "Compare values; the largest is the absolute max" }
],
"answer": ["s1", "s2", "s3", "s4"],
"explanation": "The Extreme Value Theorem requires continuity on a closed interval; then candidates are critical points and endpoints."
},
{
"id": "q6",
"type": "match",
"category": "Derivatives",
"points": 4,
"prompt": "Match each function to its derivative.",
"left": [
{ "id": "l1", "text": "$\\sin x$" },
{ "id": "l2", "text": "$\\tan x$" },
{ "id": "l3", "text": "$\\ln x$" },
{ "id": "l4", "text": "$e^{2x}$" }
],
"right": [
{ "id": "r1", "text": "$\\dfrac{1}{x}$" },
{ "id": "r2", "text": "$\\cos x$" },
{ "id": "r3", "text": "$2e^{2x}$" },
{ "id": "r4", "text": "$\\sec^2 x$" }
],
"answer": { "l1": "r2", "l2": "r4", "l3": "r1", "l4": "r3" }
},
{
"id": "q7",
"type": "essay",
"category": "Applications",
"points": 5,
"prompt": "Explain, in your own words, the Mean Value Theorem and give a geometric interpretation.",
"rubric": [
"States hypotheses (continuous on $[a,b]$, differentiable on $(a,b)$)",
"States conclusion: $\\exists c \\in (a,b)$ with $f'(c) = \\frac{f(b)-f(a)}{b-a}$",
"Explains geometric meaning (tangent parallel to secant)",
"Gives or references an example"
]
}
]
}
@@ -0,0 +1,80 @@
{
"title": "Mixed Demo — Every Question Type",
"subtitle": "A tour of every renderer in the platform",
"timeLimitMinutes": null,
"feedbackMode": "end",
"categories": ["Demo"],
"questions": [
{
"id": "q1",
"type": "multiple-choice",
"category": "Demo",
"points": 1,
"prompt": "Which of the following is the capital of Japan?",
"image": {
"src": "https://images.unsplash.com/photo-1540959733332-eab4deabeeaf?w=800",
"alt": "City skyline",
"caption": "A city skyline — can you identify it?"
},
"choices": [
{ "id": "a", "text": "Kyoto" },
{ "id": "b", "text": "Osaka" },
{ "id": "c", "text": "Tokyo" },
{ "id": "d", "text": "Hiroshima" }
],
"answer": "c",
"explanation": "Tokyo has been Japan's capital since 1868."
},
{
"id": "q2",
"type": "multiple-choice",
"category": "Demo",
"points": 2,
"prompt": "What does the following function return when called with `fib(6)`?",
"code": {
"language": "javascript",
"content": "function fib(n) {\n if (n < 2) return n;\n return fib(n - 1) + fib(n - 2);\n}"
},
"choices": [
{ "id": "a", "text": "5" },
{ "id": "b", "text": "8" },
{ "id": "c", "text": "13" },
{ "id": "d", "text": "6" }
],
"answer": "b",
"explanation": "The Fibonacci sequence is 0, 1, 1, 2, 3, 5, 8 — so fib(6) = 8."
},
{
"id": "q3",
"type": "short-answer",
"category": "Demo",
"points": 1,
"prompt": "In the data table below, which country had the highest GDP growth?",
"table": {
"headers": ["Country", "GDP Growth (%)", "Year"],
"rows": [
["Vietnam", "7.2", "2024"],
["India", "6.8", "2024"],
["Indonesia", "5.1", "2024"],
["Philippines", "5.9", "2024"]
]
},
"answer": "Vietnam",
"caseSensitive": false
},
{
"id": "q4",
"type": "essay",
"category": "Demo",
"points": 5,
"prompt": "Describe a time you solved a difficult problem. What approach did you take?",
"minWords": 50,
"rubric": [
"Specific situation (not generic)",
"Clear problem statement",
"Described methodology",
"Outcome or reflection"
]
}
]
}
@@ -0,0 +1,103 @@
{
"title": "Spanish A2 — Vocabulary & Grammar",
"subtitle": "Elementary level practice set",
"timeLimitMinutes": 20,
"feedbackMode": "immediate",
"categories": ["Vocabulary", "Grammar", "Listening"],
"questions": [
{
"id": "q1",
"type": "multiple-choice",
"category": "Grammar",
"points": 1,
"prompt": "Choose the correct form: _____ a la biblioteca todos los días.",
"choices": [
{ "id": "a", "text": "Voy" },
{ "id": "b", "text": "Va" },
{ "id": "c", "text": "Vamos" },
{ "id": "d", "text": "Van" }
],
"answer": "a",
"explanation": "First person singular of 'ir' (to go) is 'voy'."
},
{
"id": "q2",
"type": "translation",
"category": "Vocabulary",
"points": 2,
"sourceLanguage": "English",
"targetLanguage": "Spanish",
"prompt": "Translate: \"I would like a coffee with milk, please.\"",
"answer": "Me gustaría un café con leche, por favor.",
"acceptedForms": [
"Quisiera un café con leche, por favor",
"Me gustaria un cafe con leche por favor",
"Quiero un café con leche, por favor"
],
"caseSensitive": false,
"ignorePunctuation": true,
"ignoreAccents": true
},
{
"id": "q3",
"type": "match",
"category": "Vocabulary",
"points": 4,
"prompt": "Match each Spanish word to its English translation.",
"left": [
{ "id": "l1", "text": "perro" },
{ "id": "l2", "text": "árbol" },
{ "id": "l3", "text": "lluvia" },
{ "id": "l4", "text": "manzana" }
],
"right": [
{ "id": "r1", "text": "rain" },
{ "id": "r2", "text": "apple" },
{ "id": "r3", "text": "dog" },
{ "id": "r4", "text": "tree" }
],
"answer": { "l1": "r3", "l2": "r4", "l3": "r1", "l4": "r2" }
},
{
"id": "q4",
"type": "short-answer",
"category": "Grammar",
"points": 2,
"prompt": "Conjugate 'comer' in the first person plural, present tense.",
"answer": "comemos",
"caseSensitive": false,
"explanation": "Regular -er verb: com + emos = comemos ('we eat')."
},
{
"id": "q5",
"type": "multiple-select",
"category": "Grammar",
"points": 2,
"prompt": "Which of the following are feminine nouns? Select all that apply.",
"choices": [
{ "id": "a", "text": "la mesa" },
{ "id": "b", "text": "el libro" },
{ "id": "c", "text": "la casa" },
{ "id": "d", "text": "el perro" },
{ "id": "e", "text": "la ciudad" }
],
"answer": ["a", "c", "e"]
},
{
"id": "q6",
"type": "order",
"category": "Grammar",
"points": 3,
"prompt": "Arrange these words to form a grammatically correct sentence:",
"items": [
{ "id": "w1", "text": "Mi" },
{ "id": "w2", "text": "hermana" },
{ "id": "w3", "text": "estudia" },
{ "id": "w4", "text": "en" },
{ "id": "w5", "text": "la universidad" }
],
"answer": ["w1", "w2", "w3", "w4", "w5"],
"explanation": "Subject + verb + prepositional phrase: 'My sister studies at the university.'"
}
]
}
File diff suppressed because it is too large. Load diff
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+75
View File
@@ -0,0 +1,75 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is enabled on this template. See [this documentation](https://react.dev/learn/react-compiler) for more information.
Note: This will impact Vite dev & build performances.
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
+23
View File
@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])
+3644
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="46" fill="none" viewBox="0 0 48 46"><path fill="#863bff" d="M25.946 44.938c-.664.845-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.287c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.497 0-3.578-1.842-3.578H1.237c-.92 0-1.456-1.04-.92-1.788L10.013.474c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.579 1.842 3.579h11.377c.943 0 1.473 1.088.89 1.83L25.947 44.94z" style="fill:#863bff;fill:color(display-p3 .5252 .23 1);fill-opacity:1"/><mask id="a" width="48" height="46" x="0" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M25.842 44.938c-.664.844-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.183c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.498 0-3.579-1.842-3.579H1.133c-.92 0-1.456-1.04-.92-1.787L9.91.473c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.578 1.842 3.578h11.377c.943 0 1.473 1.088.89 1.832L25.843 44.94z" style="fill:#000;fill-opacity:1"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#ede6ff" rx="5.508" ry="14.704" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -4.47 31.516)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#ede6ff" rx="10.399" ry="29.851" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -39.328 7.883)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#7e14ff" rx="5.508" ry="30.487" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -25.913 -14.639)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -32.644 -3.334)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -34.34 30.47)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#ede6ff" rx="14.072" ry="22.078" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="rotate(93.35 24.506 48.493)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx=".387" cy="8.972" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(39.51 .387 8.972)"/></g><g filter="url(#k)"><ellipse cx="47.523" cy="-6.092" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 47.523 -6.092)"/></g><g filter="url(#l)"><ellipse cx="41.412" cy="6.333" fill="#47bfff" rx="5.971" ry="9.665" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 41.412 6.333)"/></g><g filter="url(#m)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#n)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#o)"><ellipse cx="35.651" cy="29.907" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 35.651 29.907)"/></g><g filter="url(#p)"><ellipse cx="38.418" cy="32.4" fill="#47bfff" rx="5.971" ry="15.297" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 38.418 32.4)"/></g></g><defs><filter id="b" width="60.045" height="41.654" x="-19.77" y="16.149" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-54.613" y="-7.533" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filtLine truncated

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"module": "esnext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}