Skip to content

Commit 567f8cb

Browse files
jsollycursoragent
andauthored
Expand FAR capture curriculum to 198 questions (#19)
* feat(far): expand deck modules, fundamentals unit, and shadcn skill WIP before merging origin/main — split questions by unit, treat Fundamentals as a pie slice, and add project-local shadcn-svelte skill for Cursor/Codex/Claude Code. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(far): unlock on placement complete and split fundamentals deck Completing adaptive placement always opens the lifecycle chart; high scores still credit Basics. Collapse gate checks into isUnitLocked, improve gated home a11y, and house Fundamentals questions under deck/fundamentals topic modules. Co-authored-by: Cursor <cursoragent@cursor.com> * feat(far): expand capture learning curriculum Add the 198-question civilian capture curriculum and align the eight-slice learning flow. WIP — staged before merging origin/main. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(far): preserve placement and scoring integrity Defer test-out progress until completion and prevent an incorrect capture option from clearing its lesson. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 073e753 commit 567f8cb

40 files changed

Lines changed: 6184 additions & 1739 deletions

src/components/app/FarQuizApp.svelte

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,27 @@
11
<script lang="ts">
2+
import { tick } from "svelte";
3+
24
import { game } from "$lib/quiz-state.svelte.js";
35
import PieHome from "./PieHome.svelte";
46
import QuizView from "./QuizView.svelte";
57
import SummaryView from "./SummaryView.svelte";
68
79
let main = $state<HTMLElement | null>(null);
8-
// Move focus to the view container on each view swap so keyboard and screen
9-
// reader users aren't stranded on a detached node when the screen changes.
10+
// Child views focus their own descriptive headings. On the home transition,
11+
// focus its persistent heading instead of announcing an unnamed container.
1012
$effect(() => {
11-
game.view; // track the view so this re-runs on every transition
12-
main?.focus();
13+
const view = game.view;
14+
if (view !== "home") return;
15+
16+
void tick().then(() => {
17+
if (game.view === "home") {
18+
main?.querySelector<HTMLHeadingElement>("[data-home-heading]")?.focus();
19+
}
20+
});
1321
});
1422
</script>
1523

16-
<main bind:this={main} tabindex="-1" class="min-h-[100dvh] bg-background text-foreground outline-none">
24+
<main bind:this={main} class="min-h-[100dvh] bg-background text-foreground">
1725
{#if game.view === "session"}
1826
<QuizView />
1927
{:else if game.view === "summary"}

src/components/app/PieHome.svelte

Lines changed: 48 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,48 @@
11
<script lang="ts">
2+
import { tick } from "svelte";
3+
24
import { game } from "$lib/quiz-state.svelte.js";
3-
import { DIFFICULTY_LABEL } from "$lib/far/constants";
5+
import { DIFFICULTY_LABEL, TESTOUT_LENGTH, TESTOUT_PASS } from "$lib/far/constants";
46
import PieWheel from "./PieWheel.svelte";
57
import { Button } from "$lib/components/ui/button";
68
import { Badge } from "$lib/components/ui/badge";
79
810
let stats = $derived(game.allStats);
911
let gated = $derived(game.needsFundamentalsPlacement);
1012
let confirmReset = $state(false);
11-
let hubPercent = $derived(game.masteryPercent);
12-
let hubLabel = $derived("captured");
13-
let resetButtonEl: HTMLElement | null = $state(null);
1413
let confirmYesEl: HTMLElement | null = $state(null);
14+
let resetButtonEl: HTMLElement | null = $state(null);
15+
let pageHeadingEl: HTMLHeadingElement | null = null;
16+
17+
function capturePageHeading(element: HTMLHeadingElement) {
18+
pageHeadingEl = element;
19+
return () => {
20+
if (pageHeadingEl === element) pageHeadingEl = null;
21+
};
22+
}
1523
16-
$effect(() => {
17-
if (confirmReset) {
18-
queueMicrotask(() => confirmYesEl?.focus());
19-
}
20-
});
24+
async function finishResetConfirmation(reset: boolean) {
25+
if (reset) game.resetProgress();
26+
confirmReset = false;
27+
await tick();
28+
if (reset) pageHeadingEl?.focus();
29+
else resetButtonEl?.focus();
30+
}
2131
</script>
2232

2333
<div
2434
class="mx-auto w-full max-w-xl px-4 pb-16 pt-5 sm:max-w-2xl sm:px-6 sm:pb-20 sm:pt-7 lg:max-w-3xl lg:px-8 lg:pt-8"
2535
>
2636
<header class="flex items-center justify-between gap-3 sm:gap-5">
2737
<div>
28-
<h1 class="text-xl font-bold tracking-tight sm:text-2xl lg:text-[1.75rem]">Learn The FAR</h1>
38+
<h1
39+
{@attach capturePageHeading}
40+
data-home-heading
41+
tabindex="-1"
42+
class="rounded-sm text-xl font-bold tracking-tight focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-4 sm:text-2xl lg:text-[1.75rem]"
43+
>
44+
Learn The FAR
45+
</h1>
2946
{#if !gated}
3047
<p class="text-xs text-muted-foreground sm:text-sm">
3148
A fun way to learn the FAR, one scenario at a time.
@@ -43,25 +60,19 @@
4360
</header>
4461

4562
<div class="mt-4 sm:mt-6">
46-
<PieWheel
47-
{stats}
48-
{hubPercent}
49-
{hubLabel}
50-
locked={gated}
51-
onpick={(id) => game.startUnit(id)}
52-
/>
63+
<PieWheel {stats} locked={gated} onpick={(id) => game.startUnit(id)} />
5364
</div>
5465

5566
{#if gated}
5667
<section class="mt-4 rounded-2xl border-2 border-primary/30 bg-card p-4 sm:mt-6 sm:p-6">
5768
<h2 class="text-sm font-semibold leading-snug sm:text-lg">Unlock the deal lifecycle</h2>
5869
<p class="mt-1 text-xs leading-5 text-muted-foreground sm:mt-2 sm:text-sm sm:leading-6">
59-
Learn the Fundamentals at your pace, or take a short placement test to seed your capture
60-
chart.
70+
Learn the Fundamentals at your pace, or take a short {TESTOUT_LENGTH}-question test.
71+
Score {Math.round(TESTOUT_PASS * 100)}% or better to unlock every lifecycle slice.
6172
</p>
6273
{#if game.hasFundamentalsAttempt}
6374
<p class="mt-2 text-[0.65rem] tabular-nums text-muted-foreground sm:mt-3 sm:text-xs">
64-
{game.masteryPercent}% of the chart already filled from your answers
75+
{game.fundamentalsPercent}% of Fundamentals cleared
6576
</p>
6677
{/if}
6778
<div class="mt-4 grid gap-2 sm:mt-5 sm:grid-cols-2 sm:gap-3">
@@ -84,7 +95,7 @@
8495
class="w-full sm:h-11 sm:text-base"
8596
onclick={() => game.startTestOut()}
8697
>
87-
{game.hasFundamentalsAttempt ? "Continue placement" : "See what I know"}
98+
Take the Fundamentals test
8899
</Button>
89100
{#if game.hasFundamentalsAttempt && game.fundamentalsGaps.length > 0}
90101
<Button
@@ -150,8 +161,7 @@
150161
<div class="flex items-start gap-3 sm:gap-4">
151162
<span
152163
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl text-sm font-bold text-white sm:h-12 sm:w-12 sm:text-base"
153-
class:opacity-50={lifecycleLocked}
154-
style={`background:hsl(${s.unit.hue} 70% 52%)`}
164+
style={`background:hsl(${s.unit.hue} 70% 27%)`}
155165
>
156166
{Math.round(s.ratio * 100)}%
157167
</span>
@@ -213,24 +223,24 @@
213223
{#if game.hasProgress}
214224
<div class="mt-8 flex justify-center sm:mt-10">
215225
{#if confirmReset}
216-
<div class="flex items-center gap-2 text-sm">
217-
<span class="text-muted-foreground">Wipe all local progress?</span>
226+
<div
227+
role="group"
228+
aria-labelledby="reset-confirmation-question"
229+
class="flex flex-wrap items-center justify-center gap-2 text-sm"
230+
>
231+
<span id="reset-confirmation-question" class="w-full text-center text-muted-foreground">
232+
Wipe all local quiz progress, streaks, and achievements?
233+
</span>
218234
<Button
219235
bind:ref={confirmYesEl}
220236
variant="destructive"
221237
size="sm"
222-
onclick={() => {
223-
game.resetProgress();
224-
confirmReset = false;
225-
}}>Yes</Button
238+
onclick={() => finishResetConfirmation(true)}>Yes, wipe progress</Button
226239
>
227240
<Button
228241
variant="outline"
229242
size="sm"
230-
onclick={() => {
231-
confirmReset = false;
232-
queueMicrotask(() => resetButtonEl?.focus());
233-
}}>No</Button
243+
onclick={() => finishResetConfirmation(false)}>No, keep progress</Button
234244
>
235245
</div>
236246
{:else}
@@ -239,7 +249,11 @@
239249
variant="ghost"
240250
size="sm"
241251
class="text-muted-foreground"
242-
onclick={() => (confirmReset = true)}
252+
onclick={async () => {
253+
confirmReset = true;
254+
await tick();
255+
confirmYesEl?.focus();
256+
}}
243257
>
244258
Reset progress
245259
</Button>

src/components/app/PieWheel.svelte

Lines changed: 26 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,13 @@
1111
1212
type Props = {
1313
stats: UnitStats[];
14-
/** Center hub percentage (overall mastery). */
15-
hubPercent: number;
16-
hubLabel: string;
1714
/** When true, lifecycle slices are dimmed; Fundamentals stays selectable. */
1815
locked?: boolean;
1916
onpick: (unitId: UnitId) => void;
2017
};
2118
2219
type WheelOption = ComposeOption<PieSeriesOption | TooltipComponentOption>;
2320
24-
const INNER_RADIUS = 39;
2521
/** Leave room for emphasis.scaleSize so hover can grow without clipping. */
2622
const OUTER_RADIUS = 88;
2723
const PAD_ANGLE = 2;
@@ -31,18 +27,18 @@
3127
3228
use([PieChart, TooltipComponent, LabelLayout, SVGRenderer]);
3329
34-
let { stats, hubPercent, hubLabel, locked = false, onpick }: Props = $props();
30+
let { stats, locked = false, onpick }: Props = $props();
3531
3632
function clampRatio(ratio: number): number {
3733
return Math.min(1, Math.max(0, ratio));
3834
}
3935
40-
function sliceIsLocked(unitId: UnitId): boolean {
41-
return locked && unitId !== "fundamentals";
36+
function sliceIsLocked(unitId: UnitId, isLocked = locked): boolean {
37+
return isLocked && unitId !== "fundamentals";
4238
}
4339
44-
function sliceLabel(stat: UnitStats): string {
45-
return sliceIsLocked(stat.unit.id)
40+
function sliceLabel(stat: UnitStats, isLocked = locked): string {
41+
return sliceIsLocked(stat.unit.id, isLocked)
4642
? `${stat.unit.label}: complete Fundamentals to unlock`
4743
: `${stat.unit.label}: ${Math.round(clampRatio(stat.ratio) * 100)}% mastered`;
4844
}
@@ -67,7 +63,7 @@
6763
const track: PieSeriesOption = {
6864
...pieGeometry,
6965
id: TRACK_SERIES_ID,
70-
radius: [`${INNER_RADIUS}%`, `${OUTER_RADIUS}%`],
66+
radius: ["0%", `${OUTER_RADIUS}%`],
7167
// Keep hover live while locked; click handler gates lifecycle slices.
7268
silent: false,
7369
cursor: "pointer",
@@ -89,7 +85,7 @@
8985
itemStyle: { opacity: isLocked ? 0.2 : 0.35 },
9086
},
9187
data: currentStats.map((stat) => {
92-
const dimmed = sliceIsLocked(stat.unit.id);
88+
const dimmed = sliceIsLocked(stat.unit.id, isLocked);
9389
return {
9490
value: 1,
9591
name: stat.unit.label,
@@ -109,15 +105,13 @@
109105
(stat, progressIndex): PieSeriesOption[] => {
110106
const ratio = clampRatio(stat.ratio);
111107
if (ratio === 0) return [];
108+
const dimmed = sliceIsLocked(stat.unit.id, isLocked);
112109
113110
return [
114111
{
115112
...pieGeometry,
116113
id: `wheel-progress-${stat.unit.id}`,
117-
radius: [
118-
`${INNER_RADIUS}%`,
119-
`${INNER_RADIUS + (OUTER_RADIUS - INNER_RADIUS) * ratio}%`,
120-
],
114+
radius: ["0%", `${OUTER_RADIUS * ratio}%`],
121115
silent: true,
122116
z: 2,
123117
label: { show: false },
@@ -130,7 +124,7 @@
130124
itemStyle: {
131125
color:
132126
sliceIndex === progressIndex
133-
? `hsl(${stat.unit.hue}, 70%, 52%)`
127+
? `hsla(${stat.unit.hue}, 70%, 52%, ${dimmed ? 0.18 : 1})`
134128
: "transparent",
135129
},
136130
})),
@@ -142,7 +136,7 @@
142136
const labels: PieSeriesOption = {
143137
...pieGeometry,
144138
id: LABEL_SERIES_ID,
145-
radius: [`${INNER_RADIUS}%`, `${OUTER_RADIUS}%`],
139+
radius: ["0%", `${OUTER_RADIUS}%`],
146140
silent: true,
147141
z: 3,
148142
avoidLabelOverlap: false,
@@ -151,28 +145,23 @@
151145
show: true,
152146
position: "inside",
153147
formatter: "{b}",
148+
color: "#111827",
149+
backgroundColor: "#f9fafb",
150+
borderColor: "rgba(17, 24, 39, 0.35)",
151+
borderWidth: 1,
152+
borderRadius: 5,
153+
padding: [3, 6],
154154
fontFamily: "Inter Variable, Inter, sans-serif",
155155
fontSize: chartLabelFontSize(chartWidth),
156156
fontWeight: 600,
157157
},
158158
labelLayout: { hideOverlap: false },
159159
labelLine: { show: false },
160-
data: currentStats.map((stat) => {
161-
const progressCoversLabel = clampRatio(stat.ratio) >= 0.5;
162-
return {
163-
value: 1,
164-
name: stat.unit.label,
165-
itemStyle: { color: "transparent" },
166-
label: {
167-
color: progressCoversLabel ? "#fff" : `hsl(${stat.unit.hue}, 50%, 28%)`,
168-
opacity: isLocked ? 0.55 : 1,
169-
textBorderColor: progressCoversLabel
170-
? `hsl(${stat.unit.hue}, 70%, 36%)`
171-
: "var(--color-card)",
172-
textBorderWidth: 3,
173-
},
174-
};
175-
}),
160+
data: currentStats.map((stat) => ({
161+
value: 1,
162+
name: stat.unit.label,
163+
itemStyle: { color: "transparent" },
164+
})),
176165
};
177166
178167
return {
@@ -192,7 +181,7 @@
192181
return "";
193182
}
194183
const stat = currentStats[params.dataIndex];
195-
return stat ? sliceLabel(stat) : "";
184+
return stat ? sliceLabel(stat, isLocked) : "";
196185
},
197186
},
198187
series: [track, ...progress, labels],
@@ -240,28 +229,14 @@
240229
</script>
241230

242231
<div
243-
class={[
244-
"relative mx-auto aspect-square w-full max-w-[20rem] sm:max-w-[26rem] lg:max-w-[28rem]",
245-
locked && "opacity-80",
246-
]}
232+
class="relative mx-auto aspect-square w-full max-w-[20rem] sm:max-w-[26rem] lg:max-w-[28rem]"
247233
role="group"
248234
aria-label={locked
249-
? "Capture chart — Fundamentals is open; finish assessing to unlock the rest"
235+
? "Mastery wheel — Fundamentals is available; clear 80% or pass the test to unlock lifecycle slices"
250236
: "Mastery wheel"}
251237
>
252238
<div class="absolute inset-0 h-full w-full" aria-hidden="true" {@attach attachChart}></div>
253239

254-
<div
255-
class="pointer-events-none absolute left-1/2 top-1/2 z-10 flex aspect-square w-[39%] -translate-x-1/2 -translate-y-1/2 flex-col items-center justify-center rounded-full border-2 bg-card"
256-
style:border-color={locked ? "hsl(210 70% 52%)" : "var(--color-border)"}
257-
>
258-
<span class="text-2xl font-bold tabular-nums sm:text-3xl lg:text-4xl">{hubPercent}%</span>
259-
<span
260-
class="text-[0.65rem] uppercase tracking-wide text-muted-foreground sm:text-xs lg:text-sm"
261-
>{hubLabel}</span
262-
>
263-
</div>
264-
265240
<div class="pointer-events-none absolute inset-0 z-20">
266241
{#each stats as stat (stat.unit.id)}
267242
<button
@@ -290,6 +265,7 @@
290265
}
291266
292267
.slice-control:focus-visible {
268+
pointer-events: auto;
293269
left: 50%;
294270
bottom: 0.75rem;
295271
z-index: 1;

0 commit comments

Comments
 (0)