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
24 changes: 24 additions & 0 deletions docs/authoring-questions.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,30 @@ At least one other FGA-qualified reviewer should approve the rationale content b

---

## Fill-in-the-Blank Answer Matching

`fill-blank` items are scored by comparing the student's typed input against every string in
`acceptedAnswers`. Matching is **normalized** on both sides before comparison:

1. Leading/trailing whitespace is trimmed.
2. The string is Unicode-decomposed (NFKD) and combining diacritics are stripped (so `café`
and `cafe` are treated as equal).
3. The string is lowercased.
4. Runs of internal whitespace are collapsed to a single space.

This means a student does not need to match case, accents, or exact spacing to be marked
correct. It does **not** perform numeric-word equivalence — `"10"` and `"ten"` are treated as
different answers. If a fill-blank item has a numeral form and a spelled-out form that should
both be accepted, list both explicitly in `acceptedAnswers`:

```yaml
acceptedAnswers:
- "10"
- ten
```

---

## Quality Bar for Curated Items

> This section restates V1-PLAN.md §10 with additional worked examples.
Expand Down
26 changes: 26 additions & 0 deletions src/components/calculator/MeasurementTools.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,17 @@ import { DensityEstimator } from './DensityEstimator';
import { HannemanRI } from './HannemanRI';
import { ToolSection } from '../ui/ToolSection';

const JUMP_LINKS = [
{ id: 'sg', label: 'SG' },
{ id: 'ri-lookup', label: 'RI Lookup' },
{ id: 'birefringence', label: 'Birefringence' },
{ id: 'critical-angle', label: 'Critical Angle' },
{ id: 'dispersion', label: 'Dispersion' },
{ id: 'carat-estimate', label: 'Carat Estimator' },
{ id: 'density', label: 'Density' },
{ id: 'hanneman-ri', label: 'Hanneman RI' },
];

