Skip to content

Commit b47035f

Browse files
jsollycursoragent
andauthored
feat(study): multi-chapter reference shelves and system dark mode (#23)
Replace soft-quiz study with browsable per-slice shelves (separate reading progress), and follow prefers-color-scheme for light/dark with no toggle. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent bd65ea2 commit b47035f

22 files changed

Lines changed: 4700 additions & 304 deletions
Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
<script lang="ts">
2+
import { tick } from "svelte";
3+
4+
import { game } from "$lib/quiz-state.svelte.js";
5+
import { Button } from "$lib/components/ui/button";
6+
import type { SourceKind } from "$lib/far/chapters/types";
7+
8+
let chapter = $derived(game.chapter);
9+
let headingEl: HTMLHeadingElement | null = $state(null);
10+
11+
$effect(() => {
12+
const current = chapter;
13+
if (!current) return;
14+
void tick().then(() => {
15+
if (game.chapter === current) headingEl?.focus();
16+
});
17+
});
18+
19+
function sourceKindLabel(kind: SourceKind): string {
20+
switch (kind) {
21+
case "controlling-authority":
22+
return "Controlling authority";
23+
case "guidance":
24+
return "Guidance";
25+
case "decision":
26+
return "Decision";
27+
case "capture-practice":
28+
return "Capture practice";
29+
}
30+
}
31+
32+
function backToShelfOrHome() {
33+
if (chapter && game.chapterKind === "shelf-chapter") {
34+
game.openShelf(chapter.unitId);
35+
return;
36+
}
37+
game.goHome();
38+
}
39+
</script>
40+
41+
{#if chapter}
42+
<div class="mx-auto flex min-h-[100dvh] w-full max-w-2xl flex-col px-4 pb-16 pt-5 sm:px-6 sm:pt-7">
43+
<header class="mb-6 sm:mb-8">
44+
<button
45+
type="button"
46+
class="mb-3 text-sm text-muted-foreground underline-offset-4 hover:underline"
47+
onclick={backToShelfOrHome}
48+
>
49+
← {game.chapterKind === "shelf-chapter" ? "Back to shelf" : "Back"}
50+
</button>
51+
<p class="text-xs font-semibold uppercase tracking-wide text-muted-foreground sm:text-sm">
52+
{chapter.subtitle}
53+
</p>
54+
<h1
55+
bind:this={headingEl}
56+
tabindex="-1"
57+
class="mt-1 text-2xl font-bold tracking-tight outline-none sm:text-3xl"
58+
>
59+
{chapter.title}
60+
</h1>
61+
<p class="mt-3 text-sm leading-6 text-muted-foreground sm:text-base sm:leading-7">
62+
{chapter.intro}
63+
</p>
64+
{#if chapter.pieces.length > 1}
65+
<nav class="mt-5" aria-label="On this page">
66+
<p class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
67+
On this page
68+
</p>
69+
<ol class="mt-2 list-decimal space-y-1 pl-5 text-sm">
70+
{#each chapter.pieces as piece (piece.id)}
71+
<li>
72+
<a
73+
class="text-primary underline-offset-4 hover:underline"
74+
href={`#piece-${piece.id}`}>{piece.title}</a
75+
>
76+
</li>
77+
{/each}
78+
</ol>
79+
</nav>
80+
{/if}
81+
</header>
82+
83+
<div class="flex flex-col gap-10 sm:gap-12">
84+
{#each chapter.pieces as piece, index (piece.id)}
85+
<article class="scroll-mt-6">
86+
<p class="text-xs font-semibold tabular-nums text-muted-foreground">
87+
{index + 1} / {chapter.pieces.length}
88+
</p>
89+
<h2
90+
id={`piece-${piece.id}`}
91+
tabindex="-1"
92+
class="mt-1 scroll-mt-6 text-xl font-semibold tracking-tight outline-none sm:text-2xl"
93+
>
94+
{piece.title}
95+
</h2>
96+
97+
{#if piece.story}
98+
<p class="mt-3 text-sm leading-6 text-foreground/90 sm:text-base sm:leading-7">
99+
{piece.story}
100+
</p>
101+
{/if}
102+
103+
<dl class="mt-5 space-y-4">
104+
<div>
105+
<dt class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
106+
What it is
107+
</dt>
108+
<dd class="mt-1 text-sm leading-6 sm:text-base sm:leading-7">{piece.is}</dd>
109+
</div>
110+
<div>
111+
<dt class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
112+
What it is not
113+
</dt>
114+
<dd class="mt-1 text-sm leading-6 sm:text-base sm:leading-7">{piece.isNot}</dd>
115+
</div>
116+
<div>
117+
<dt class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
118+
Where it fits
119+
</dt>
120+
<dd class="mt-1 text-sm leading-6 sm:text-base sm:leading-7">{piece.fits}</dd>
121+
</div>
122+
</dl>
123+
124+
{#if piece.judgment}
125+
<p class="mt-4 text-sm leading-6 sm:text-base sm:leading-7">
126+
<span class="font-semibold">Judgment: </span>{piece.judgment}
127+
</p>
128+
{/if}
129+
130+
{#if piece.checklist && piece.checklist.length > 0}
131+
<div class="mt-4">
132+
<p class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
133+
Field checklist
134+
</p>
135+
<ul class="mt-2 list-disc space-y-1 pl-5 text-sm leading-6 sm:text-base sm:leading-7">
136+
{#each piece.checklist as item (item)}
137+
<li>{item}</li>
138+
{/each}
139+
</ul>
140+
</div>
141+
{/if}
142+
143+
{#if piece.quote}
144+
<blockquote
145+
class="mt-5 border-l-4 border-primary/40 bg-muted/40 px-4 py-3 text-sm leading-6 sm:text-[0.95rem] sm:leading-7"
146+
>
147+
<p class="italic text-foreground/95">“{piece.quote.text}”</p>
148+
<footer class="mt-2 not-italic">
149+
<a
150+
class="text-xs font-medium text-primary underline-offset-4 hover:underline sm:text-sm"
151+
href={piece.quote.sourceUrl}
152+
target="_blank"
153+
rel="noopener noreferrer"
154+
>
155+
{piece.quote.citation}
156+
</a>
157+
<span class="text-xs text-muted-foreground"> · public domain</span>
158+
</footer>
159+
</blockquote>
160+
{:else if piece.sourceUrl && piece.citation}
161+
<p class="mt-4 text-xs sm:text-sm">
162+
<a
163+
class="font-medium text-primary underline-offset-4 hover:underline"
164+
href={piece.sourceUrl}
165+
target="_blank"
166+
rel="noopener noreferrer"
167+
>
168+
{piece.citation}
169+
</a>
170+
{#if piece.sourceKind}
171+
<span class="text-muted-foreground">
172+
· {sourceKindLabel(piece.sourceKind)}</span
173+
>
174+
{/if}
175+
</p>
176+
{/if}
177+
</article>
178+
{/each}
179+
</div>
180+
181+
{#if chapter.furtherReading && chapter.furtherReading.length > 0}
182+
<section class="mt-10 border-t pt-6 sm:mt-12">
183+
<h2 class="text-sm font-semibold sm:text-base">Further reading</h2>
184+
<ul class="mt-3 space-y-2 text-sm">
185+
{#each chapter.furtherReading as link (link.url + link.label)}
186+
<li>
187+
<a
188+
class="font-medium text-primary underline-offset-4 hover:underline"
189+
href={link.url}
190+
target="_blank"
191+
rel="noopener noreferrer"
192+
>
193+
{link.label}
194+
</a>
195+
<span class="text-muted-foreground"> · {sourceKindLabel(link.kind)}</span>
196+
</li>
197+
{/each}
198+
</ul>
199+
</section>
200+
{/if}
201+
202+
<section class="mt-10 border-t pt-8 sm:mt-12">
203+
<p class="text-sm leading-6 text-muted-foreground sm:text-base sm:leading-7">{chapter.closing}</p>
204+
<div class="mt-6 flex flex-col gap-2 sm:flex-row sm:items-center">
205+
<Button
206+
size="lg"
207+
class="w-full sm:h-11 sm:flex-1 sm:text-base"
208+
onclick={() => game.runChapterQuizAction(chapter.quizCta.action)}
209+
>
210+
{chapter.quizCta.label}
211+
</Button>
212+
{#if game.chapterKind === "shelf-chapter"}
213+
<Button
214+
size="lg"
215+
variant="outline"
216+
class="w-full sm:h-11 sm:w-auto sm:text-base"
217+
onclick={() => game.markCurrentChapterRead()}
218+
>
219+
{game.isChapterRead(chapter.id) ? "Marked read" : "Mark as read"}
220+
</Button>
221+
<Button
222+
size="lg"
223+
variant="ghost"
224+
class="w-full sm:h-11 sm:w-auto sm:text-base"
225+
onclick={() => game.openShelf(chapter.unitId)}
226+
>
227+
Back to shelf
228+
</Button>
229+
{:else}
230+
<Button
231+
size="lg"
232+
variant="outline"
233+
class="w-full sm:h-11 sm:w-auto sm:text-base"
234+
onclick={() => game.goHome()}
235+
>
236+
Done reading
237+
</Button>
238+
{/if}
239+
</div>
240+
</section>
241+
</div>
242+
{/if}

src/components/app/FarQuizApp.svelte

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
import PieHome from "./PieHome.svelte";
66
import QuizView from "./QuizView.svelte";
77
import SummaryView from "./SummaryView.svelte";
8+
import ChapterView from "./ChapterView.svelte";
9+
import ShelfView from "./ShelfView.svelte";
810
911
let main = $state<HTMLElement | null>(null);
1012
// Child views focus their own descriptive headings. On the home transition,
@@ -26,6 +28,10 @@
2628
<QuizView />
2729
{:else if game.view === "summary"}
2830
<SummaryView />
31+
{:else if game.view === "shelf"}
32+
<ShelfView />
33+
{:else if game.view === "chapter"}
34+
<ChapterView />
2935
{:else}
3036
<PieHome />
3137
{/if}

src/components/app/PieHome.svelte

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -67,25 +67,30 @@
6767
<section class="mt-4 rounded-2xl border-2 border-primary/30 bg-card p-4 sm:mt-6 sm:p-6">
6868
<h2 class="text-sm font-semibold leading-snug sm:text-lg">Unlock the deal lifecycle</h2>
6969
<p class="mt-1 text-xs leading-5 text-muted-foreground sm:mt-2 sm:text-sm sm:leading-6">
70-
Learn the Basics 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.
70+
Read the Basics shelf for the map, then check yourself with a quiz or the short
71+
{TESTOUT_LENGTH}-question test. Score {Math.round(TESTOUT_PASS * 100)}% or better to unlock
72+
every lifecycle slice. Reading never clears the pie on its own.
7273
</p>
7374
{#if game.hasFundamentalsAttempt}
7475
<p class="mt-2 text-[0.65rem] tabular-nums text-muted-foreground sm:mt-3 sm:text-xs">
7576
{game.fundamentalsPercent}% of Basics cleared
7677
</p>
7778
{/if}
7879
<div class="mt-4 grid gap-2 sm:mt-5 sm:grid-cols-2 sm:gap-3">
79-
<Button size="lg" class="sm:h-11 sm:text-base" onclick={() => game.startUnit("fundamentals")}>
80-
Quiz Basics
80+
<Button
81+
size="lg"
82+
class="sm:h-11 sm:text-base"
83+
onclick={() => game.openShelf("fundamentals")}
84+
>
85+
Read Basics shelf
8186
</Button>
8287
<Button
8388
size="lg"
8489
variant="outline"
8590
class="sm:h-11 sm:text-base"
86-
onclick={() => game.startStudyUnit("fundamentals")}
91+
onclick={() => game.startUnit("fundamentals")}
8792
>
88-
Study Basics
93+
Quiz Basics
8994
</Button>
9095
</div>
9196
<div class="mt-2 flex flex-col gap-2 sm:mt-3 sm:gap-3">
@@ -104,7 +109,7 @@
104109
class="w-full sm:h-11 sm:text-base"
105110
onclick={() => game.startStudyFundamentalsGaps()}
106111
>
107-
Review what you missed
112+
Here’s the missing picture
108113
</Button>
109114
{/if}
110115
</div>
@@ -137,10 +142,10 @@
137142
>
138143
<span class="text-2xl sm:text-3xl" aria-hidden="true">📖</span>
139144
<div class="min-w-0 flex-1">
140-
<p class="font-semibold leading-tight sm:text-lg">Study what you need</p>
145+
<p class="font-semibold leading-tight sm:text-lg">Here’s the missing picture</p>
141146
<p class="text-xs text-muted-foreground sm:text-sm">
142-
{game.shakyQuestions.length} question{game.shakyQuestions.length === 1 ? "" : "s"}
143-
selected for you
147+
{game.shakyQuestions.length} concept{game.shakyQuestions.length === 1 ? "" : "s"} to
148+
re-read — not a soft quiz
144149
</p>
145150
</div>
146151
<span class="shrink-0 text-lg text-muted-foreground sm:text-xl" aria-hidden="true">›</span>
@@ -160,7 +165,7 @@
160165
<div
161166
class={`rounded-2xl border-2 p-3 sm:p-4 lg:p-5 ${
162167
lifecycleLocked
163-
? "border-border/50 bg-muted/40 opacity-60"
168+
? "border-border/50 bg-muted/40"
164169
: "border-border bg-card"
165170
}`}
166171
aria-disabled={lifecycleLocked ? "true" : undefined}
@@ -236,9 +241,9 @@
236241
size="sm"
237242
variant="outline"
238243
disabled={lifecycleLocked}
239-
onclick={() => game.startStudyUnit(s.unit.id)}
244+
onclick={() => game.openShelf(s.unit.id)}
240245
>
241-
Study this
246+
Browse shelf
242247
</Button>
243248
</div>
244249
</div>
@@ -254,7 +259,7 @@
254259
class="flex flex-wrap items-center justify-center gap-2 text-sm"
255260
>
256261
<span id="reset-confirmation-question" class="w-full text-center text-muted-foreground">
257-
Wipe all local quiz progress, streaks, and achievements?
262+
Wipe all local quiz progress, streaks, achievements, and reading marks?
258263
</span>
259264
<Button
260265
bind:ref={confirmYesEl}

0 commit comments

Comments
 (0)