Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .impeccable.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
## Design Context

### Users
FGA-track gemmology students and self-taught hobbyists working through identification curriculum. They sit with the article open during a focused study block (often evenings, often after-work fatigue). They want signal density without noise. Many will have a 10× loupe or a refractometer next to the screen — the screen is one instrument among many, not the centre of attention. A non-trivial fraction is colour-vision-impaired (gemmology disproportionately attracts people who train themselves around colour weakness).

### Brand Personality
**Scientifically clean and intriguing.** Three-word distillation: *precise, considered, alive*. The site is reference material that should feel handled — like a well-thumbed mineralogy textbook, the printed legend on a microscope eyepiece, or the engraved scale on a gemmologist's polariscope. Clean does not mean empty; intrigue does not mean decoration. The page must not feel dead.

### Aesthetic Direction
- **Typographic-led, not graphic-led.** Hierarchy comes from weight, size, leading, and a controlled accent palette — never from drop shadows, gradient fills, or icon decoration.
- **Quiet accent, sharp rhythm.** The existing `crystal:` (sky-blue) palette is the single chromatic anchor. `gem:` tokens are reserved for mineral-data contexts. Slate is the body neutral.
- **Instrument-label moments.** Where the interface acts (correct/wrong feedback, "Quick check" header, badges), it should read like an etched marking on a brass scale — confident, small, definite. Avoid SaaS-style coloured pills with rounded everything.
- **Light-first, dark-fluent.** Light is the default reading surface; dark mode must be a true mirror, not a partial inversion.

### Anti-references
- **Not Duolingo or gamified-flashcard energy.** No bouncing, no streak fire, no celebration glitter.
- **Not generic admin/SaaS.** No left-stripe-coloured callouts, no gradient hero text, no purple-to-blue accent gradients, no Inter, no Syne.
- **Not a "lab tech demo."** No glowing neon-on-near-black, no monospace as decoration. Monospace is reserved for actual readouts (counters, codes, formulae).
- **Not flat-card-grid templates.** Cards exist; they should not feel mass-produced.

### Theme & Constraints
- **Theme**: light + dark, both must be first-class. Dark is for late-evening study and must remain readable without reaching for a brightness slider.
- **Stack**: Tailwind v3 with `crystal:` and `gem:` palette extensions in `tailwind.config.mjs`; dark mode via `class` strategy on `<html>`.
- **Accessibility**: WCAG 2.2 AA non-negotiable; the site was just remediated. Maintain ≥ 4.5:1 contrast on body text, ≥ 3:1 on large/non-text. Do not regress.
- **Motion**: subtle, exponential easing (ease-out-quart/quint), under 200ms for state changes. Honour `prefers-reduced-motion`.
- **No emoji** in product UI. Icons come from Lucide React when needed.

### One-line aesthetic vow
*Restrained, precise, with one moment per surface that makes the eye pause.*
9 changes: 5 additions & 4 deletions src/components/quiz/study/LearnQuizWidget.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ describe('LearnQuizWidget', () => {
render(
<LearnQuizWidget slug="slug" pretestEnabled={true} questions={QUESTIONS} />,
);
expect(screen.getByText(/1 of 3/i)).toBeInTheDocument();
expect(screen.getByRole('complementary', { name: /question 1 of 3/i })).toBeInTheDocument();
});

