Skip to content

Commit 0e00055

Browse files
kubafloCopilotPureWeen
authored
feat: improve interactive tutorial with progress tracking, tips, and keyboard nav (#576)
## Summary Enhances the existing interactive tutorial (from PR #178) with better UX, progress tracking, and visual polish. ## Changes ### Tutorial Overlay - **Step progress dots** — visual dots showing current position within a chapter (active/done/upcoming) - **Overall progress bar** — thin bar at the bottom tracking global step position - **Keyboard navigation** — Arrow keys (left/right) to navigate, Escape to close - **Slide animations** — left/right transition between steps - **Tip callouts** — contextual hints with lightbulb icon and accent-colored left border - **Better visual hierarchy** — chapter name in accent color, chapter counter, SVG close button ### Tutorial Page - **Overall progress section** — gradient progress bar with chapter completion count - **Numbered chapter cards** — chapter numbers in circular badges - **Smart "Continue Tour" button** — resumes from first incomplete chapter instead of restarting - **"Replay Full Tour"** — shown when all chapters are completed - **Keyboard shortcut hint** — footer note about arrow keys and Escape - **Responsive layout** — single-column grid on mobile (\<640px) ### First-Launch Experience - **Tutorial nudge banner** — shows on empty dashboard for new users - **Dismissible** — persisted via `HasSeenTutorialPrompt` in UiState ### Model / Service - **`TutorialStep.Tip`** — optional contextual hint property - **`TutorialService` progress properties** — `TotalChapters`, `TotalSteps`, `GlobalStepIndex`, `OverallProgressPercent`, `CompletedChapterCount` - **`UiState.HasSeenTutorialPrompt`** — persists nudge dismissal ### Tests - 7 new tests: `TotalChapters`, `TotalSteps`, `GlobalStepIndex`, `OverallProgressPercent` (zero/partial/full), `CompletedChapterCount`, `TipsAreNotEmpty` - All 3331 tests pass (0 failures) --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Shane Neuville <shneuvil@microsoft.com>
1 parent 3ce680c commit 0e00055

11 files changed

Lines changed: 570 additions & 48 deletions

PolyPilot.Tests/TutorialServiceTests.cs

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,87 @@ public void OnStateChanged_FiresOnStartAndNext()
164164

165165
Assert.Equal(3, count);
166166
}
167+
168+
[Fact]
169+
public void TotalChapters_ReturnsExpectedCount()
170+
{
171+
var svc = new TutorialService();
172+
Assert.Equal(TutorialContent.Chapters.Count, svc.TotalChapters);
173+
}
174+
175+
[Fact]
176+
public void TotalSteps_ReturnsAllStepsAcrossChapters()
177+
{
178+
var svc = new TutorialService();
179+
var expected = TutorialContent.Chapters.Sum(c => c.Steps.Count);
180+
Assert.Equal(expected, svc.TotalSteps);
181+
}
182+
183+
[Fact]
184+
public void GlobalStepIndex_TracksPositionAcrossChapters()
185+
{
186+
var svc = new TutorialService();
187+
svc.StartTutorial();
188+
Assert.Equal(0, svc.GlobalStepIndex);
189+
190+
// Advance through first chapter
191+
var stepsInFirst = svc.CurrentChapter!.Steps.Count;
192+
for (int i = 0; i < stepsInFirst; i++)
193+
svc.NextStep();
194+
195+
// Should now be at the first step of the second chapter
196+
Assert.Equal(stepsInFirst, svc.GlobalStepIndex);
197+
}
198+
199+
[Fact]
200+
public void OverallProgressPercent_ZeroWhenNoneCompleted()
201+
{
202+
var svc = new TutorialService();
203+
Assert.Equal(0, svc.OverallProgressPercent);
204+
}
205+
206+
[Fact]
207+
public void OverallProgressPercent_IncreasesOnChapterCompletion()
208+
{
209+
var svc = new TutorialService();
210+
svc.StartTutorial();
211+
212+
// Complete first chapter
213+
var stepsInFirst = svc.CurrentChapter!.Steps.Count;
214+
for (int i = 0; i < stepsInFirst; i++)
215+
svc.NextStep();
216+
217+
Assert.True(svc.OverallProgressPercent > 0);
218+
Assert.True(svc.OverallProgressPercent <= 100);
219+
}
220+
221+
[Fact]
222+
public void OverallProgressPercent_100WhenAllCompleted()
223+
{
224+
var svc = new TutorialService();
225+
svc.StartTutorial();
226+
227+
// Complete all chapters
228+
int totalSteps = TutorialContent.Chapters.Sum(c => c.Steps.Count);
229+
for (int i = 0; i < totalSteps; i++)
230+
svc.NextStep();
231+
232+
Assert.Equal(100, svc.OverallProgressPercent);
233+
}
234+
235+
[Fact]
236+
public void CompletedChapterCount_TracksCompletions()
237+
{
238+
var svc = new TutorialService();
239+
Assert.Equal(0, svc.CompletedChapterCount);
240+
241+
svc.StartTutorial();
242+
var stepsInFirst = svc.CurrentChapter!.Steps.Count;
243+
for (int i = 0; i < stepsInFirst; i++)
244+
svc.NextStep();
245+
246+
Assert.Equal(1, svc.CompletedChapterCount);
247+
}
167248
}
168249

