diff --git a/src/lib/components/layout/CheatSheet.svelte b/src/lib/components/layout/CheatSheet.svelte index 89e97d6..032c2ea 100644 --- a/src/lib/components/layout/CheatSheet.svelte +++ b/src/lib/components/layout/CheatSheet.svelte @@ -14,10 +14,13 @@ Eye, FileCode, FolderPlus, + Gamepad2, Globe, LifeBuoy, + ListFilter, Lock, Package, + Puzzle, Route, Scissors, Workflow, @@ -34,6 +37,8 @@ type CheatSheetCommand } from '$lib/data/cheat-sheet'; import { tokenizeShellCommand } from '$lib/data/bash-syntax'; + import { readingContext } from '$lib/ai/reading-context.svelte'; + import { exerciseFocusOf, rowUsesWords } from '$lib/playground/exercise-commands'; let { open = false, onToggle }: { open: boolean; onToggle: () => void } = $props(); @@ -73,12 +78,41 @@ 'life-buoy': LifeBuoy }; + /* ── exercise focus ────────────────────────────────────────────────── + When the learner is AT a playground or challenge — the scroll-spy + anchor resolves to one, or the panel opened on one — the sheet can + narrow itself to the commands that exercise actually reaches for. + The lightbulb-free ListFilter toggle in the toolbar turns it off and + on; it only appears while there is an exercise to focus on. */ + let focusEnabled = $state(true); + + const exercise = $derived(exerciseFocusOf(readingContext.scenarioId ?? readingContext.sectionId)); + + /** The sheet narrowed to the exercise's commands — null when that would + * leave nothing to show (an exercise whose commands the sheet lacks). */ + const focusedCategories = $derived.by(() => { + if (!exercise) return null; + const result: CheatSheetCategory[] = []; + for (const category of cheatSheet) { + const commands = category.commands.filter((cmd) => rowUsesWords(cmd.command, exercise.words)); + if (commands.length > 0) result.push({ ...category, commands }); + } + return result.length > 0 ? result : null; + }); + + const focusActive = $derived(focusEnabled && focusedCategories !== null); + const focusAccent = $derived( + exercise?.kind === 'challenge' ? 'var(--color-challenge)' : 'var(--color-important)' + ); + const FocusIcon = $derived(exercise?.kind === 'challenge' ? Puzzle : Gamepad2); + let filteredCategories = $derived.by(() => { + const base = focusActive && focusedCategories ? focusedCategories : cheatSheet; const query = searchQuery.toLowerCase().trim(); - if (!query) return cheatSheet; + if (!query) return base; const result: CheatSheetCategory[] = []; - for (const category of cheatSheet) { + for (const category of base) { const matchingCommands = category.commands.filter( (cmd) => cmd.command.toLowerCase().includes(query) || @@ -116,10 +150,57 @@ for (const category of filteredCategories) expandedCategories.add(category.label); } }); + + // A focused sheet is short; a collapsed category inside it hides half of + // an already-small kit. Expand what the focus surfaces (never collapse). + $effect(() => { + if (focusActive && focusedCategories) { + for (const category of focusedCategories) expandedCategories.add(category.label); + } + }); +{#snippet focusToggle()} + + {#if exercise && focusedCategories} + + {/if} +{/snippet} + +{#snippet focusStrip()} + + {#if focusActive && exercise} +
+ +

+ Commands for {exercise.title} +

+
+ {/if} +{/snippet} + {#snippet legend()} @@ -212,7 +293,7 @@