const ICON_PATHS = {
scale: 'M3 6l3 1m0 0l-3 9a5.002 5.002 0 006.001 0M6 7l3 9M6 7l6-2m6 2l3-1m-3 1l-3 9a5.002 5.002 0 006.001 0M18 7l3 9m-3-9l-6-2m0-2v2m0 16V5m0 16H9m3 0h3',
search: 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z',
Expand All @@ -26,6 +37,21 @@ const ICON_PATHS = {
export function MeasurementTools() {
return (
<div className="space-y-6">
<nav
aria-label="Jump to calculator"
className="flex gap-2 overflow-x-auto pb-1 -mx-1 px-1"
>
{JUMP_LINKS.map(({ id, label }) => (
<a
key={id}
href={`#tool-${id}`}
className="shrink-0 inline-flex items-center rounded-full border border-slate-300 bg-white px-3 py-1.5 text-sm font-medium text-slate-700 transition-colors hover:border-crystal-300 hover:bg-slate-50 hover:text-crystal-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-crystal-500 focus-visible:ring-offset-2"
>
{label}
</a>
))}
</nav>

<ToolSection
id="sg"
title="Specific Gravity"
Expand Down
10 changes: 5 additions & 5 deletions src/components/docs/Callout.astro
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ const icons = {
<style>
.callout {
@apply relative flex gap-4 my-6 p-5 rounded-xl;
@apply border-l-4;
@apply border;
}

.callout-icon svg {
Expand All @@ -59,22 +59,22 @@ const icons = {

/* Variants */
.callout-info {
@apply bg-crystal-50/50 border-crystal-400 text-crystal-900;
@apply bg-crystal-50 border-crystal-400 text-crystal-900;
}
.callout-info .callout-icon { @apply text-crystal-500; }

.callout-warning {
@apply bg-amber-50/50 border-amber-400 text-amber-900;
@apply bg-amber-50 border-amber-400 text-amber-900;
}
.callout-warning .callout-icon { @apply text-amber-500; }

.callout-tip {
@apply bg-emerald-50/50 border-emerald-400 text-emerald-900;
@apply bg-emerald-50 border-emerald-400 text-emerald-900;
}
.callout-tip .callout-icon { @apply text-emerald-500; }

.callout-error {
@apply bg-red-50/50 border-red-400 text-red-900;
@apply bg-red-50 border-red-400 text-red-900;
}
.callout-error .callout-icon { @apply text-red-500; }
</style>
25 changes: 19 additions & 6 deletions src/components/layout/Header.astro
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const currentPath = Astro.url.pathname;
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="flex h-16 items-center justify-between">
<!-- Logo -->
<a href="/" class="flex items-center gap-3 font-semibold text-slate-900 hover:text-crystal-700 transition-colors">
<a href="/" class="flex items-center gap-3 font-semibold text-slate-900 hover:text-crystal-700 transition-colors rounded-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-crystal-500 focus-visible:ring-offset-2">
<svg class="h-8 w-8 text-crystal-700" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16 2L28 10V22L16 30L4 22V10L16 2Z" stroke="currentColor" stroke-width="2" fill="none"/>
<path d="M16 2L16 30" stroke="currentColor" stroke-width="1.5" opacity="0.5"/>
Expand All @@ -35,6 +35,7 @@ const currentPath = Astro.url.pathname;
aria-current={isActive ? 'page' : undefined}
class:list={[
'relative px-4 py-2 rounded-lg text-sm font-medium transition-colors',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-crystal-500 focus-visible:ring-offset-2',
isActive
? 'bg-crystal-50 text-crystal-700 after:absolute after:left-3 after:right-3 after:-bottom-px after:h-0.5 after:bg-crystal-700 after:rounded-full'
: 'text-slate-600 hover:text-slate-900 hover:bg-slate-50',
Expand Down Expand Up @@ -63,8 +64,10 @@ const currentPath = Astro.url.pathname;
<!-- Mobile menu button -->
<button
type="button"
class="md:hidden p-2 text-slate-600 hover:text-slate-900 transition-colors"
class="md:hidden p-2 text-slate-600 hover:text-slate-900 transition-colors rounded-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-crystal-500 focus-visible:ring-offset-2"
aria-label="Toggle menu"
aria-expanded="false"
aria-controls="mobile-menu"
id="mobile-menu-button"
>
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
Expand All @@ -85,10 +88,11 @@ const currentPath = Astro.url.pathname;
href={href}
aria-current={isActive ? 'page' : undefined}
class:list={[
'block px-4 py-2 rounded-lg text-sm font-medium transition-colors border-l-4',
'relative block px-4 py-2 rounded-lg text-sm font-medium transition-colors',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-crystal-500 focus-visible:ring-offset-2',
isActive
? 'bg-crystal-50 text-crystal-700 border-crystal-700'
: 'border-transparent text-slate-600 hover:text-slate-900 hover:bg-slate-50',
? 'bg-crystal-50 text-crystal-700 after:absolute after:left-3 after:right-3 after:-bottom-px after:h-0.5 after:bg-crystal-700 after:rounded-full'
: 'text-slate-600 hover:text-slate-900 hover:bg-slate-50',
]}
>
{label}
Expand All @@ -104,6 +108,15 @@ const currentPath = Astro.url.pathname;
const mobileMenu = document.getElementById('mobile-menu');

menuButton?.addEventListener('click', () => {
mobileMenu?.classList.toggle('hidden');
const nowHidden = mobileMenu?.classList.toggle('hidden');
menuButton.setAttribute('aria-expanded', String(!nowHidden));
});

document.addEventListener('keydown', (event) => {
if (event.key !== 'Escape') return;
if (!mobileMenu || mobileMenu.classList.contains('hidden')) return;
mobileMenu.classList.add('hidden');
menuButton?.setAttribute('aria-expanded', 'false');
menuButton?.focus();
});
</script>
2 changes: 1 addition & 1 deletion src/components/learn/SectionRenderer.astro
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ const sectionId = section.id || section.title.toLowerCase().replace(/\s+/g, '-')

// Configure marked for safe rendering
marked.setOptions({
breaks: true,
breaks: false,
gfm: true,
});

Expand Down
70 changes: 70 additions & 0 deletions src/components/quiz/ConfirmDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* ConfirmDialog — shared modal confirmation dialog.
*
* Extracted from Exam.tsx's inline submit-confirmation modal so both Exam and
* Quiz can use the same pattern (e.g. Exam's "Submit Exam?" and Quiz's
* mid-quiz "Restart Quiz?" confirmations).
*/

import type { ReactNode } from 'react';
import { Button } from '../ui/Button';

interface ConfirmDialogProps {
/** Whether the dialog is visible. Renders nothing when false. */
open: boolean;
/** Dialog title. */
title: string;
/** Body content — freeform, rendered below the title. */
children?: ReactNode;
/** Label for the confirm (primary) button. */
confirmLabel: string;
/** Label for the cancel (secondary) button. */
cancelLabel: string;
/** Called when the user confirms. */
onConfirm: () => void;
/** Called when the user cancels (also fires on backdrop click). */
onCancel: () => void;
}

export function ConfirmDialog({
open,
title,
children,
confirmLabel,
cancelLabel,
onConfirm,
onCancel,
}: ConfirmDialogProps) {
if (!open) return null;

return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50"
onClick={onCancel}
role="presentation"
>
<div
className="bg-white rounded-xl shadow-xl max-w-md w-full p-6"
role="dialog"
aria-modal="true"
aria-labelledby="confirm-dialog-title"
onClick={(e) => e.stopPropagation()}
>
<h3 id="confirm-dialog-title" className="text-lg font-semibold text-slate-900">
{title}
</h3>
{children && (
<div className="mt-4 space-y-2 text-sm text-slate-600">{children}</div>
)}
<div className="mt-6 flex gap-3">
<Button variant="secondary" onClick={onCancel} className="flex-1">
{cancelLabel}
</Button>
<Button variant="primary" onClick={onConfirm} className="flex-1">
{confirmLabel}
</Button>
</div>
</div>
</div>
);
}
Loading
Loading