169250
public class TutorialContentTests
@@ -225,4 +306,17 @@ public void Content_HasExpectedChapterCount()
225306
{
226307
Assert.Equal(8, TutorialContent.Chapters.Count);
227308
}
309+
310+
[Fact]
311+
public void TipsAreNotEmpty_WhenPresent()
312+
{
313+
foreach (var chapter in TutorialContent.Chapters)
314+
{
315+
foreach (var step in chapter.Steps.Where(s => s.Tip != null))
316+
{
317+
Assert.False(string.IsNullOrWhiteSpace(step.Tip),
318+
$"Step {step.Id} in {chapter.Id} has a non-null but empty Tip");
319+
}
320+
}
321+
}
228322
}

PolyPilot/Components/Pages/Dashboard.razor

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
@inject GitAutoUpdateService GitAutoUpdate
1212
@inject QrScannerService QrScanner
1313
@inject FiestaService FiestaService
14+
@inject TutorialService TutorialService
1415
@implements IAsyncDisposable
1516

1617
<div class="dashboard @(expandedSession != null ? "expanded-mode" : "")" style="--card-min-height: @(_cardMinHeight)px">
@@ -19,6 +20,16 @@
1920
<div class="no-sessions-dash">
2021
<img src="PolyPilot_logo_lg.png" alt="PolyPilot" class="empty-logo" />
2122
<p class="welcome-title">Welcome to PolyPilot</p>
23+
@if (!_hasSeenTutorialPrompt && PlatformHelper.IsDesktop)
24+
{
25+
<div class="tutorial-nudge">
26+
<span class="tutorial-nudge-text">New here? Take a quick tour to learn the basics.</span>
27+
<button class="tutorial-nudge-btn" @onclick="StartTutorialFromDashboard">Start Tutorial</button>
28+
<button class="tutorial-nudge-dismiss" @onclick="DismissTutorialNudge" title="Dismiss">
29+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
30+
</button>
31+
</div>
32+
}
2233
@if (CopilotService.IsRestoring)
2334
{
2435
<div class="restoring-indicator">
@@ -587,6 +598,7 @@
587598
private DotNetObjectReference<Dashboard>? _dotNetRef;
588599
private string? initError;
589600
private bool _initializationComplete = false;
601+
private bool _hasSeenTutorialPrompt = false;
590602
private readonly Dictionary<string, ChatMessage> _fiestaStreamingMessages = new(StringComparer.Ordinal);
591603
private Dictionary<string, (OrchestratorPhase Phase, string? Detail)> _groupPhases = new();
592604

@@ -669,6 +681,9 @@
669681
// Restore card min height (default 250, range 150-600)
670682
_cardMinHeight = uiState.CardMinHeight is >= 150 and <= 600 ? uiState.CardMinHeight : 250;
671683

684+
// Restore tutorial prompt visibility
685+
_hasSeenTutorialPrompt = uiState.HasSeenTutorialPrompt;
686+
672687
// Restore expanded session state
673688
if (!string.IsNullOrEmpty(uiState.ExpandedSession))
674689
{
@@ -818,6 +833,20 @@
818833
StateHasChanged();
819834
}
820835

836+
private void StartTutorialFromDashboard()
837+
{
838+
_hasSeenTutorialPrompt = true;
839+
CopilotService.SaveUiState("/", hasSeenTutorialPrompt: true);
840+
TutorialService.StartTutorial();
841+
}
842+
843+
private void DismissTutorialNudge()
844+
{
845+
_hasSeenTutorialPrompt = true;
846+
CopilotService.SaveUiState("/", hasSeenTutorialPrompt: true);
847+
StateHasChanged();
848+
}
849+
821850
private bool _isRestartingServer;
822851

823852
private async Task RestartServer()

PolyPilot/Components/Pages/Dashboard.razor.css

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,64 @@
236236
letter-spacing: 0.02em;
237237
}
238238

