- {#if filteredCategories.length > 0}
+ {#if filteredCategories.length > 0 && !focusActive}
{@render legend()}
{/if}
{#each filteredCategories as category (category.label)}
@@ -349,9 +435,10 @@
class="text-xs font-semibold tracking-wider uppercase"
style="color: var(--color-text-muted); letter-spacing: 0.08em;"
>
- Terminal Cheat Sheet
+ Cheat Sheet
- {#if filteredCategories.length > 0}
+ {#if filteredCategories.length > 0 && !focusActive}
{@render legend()}
{/if}
diff --git a/src/lib/playground/exercise-commands.test.ts b/src/lib/playground/exercise-commands.test.ts
new file mode 100644
index 0000000..73a149e
--- /dev/null
+++ b/src/lib/playground/exercise-commands.test.ts
@@ -0,0 +1,61 @@
+import { describe, expect, it } from 'vitest';
+import { commandWordsOf, exerciseFocusOf, rowUsesWords } from './exercise-commands';
+
+describe('commandWordsOf', () => {
+ it('takes the first word of a simple command', () => {
+ expect(commandWordsOf('ls -la')).toEqual(['ls']);
+ });
+
+ it('yields one word per pipeline segment', () => {
+ expect(commandWordsOf('cat access.log | sort | uniq -c')).toEqual(['cat', 'sort', 'uniq']);
+ });
+
+ it('unwraps sudo, keeping both words', () => {
+ expect(commandWordsOf('sudo chown neo notes.txt')).toEqual(['sudo', 'chown']);
+ });
+
+ it('skips VAR=value prefixes', () => {
+ expect(commandWordsOf('PORT=3000 node server.js')).toEqual(['node']);
+ });
+
+ it('does not split on pipes inside quotes', () => {
+ expect(commandWordsOf("sed 's/a|b/c/' notes.txt")).toEqual(['sed']);
+ });
+});
+
+describe('exerciseFocusOf', () => {
+ it('resolves a playground anchor to its suggested command words', () => {
+ const focus = exerciseFocusOf('first-steps');
+ expect(focus?.kind).toBe('playground');
+ expect(focus?.title).toBe('Say hello to the machine');
+ for (const word of ['whoami', 'pwd', 'date', 'echo']) {
+ expect(focus?.words.has(word)).toBe(true);
+ }
+ });
+
+ it('resolves a challenge anchor to its pool command words', () => {
+ const focus = exerciseFocusOf('ch-3-after-the-agent');
+ expect(focus?.kind).toBe('challenge');
+ expect(focus?.words.size).toBeGreaterThan(0);
+ });
+
+ it('returns null for ordinary sections and null input', () => {
+ expect(exerciseFocusOf('section-3-2')).toBeNull();
+ expect(exerciseFocusOf('hero')).toBeNull();
+ expect(exerciseFocusOf(null)).toBeNull();
+ });
+});
+
+describe('rowUsesWords', () => {
+ const words = new Set(['ls', 'grep']);
+
+ it('matches a row whose command word is in the set', () => {
+ expect(rowUsesWords('ls -a', words)).toBe(true);
+ expect(rowUsesWords('grep -r "" .', words)).toBe(true);
+ });
+
+ it('rejects rows outside the set, including key chords', () => {
+ expect(rowUsesWords('mkdir ', words)).toBe(false);
+ expect(rowUsesWords('Ctrl+C', words)).toBe(false);
+ });
+});
diff --git a/src/lib/playground/exercise-commands.ts b/src/lib/playground/exercise-commands.ts
new file mode 100644
index 0000000..cbd787b
--- /dev/null
+++ b/src/lib/playground/exercise-commands.ts
@@ -0,0 +1,76 @@
+/**
+ * Which commands does the exercise under the learner's cursor reach for?
+ *
+ * The cheat sheet's focus filter reads this. The scroll-spy anchor of every
+ * inline activity IS its scenario id (LessonActivity passes the same string
+ * to both props), and a challenge's anchor IS its challenge id — so one
+ * lookup against both registries resolves "where the learner is" into an
+ * exercise. Playgrounds contribute their `suggestedCommands` walkthrough;
+ * challenges contribute the whole kit, distractors included — the cheat
+ * sheet explains what each command does, which is exactly the audit a salted
+ * pool invites, and it never reveals which pool entries are the solution.
+ */
+import { playgroundScenarios } from './scenarios';
+import { allChallenges } from './challenges';
+import { commandWordOf, splitSegments } from './challenge-parsing';
+
+export interface ExerciseFocus {
+ /** Anchor/scenario id ('tidy-up', 'ch-3-after-the-agent'). */
+ id: string;
+ title: string;
+ kind: 'playground' | 'challenge';
+ /** Command words the exercise's command lines use ('ls', 'grep', …). */
+ words: ReadonlySet;
+}
+
+/**
+ * Every command word on one command line: one per pipeline segment, with a
+ * `sudo` prefix contributing both itself and the command it wraps.
+ */
+export function commandWordsOf(line: string): string[] {
+ const words: string[] = [];
+ for (const segment of splitSegments(line)) {
+ let word = commandWordOf(segment);
+ if (word === 'sudo') {
+ words.push('sudo');
+ word = commandWordOf(segment.replace(/^\s*sudo\s+/, ''));
+ }
+ if (word) words.push(word);
+ }
+ return words;
+}
+
+function toFocus(
+ id: string,
+ title: string,
+ kind: ExerciseFocus['kind'],
+ lines: readonly string[]
+): ExerciseFocus {
+ const words = new Set();
+ for (const line of lines) for (const word of commandWordsOf(line)) words.add(word);
+ return { id, title, kind, words };
+}
+
+/** The exercise an anchor id points at, or null for ordinary sections. */
+export function exerciseFocusOf(anchorId: string | null): ExerciseFocus | null {
+ if (!anchorId) return null;
+ const challenge = allChallenges.find((c) => c.id === anchorId);
+ if (challenge) {
+ return toFocus(
+ challenge.id,
+ challenge.title,
+ 'challenge',
+ challenge.pool.map((entry) => entry.command)
+ );
+ }
+ const scenario = playgroundScenarios.find((s) => s.id === anchorId);
+ if (scenario) {
+ return toFocus(scenario.id, scenario.title, 'playground', scenario.suggestedCommands);
+ }
+ return null;
+}
+
+/** Does a cheat-sheet row's command column use any of the exercise's words? */
+export function rowUsesWords(rowCommand: string, words: ReadonlySet): boolean {
+ return commandWordsOf(rowCommand).some((word) => words.has(word));
+}
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte
index 5ffcb93..f6195ee 100644
--- a/src/routes/+page.svelte
+++ b/src/routes/+page.svelte
@@ -274,33 +274,36 @@
}
// The three header panels are mutually exclusive: opening one closes
- // the others. Agent/Playground additionally enter desktop "reading
- // mode": the sidebar auto-collapses and the content reflows beside the
- // panel — the sidebar's prior state is restored when both close.
+ // the others. Each enters desktop "reading mode": the sidebar
+ // auto-collapses and the content reflows beside the panel — the
+ // sidebar's prior state is restored when all of them are closed.
let sidebarBeforePanel = false;
/** Call BEFORE mutating any open flags when a side panel is opening. */
function enterReadingMode() {
- if (!playgroundOpen && !agentOpen) {
+ if (!playgroundOpen && !agentOpen && !cheatSheetOpen) {
sidebarBeforePanel = sidebarOpen;
sidebarOpen = false;
}
}
- /** Call AFTER mutating flags — restores the sidebar once both are closed. */
+ /** Call AFTER mutating flags — restores the sidebar once all are closed. */
function maybeLeaveReadingMode() {
- if (!playgroundOpen && !agentOpen) {
+ if (!playgroundOpen && !agentOpen && !cheatSheetOpen) {
sidebarOpen = sidebarBeforePanel;
}
}
function toggleCheatSheet() {
if (!cheatSheetOpen) {
+ enterReadingMode();
playgroundOpen = false;
agentOpen = false;
+ cheatSheetOpen = true;
+ } else {
+ cheatSheetOpen = false;
maybeLeaveReadingMode();
}
- cheatSheetOpen = !cheatSheetOpen;
}
function togglePlayground() {
@@ -431,7 +434,8 @@