it('renders answer options as radio buttons', () => {
Expand Down Expand Up @@ -197,7 +197,7 @@ describe('LearnQuizWidget', () => {
await answerQuestion(user, 'Cubic');
await user.click(screen.getByRole('button', { name: /next question/i }));
expect(screen.getByText(Q2.questionText)).toBeInTheDocument();
expect(screen.getByText(/2 of 3/i)).toBeInTheDocument();
expect(screen.getByRole('complementary', { name: /question 2 of 3/i })).toBeInTheDocument();
});

// ── Q3 and completion ─────────────────────────────────────────────────────
Expand Down Expand Up @@ -244,7 +244,8 @@ describe('LearnQuizWidget', () => {
await user.click(screen.getByRole('button', { name: /continue to article/i }));

// 2 correct out of 3
expect(screen.getByText(/2 of 3 correct/i)).toBeInTheDocument();
expect(screen.getByText(/pretest complete/i)).toBeInTheDocument();
expect(screen.getByText(/2\s*\/\s*3/)).toBeInTheDocument();
});

// ── Store integration ─────────────────────────────────────────────────────
Expand Down Expand Up @@ -295,7 +296,7 @@ describe('LearnQuizWidget', () => {
questions={[Q1, Q2, Q3, extraQ]}
/>,
);
expect(screen.getByText(/1 of 3/i)).toBeInTheDocument();
expect(screen.getByRole('complementary', { name: /question 1 of 3/i })).toBeInTheDocument();
});

// ── a11y ──────────────────────────────────────────────────────────────────
Expand Down
110 changes: 73 additions & 37 deletions src/components/quiz/study/LearnQuizWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,10 @@
*/

import { useState, useId } from 'react';
import { BookOpen, ChevronRight, CheckCircle2 } from 'lucide-react';
import { ChevronRight, CheckCircle2 } from 'lucide-react';
import type { StudyStore, Confidence } from '../../../lib/quiz/study-types';
import { Card, CardContent } from '../../ui/Card';
import { Button } from '../../ui/Button';
import { Badge } from '../../ui/Badge';
import { cn } from '../../ui/cn';
import { ConfidenceTap } from './ConfidenceTap';
import { RationalePanel } from './RationalePanel';
Expand Down Expand Up @@ -152,16 +151,22 @@ export function LearnQuizWidget({
className="my-6"
aria-label="Pretest complete"
>
<Card padding="md" className="border-crystal-200 dark:border-crystal-800 bg-crystal-50/30 dark:bg-crystal-950/20">
<Card
padding="md"
className="border-emerald-200 dark:border-emerald-900/50 bg-emerald-50/40 dark:bg-emerald-950/30"
>
<CardContent className="flex items-start gap-4">
<span aria-hidden="true">
<CheckCircle2 className="w-8 h-8 text-crystal-600 dark:text-crystal-400 flex-shrink-0 mt-0.5" />
<CheckCircle2 className="w-7 h-7 text-emerald-600 dark:text-emerald-400 flex-shrink-0 mt-0.5" />
</span>
<div>
<p className="font-semibold text-slate-800 dark:text-slate-200 text-sm">
Pretest complete — {correctCount} of {visibleQuestions.length} correct
<p className="font-semibold text-slate-900 dark:text-slate-100 text-sm tracking-tight">
Pretest complete
<span className="ml-2 font-mono text-xs font-normal text-emerald-700 dark:text-emerald-400">
{correctCount} / {visibleQuestions.length}
</span>
</p>
<p className="text-sm text-slate-600 dark:text-slate-400 mt-1">
<p className="text-sm text-slate-600 dark:text-slate-400 mt-1.5 leading-relaxed">
The article below covers each of these topics in depth. Pay particular
attention to any questions you got wrong — the pretesting effect makes
the content that follows measurably stickier.
Expand All @@ -188,33 +193,42 @@ export function LearnQuizWidget({

return (
<aside className="my-6" aria-label={`Pretest — question ${currentIndex + 1} of ${visibleQuestions.length}`}>
<Card padding="none" className="overflow-hidden border-crystal-200 dark:border-crystal-800">
{/* Widget header */}
<div className="px-5 py-3 bg-crystal-50 dark:bg-crystal-950/30 border-b border-crystal-200 dark:border-crystal-800 flex items-center justify-between">
<div className="flex items-center gap-2">
<BookOpen className="w-4 h-4 text-crystal-600 dark:text-crystal-400" aria-hidden="true" />
<span
id={legendId}
className="text-xs font-semibold uppercase tracking-wider text-crystal-700 dark:text-crystal-300"
>
Quick check — {currentIndex + 1} of {visibleQuestions.length}
<Card
padding="none"
className="overflow-hidden border-slate-200 dark:border-slate-800 dark:bg-slate-900"
>
{/* Widget header — instrument-label strip */}
<div className="px-5 py-2.5 bg-slate-900 dark:bg-slate-950 border-b border-slate-900 dark:border-slate-800 flex items-center justify-between">
<span
id={legendId}
className="flex items-baseline gap-2 text-sm font-semibold tracking-tight text-slate-50"
>
Quick check
<span aria-hidden="true" className="text-slate-600">/</span>
<span className="font-mono text-xs font-normal tabular-nums text-crystal-300">
{String(currentIndex + 1).padStart(2, '0')}
<span className="text-slate-600"> of </span>
{String(visibleQuestions.length).padStart(2, '0')}
</span>
</div>
<Badge variant="crystal" size="sm">Pretest</Badge>
</span>
<span className="inline-flex items-center text-[10px] font-medium uppercase tracking-[0.18em] text-crystal-200/90 border border-white/15 bg-white/5 rounded px-2 py-0.5">
Pretest
</span>
</div>

<CardContent className="px-5 py-4">
{/* Rationale teaser (not the testing effect spoiler) */}
<CardContent className="px-5 py-5">
{/* Helper line — guidance, not body */}
{currentIndex === 0 && !isSubmitted && (
<p className="text-sm text-slate-600 dark:text-slate-400 mb-3 italic">
<p className="text-sm text-slate-600 dark:text-slate-400 mb-4 leading-relaxed">
<span aria-hidden="true" className="text-crystal-500 mr-1.5">—</span>
Try these before reading. Getting them wrong here makes the article
that follows stick better in memory.
</p>
)}

{/* Question stem */}
<p
className="text-sm font-medium text-slate-800 dark:text-slate-200 leading-relaxed mb-3"
className="text-base font-medium text-slate-900 dark:text-slate-100 leading-relaxed mb-4 tracking-tight"
aria-live="polite"
>
{currentQuestion.questionText}
Expand All @@ -226,6 +240,13 @@ export function LearnQuizWidget({
const isSelected = selectedOption === opt;
const showCorrectness = isSubmitted;
const isThisCorrect = opt === currentQuestion.correctAnswer;
const letter = String.fromCharCode(65 + idx);

const isIdle = !showCorrectness && !isSelected;
const isPickedIdle = !showCorrectness && isSelected;
const isCorrectReveal = showCorrectness && isThisCorrect;
const isWrongChosen = showCorrectness && !isThisCorrect && isSelected;

return (
<button
key={idx}
Expand All @@ -235,26 +256,41 @@ export function LearnQuizWidget({
onClick={() => handleOptionSelect(opt)}
disabled={isSubmitted}
className={cn(
'w-full text-left px-4 py-2.5 rounded-lg text-sm border-2 transition-all duration-150',
'group w-full text-left pl-2 pr-4 py-2.5 rounded-lg text-sm border transition-all duration-150',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-crystal-500 focus-visible:ring-offset-2',
'disabled:pointer-events-none',
!showCorrectness && isSelected &&
'border-crystal-500 bg-crystal-50 text-crystal-700 dark:bg-crystal-950/40 dark:text-crystal-300 dark:border-crystal-500',
!showCorrectness && !isSelected &&
'border-slate-200 text-slate-700 hover:border-crystal-300 hover:bg-crystal-50/30 dark:border-slate-700 dark:text-slate-300 dark:hover:border-crystal-700',
showCorrectness && isThisCorrect &&
'border-emerald-500 bg-emerald-50 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-300',
showCorrectness && !isThisCorrect && isSelected &&
'border-red-400 bg-red-50 text-red-700 dark:bg-red-950/40 dark:text-red-300',
isIdle &&
'border-slate-200 text-slate-800 hover:border-slate-300 hover:bg-slate-50 dark:border-slate-800 dark:text-slate-200 dark:hover:border-slate-700 dark:hover:bg-slate-800/40',
isPickedIdle &&
'border-crystal-600 bg-crystal-50 text-crystal-900 ring-1 ring-crystal-600 dark:bg-crystal-950/40 dark:text-crystal-100 dark:border-crystal-500 dark:ring-crystal-500',
isCorrectReveal &&
'border-emerald-600 bg-emerald-50 text-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-100 dark:border-emerald-500',
isWrongChosen &&
'border-red-500 bg-red-50 text-red-900 dark:bg-red-950/40 dark:text-red-100 dark:border-red-500',
showCorrectness && !isThisCorrect && !isSelected &&
'border-slate-200 text-slate-600 dark:border-slate-700 dark:text-slate-400',
'border-slate-200 text-slate-500 dark:border-slate-800 dark:text-slate-500',
)}
>
<span className="inline-flex items-center gap-2">
<span className="font-mono text-xs opacity-50">
{String.fromCharCode(65 + idx)}.
<span className="inline-flex items-center gap-3">
<span
aria-hidden="true"
className={cn(
'inline-flex h-6 w-6 items-center justify-center rounded font-mono text-[11px] tracking-wider transition-colors duration-150 flex-shrink-0',
isIdle &&
'bg-slate-100 text-slate-600 group-hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-400 dark:group-hover:bg-slate-700',
isPickedIdle &&
'bg-crystal-600 text-white dark:bg-crystal-500',
isCorrectReveal &&
'bg-emerald-600 text-white dark:bg-emerald-500',
isWrongChosen &&
'bg-red-600 text-white dark:bg-red-500',
showCorrectness && !isThisCorrect && !isSelected &&
'bg-slate-100 text-slate-400 dark:bg-slate-800 dark:text-slate-600',
)}
>
{letter}
</span>
{opt}
<span>{opt}</span>
</span>
</button>
);
Expand Down
65 changes: 65 additions & 0 deletions src/components/quiz/study/RationalePanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -140,4 +140,69 @@ describe('RationalePanel', () => {
screen.getByRole('region', { name: /answer explanation/i }),
).toBeInTheDocument();
});

// ── Citation handling ──────────────────────────────────────────────────────

it('strips inline [ref:slug] markers from the main rationale', () => {
render(
<RationalePanel
correct={true}
rationaleCorrect="Tourmaline is trigonal [ref:read-gemmology-3e]. Confirmed by RI."
show={true}
/>,
);
expect(
screen.getByText('Tourmaline is trigonal. Confirmed by RI.'),
).toBeInTheDocument();
expect(screen.queryByText(/\[ref:/)).not.toBeInTheDocument();
});

it('strips inline [ref:slug] markers from option rationales', () => {
render(
<RationalePanel
correct={false}
rationaleCorrect="Here is why."
optionRationales={[
{ text: 'A', isCorrect: true, rationale: 'Anderson agrees [ref:anderson-gem-testing].' },
{ text: 'B', isCorrect: false, rationale: 'Plain text.' },
]}
userPickedIndex={1}
show={true}
/>,
);
expect(screen.getByText('Anderson agrees.')).toBeInTheDocument();
expect(screen.queryByText(/\[ref:/)).not.toBeInTheDocument();
});

it('renders a single deduped Sources footer collected across all rationales', () => {
render(
<RationalePanel
correct={false}
rationaleCorrect="Main [ref:read-gemmology-3e]."
optionRationales={[
{ text: 'A', isCorrect: true, rationale: 'one [ref:gubelin-koivula-vol1].' },
{ text: 'B', isCorrect: false, rationale: 'two [ref:read-gemmology-3e].' },
]}
userPickedIndex={1}
show={true}
/>,
);
const footer = screen.getByLabelText(/^sources$/i);
expect(footer).toHaveTextContent(/Read, Gemmology/);
expect(footer).toHaveTextContent(/Gübelin & Koivula, Photoatlas vol\. 1/);
// Deduped: "Read, Gemmology" must appear exactly once
const matches = footer.textContent?.match(/Read, Gemmology/g) ?? [];
expect(matches.length).toBe(1);
});

it('omits the Sources footer when no citations are present', () => {
render(
<RationalePanel
correct={true}
rationaleCorrect="Plain rationale with no refs."
show={true}
/>,
);
expect(screen.queryByLabelText(/^sources$/i)).not.toBeInTheDocument();
});
});
Loading
Loading