239+
.tutorial-nudge {
240+
display: flex;
241+
align-items: center;
242+
gap: 10px;
243+
padding: 10px 14px;
244+
background: rgba(var(--accent-rgb), 0.08);
245+
border: 1px solid rgba(var(--accent-rgb), 0.2);
246+
border-radius: 8px;
247+
margin-top: 12px;
248+
animation: tutorialNudgeFadeIn 0.4s ease;
249+
}
250+
251+
@keyframes tutorialNudgeFadeIn {
252+
from { opacity: 0; transform: translateY(6px); }
253+
to { opacity: 1; transform: translateY(0); }
254+
}
255+
256+
.tutorial-nudge-text {
257+
font-size: var(--type-callout);
258+
color: var(--text-secondary);
259+
flex: 1;
260+
}
261+
262+
.tutorial-nudge-btn {
263+
padding: 5px 14px;
264+
border-radius: 6px;
265+
font-size: var(--type-callout);
266+
font-weight: 500;
267+
cursor: pointer;
268+
border: none;
269+
background: var(--accent-primary);
270+
color: #fff;
271+
white-space: nowrap;
272+
transition: filter 0.15s;
273+
}
274+
275+
.tutorial-nudge-btn:hover {
276+
filter: brightness(1.15);
277+
}
278+
279+
.tutorial-nudge-dismiss {
280+
background: none;
281+
border: none;
282+
color: var(--text-muted);
283+
cursor: pointer;
284+
padding: 4px;
285+
border-radius: 4px;
286+
display: flex;
287+
align-items: center;
288+
justify-content: center;
289+
line-height: 1;
290+
}
291+
292+
.tutorial-nudge-dismiss:hover {
293+
color: var(--text-primary);
294+
background: var(--bg-tertiary);
295+
}
296+
239297
.empty-logo {
240298
width: 120px;
241299
height: auto;

PolyPilot/Components/Pages/TutorialPage.razor

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,36 @@
66
<div class="tutorial-page-header">
77
<h1>Tutorial</h1>
88
<p class="tutorial-page-subtitle">Learn how to use PolyPilot with guided walkthroughs.</p>
9+
10+
@if (Tutorial.CompletedChapterCount > 0)
11+
{
12+
<div class="tutorial-overall-progress">
13+
<div class="progress-label">
14+
<span>Overall progress</span>
15+
<span class="progress-value">@Tutorial.CompletedChapterCount / @Tutorial.TotalChapters chapters</span>
16+
</div>
17+
<div class="progress-track">
18+
<div class="progress-fill" style="width: @Tutorial.OverallProgressPercent%"></div>
19+
</div>
20+
</div>
21+
}
22+
923
<div class="tutorial-page-actions">
1024
<button class="tutorial-btn tutorial-btn-primary tutorial-start-all" @onclick="StartFullTour">
11-
Start Full Tour
25+
@if (Tutorial.CompletedChapterCount == Tutorial.TotalChapters)
26+
{
27+
<text>Replay Full Tour</text>
28+
}
29+
else if (Tutorial.CompletedChapterCount > 0)
30+
{
31+
<text>Continue Tour</text>
32+
}
33+
else
34+
{
35+
<text>Start Full Tour</text>
36+
}
1237
</button>
13-
@if (Tutorial.CompletedChapters.Count > 0)
38+
@if (Tutorial.CompletedChapterCount > 0)
1439
{
1540
<button class="tutorial-btn tutorial-btn-secondary" @onclick="ResetProgress">
1641
Reset Progress
@@ -20,22 +45,32 @@
2045
</div>
2146

2247
<div class="tutorial-chapters-grid">
48+
@{ var chapterNum = 0; }
2349
@foreach (var chapter in TutorialContent.Chapters)
2450
{
51+
chapterNum++;
2552
var isCompleted = Tutorial.CompletedChapters.Contains(chapter.Id);
53+
var num = chapterNum;
2654
<div class="tutorial-chapter-card @(isCompleted ? "completed" : "")" @onclick="() => StartChapter(chapter.Id)">
55+
<div class="chapter-number">@num</div>
2756
<div class="chapter-card-body">
2857
<h3>@chapter.Title</h3>
2958
<p>@chapter.Description</p>
3059
<span class="chapter-step-count">@chapter.Steps.Count steps</span>
3160
</div>
3261
@if (isCompleted)
3362
{
34-
<div class="chapter-completed-badge">✓</div>
63+
<div class="chapter-completed-badge">
64+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
65+
</div>
3566
}
3667
</div>
3768
}
3869
</div>
70+
71+
<div class="tutorial-page-footer">
72+
<p>Use arrow keys to navigate during a walkthrough. Press Escape to exit.</p>
73+
</div>
3974
</div>
4075

4176
@code {
@@ -45,6 +80,16 @@
4580
private void StartFullTour()
4681
{
4782
NavManager.NavigateTo("/");
83+
if (Tutorial.CompletedChapterCount > 0 && Tutorial.CompletedChapterCount < Tutorial.TotalChapters)
84+
{
85+
// Resume from first incomplete chapter
86+
var firstIncomplete = TutorialContent.Chapters.FirstOrDefault(c => !Tutorial.CompletedChapters.Contains(c.Id));
87+
if (firstIncomplete != null)
88+
{
89+
Tutorial.StartChapter(firstIncomplete.Id);
90+
return;
91+
}
92+
}
4893
Tutorial.StartTutorial();
4994
}
5095

0 commit comments

Comments
 (0)