Skip to content

Commit 0134ac6

Browse files
committed
Add interactive tutorial wizard for first-time onboarding
- TutorialWizard: Portal-based card with gradient background, step navigation, "Show on startup" checkbox, and per-step marker highlighting (circle/rect) - Marker tracks target UI elements via ResizeObserver with proper cleanup - tutorialEnabled setting in IUISettings + SettingsDialog checkbox - Integrates after app init, before loading last score from localStorage; auto-expands/collapses mixer panel on relevant steps - Grid now forwards data-* attributes for tutorial target selectors - E2E: storage-state.json disables tutorial during Playwright tests - 12 unit tests covering navigation, marker positioning, and observer lifecycle Signed-off-by: Mike Lischke <mike@lischke-online.de>
1 parent 1f9df60 commit 0134ac6

15 files changed

Lines changed: 948 additions & 7 deletions

cspell.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@
109109
"tableholder",
110110
"testadmin",
111111
"testpass",
112+
"topbar",
112113
"wavesurfer",
113114
"xlink"
114115
],

playwright.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export default defineConfig({
1515
trace: "on-first-retry",
1616
screenshot: "only-on-failure",
1717
video: "retain-on-failure",
18+
storageState: "tests/e2e/storage-state.json",
1819
},
1920
webServer: {
2021
command: process.env.CI ? "npm run serve:e2e" : "npm run build && npm run serve:e2e",

src/App.tsx

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,8 +67,10 @@ import { ModeManager } from "./ui/ModeManager.js";
6767
import { MouseHandler } from "./ui/MouseHandler.js";
6868
import { SelectionManager } from "./ui/SelectionManager.js";
6969
import { SettingsDialog } from "./ui/SettingsDialog.js";
70+
import { TutorialWizard } from "./ui/TutorialWizard.js";
7071
import { UserGroupEditor } from "./ui/UserGroupEditor.js";
7172
import { PermissionEditor } from "./ui/PermissionEditor.js";
73+
import { tutorialSteps, mixerStepIndex } from "./core/TutorialSteps.js";
7274

7375
const ScoreLibrary = lazy(() => {
7476
return import("./ui/ScoreLibrary.js").then((m) => {
@@ -123,6 +125,7 @@ export class App extends UIComponent<{}, IAppState> {
123125
private userGroupEditorRef = createRef<UserGroupEditor>();
124126
private permissionEditorRef = createRef<PermissionEditor>();
125127
private printDialogRef = createRef<PrintDialog>();
128+
private tutorialWizardRef = createRef<TutorialWizard>();
126129
private valueDialogRef = createRef<ValueDialog>();
127130
private confirmDialogRef = createRef<ConfirmDialog>();
128131

@@ -146,6 +149,8 @@ export class App extends UIComponent<{}, IAppState> {
146149
private statsItem?: IStatusBarItem;
147150
private notificationItem?: IStatusBarItem;
148151

152+
private currentTutorialStep = 0;
153+
149154
public constructor(props: {}) {
150155
super(props);
151156

@@ -366,6 +371,7 @@ export class App extends UIComponent<{}, IAppState> {
366371
imageOnly
367372
className="du-btn-ghost"
368373
data-tooltip="Display Options"
374+
data-tutorial="display-options"
369375
onClick={this.handleDisplayOptionsClick}
370376
>
371377
<Icon
@@ -378,6 +384,7 @@ export class App extends UIComponent<{}, IAppState> {
378384
imageOnly
379385
className="du-btn-ghost"
380386
data-tooltip="Score Library"
387+
data-tutorial="score-library"
381388
onClick={this.handleScoreLibraryClick}
382389
>
383390
<Icon
@@ -391,6 +398,7 @@ export class App extends UIComponent<{}, IAppState> {
391398
imageOnly
392399
className="du-btn-ghost"
393400
data-tooltip="Print / Export to PDF"
401+
data-tutorial="print"
394402
onClick={this.handlePrintClick}
395403
>
396404
<Icon
@@ -441,6 +449,7 @@ export class App extends UIComponent<{}, IAppState> {
441449
dataModel={this.dataModel}
442450
services={this.services}
443451
undoManager={this.undoManager!}
452+
data-tutorial="playback"
444453
/>
445454
<Container
446455
id="arrangementPalette"
@@ -489,6 +498,14 @@ export class App extends UIComponent<{}, IAppState> {
489498
<TooltipProvider />
490499
<ValueDialog ref={this.valueDialogRef} />
491500
<SettingsDialog ref={this.settingsDialogRef} />
501+
<TutorialWizard
502+
ref={this.tutorialWizardRef}
503+
steps={tutorialSteps}
504+
tutorialEnabled={AppStorage.loadUISettings()?.tutorialEnabled ?? true}
505+
onTutorialEnabledChange={this.handleTutorialEnabledChange}
506+
onStepChange={this.handleTutorialStepChange}
507+
onClose={this.handleTutorialClose}
508+
/>
492509
<BackendDisconnectedDialog
493510
ref={this.backendDisconnectedDialogRef}
494511
onReconnected={() => {
@@ -819,6 +836,25 @@ export class App extends UIComponent<{}, IAppState> {
819836

820837
const params = new URL(window.location.href).searchParams;
821838
const hasBananaDrum = params.has("a") || params.has("a2");
839+
const hasScoreParam = params.has("score");
840+
841+
const showTutorial = !hasBananaDrum && !hasScoreParam
842+
&& (AppStorage.loadUISettings()?.tutorialEnabled ?? true);
843+
844+
if (showTutorial) {
845+
this.initAppState();
846+
this.setState({ phase: AppPhase.Running }, () => {
847+
this.tutorialWizardRef.current?.open();
848+
});
849+
850+
return;
851+
}
852+
853+
await this.loadInitialScore(params);
854+
}
855+
856+
private async loadInitialScore(params: URLSearchParams): Promise<void> {
857+
const hasBananaDrum = params.has("a") || params.has("a2");
822858

823859
let pendingWarning: string | undefined;
824860

@@ -862,6 +898,40 @@ export class App extends UIComponent<{}, IAppState> {
862898
});
863899
}
864900

901+
private handleTutorialClose = (completed: boolean): void => {
902+
this.tutorialWizardRef.current?.close(completed);
903+
this.loadScorebook(undefined);
904+
};
905+
906+
private handleTutorialStepChange = (stepIndex: number): void => {
907+
const prevStep = this.currentTutorialStep;
908+
this.currentTutorialStep = stepIndex;
909+
910+
if (stepIndex === mixerStepIndex) {
911+
this.toggleMixerIf(!this.isMixerExpanded());
912+
}
913+
914+
if (prevStep === mixerStepIndex && stepIndex !== mixerStepIndex) {
915+
this.toggleMixerIf(this.isMixerExpanded());
916+
}
917+
};
918+
919+
private isMixerExpanded(): boolean {
920+
return document.querySelector(".trackControlsList")?.classList.contains("expanded") ?? false;
921+
}
922+
923+
private toggleMixerIf(condition: boolean): void {
924+
if (!condition) {
925+
return;
926+
}
927+
928+
document.querySelector<HTMLElement>(".trackControlsToggle")?.click();
929+
}
930+
931+
private handleTutorialEnabledChange = (enabled: boolean): void => {
932+
AppStorage.saveSetting("tutorialEnabled", enabled);
933+
};
934+
865935
private handleGithubClick = () => {
866936
window.open("https://github.com/mike-lischke/animada-score-book", "_blank");
867937
};
@@ -1317,7 +1387,12 @@ export class App extends UIComponent<{}, IAppState> {
13171387
return true;
13181388
};
13191389

1320-
private loadScorebook(source?: URLSearchParams | ISbDmScore) {
1390+
private initAppState(): void {
1391+
this.undoManager = new UndoManager(this.dataModel);
1392+
this.arrangementPlayer = new ArrangementPlayer(this.dataModel);
1393+
}
1394+
1395+
private loadScorebook(source?: IArrangementSnapshot | URLSearchParams | ISbDmScore) {
13211396
let resolvedSource: IArrangementSnapshot | URLSearchParams | ISbDmScore | undefined;
13221397

13231398
if (source) {
@@ -1362,7 +1437,8 @@ export class App extends UIComponent<{}, IAppState> {
13621437
document.title = arrangement.title + " - Animada Score Book";
13631438
}
13641439

1365-
AppStorage.saveSetting("currentScore", stringifyPackedArrangement((arrangement as Arrangement).toSnapshot()),
1440+
AppStorage.saveSetting("currentScore",
1441+
stringifyPackedArrangement((arrangement as Arrangement).toSnapshot()),
13661442
);
13671443

13681444
this.forceUpdate();

src/components/ui/Arrangement/ArrangementPlayControls.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ export class ArrangementPlayControls
130130
const arrangementView = dataModel.arrangement!;
131131

132132
return (
133-
<Grid id="arrangementPlayControls" columns={[160, "auto"]}>
133+
<Grid id="arrangementPlayControls" columns={[160, "auto"]} {...this.dataAttributes}>
134134
<Container
135135
orientation={Orientation.TopDown}
136136
mainAlignment={ChildAlignment.Start}

src/components/ui/Arrangement/TrackControls.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,12 @@ export class TrackControls extends UIComponent<ITrackControlsProperties, ITrackC
165165
});
166166

167167
return (
168-
<Container innerRef={innerRef} className={listClassName} orientation={Orientation.TopDown}>
168+
<Container
169+
innerRef={innerRef}
170+
className={listClassName}
171+
orientation={Orientation.TopDown}
172+
data-tutorial="mixer"
173+
>
169174
<Container className="trackControlsHeader" crossAlignment={ChildAlignment.Center}>
170175
<Button
171176
className="trackControlsToggle"

src/components/ui/framework/Button.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ export class Button extends UIComponent<IButtonProperties> {
6060
this.classFromProperty(round, "du-btn-circle"),
6161
this.classFromProperty(imageOnly, "imageOnly"),
6262
this.classFromProperty(disabled, "du-btn-disabled"),
63-
this.classFromProperty(isDefault, "default"),
63+
this.classFromProperty(isDefault, "du-btn-primary"),
6464
]);
6565

6666
const content = children ?? caption;

src/components/ui/framework/Grid.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ export class Grid extends UIComponent<IGridProperties> {
5959
ref={innerRef as preact.RefObject<HTMLDivElement>}
6060
className={className}
6161
style={newStyle}
62+
{...this.dataAttributes}
6263
>
6364
{children}
6465
</div>

src/components/ui/framework/Popup.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,11 @@ interface IPopupProperties extends IPortalProperties {
3131
}
3232

3333
interface IPopupState {
34-
hidden: boolean; // Used to temporarily hide the popup on scroll.
35-
currentTarget?: DOMRect; // The area for placement computation.
34+
/** Used to temporarily hide the popup on scroll. */
35+
hidden: boolean;
36+
37+
/** The area for placement computation. */
38+
currentTarget?: DOMRect;
3639
}
3740

3841
/**

src/core/AppStorage.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,9 @@ export interface IUISettings {
6363

6464
/** Whether to show the permission matrix in the Score Library tree. Defaults to true. */
6565
showPermMatrix?: boolean;
66+
67+
/** Whether to show the tutorial wizard on app startup. Defaults to true. */
68+
tutorialEnabled?: boolean;
6669
}
6770

6871
/**

src/core/TutorialSteps.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/*
2+
* Copyright (c) Mike Lischke. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*/
5+
6+
export interface ITutorialStep {
7+
title: string;
8+
description: string;
9+
targetSelector?: string;
10+
markerShape?: "circle" | "rect";
11+
}
12+
13+
/** Index of the Mixer step in {@link tutorialSteps}. */
14+
export const mixerStepIndex = 4;
15+
16+
export const tutorialSteps: ITutorialStep[] = [
17+
{
18+
title: "Welcome",
19+
description: "Animada Score Book helps you create, arrange, and share samba arrangements."
20+
+ " This tutorial walks you through the most important features.",
21+
},
22+
{
23+
title: "Score Library",
24+
targetSelector: "[data-tutorial=\"score-library\"]",
25+
markerShape: "circle",
26+
description: "The Score Library gives you an overview of all your saved arrangements."
27+
+ " Click the library icon to open it."
28+
+ " From here you can load scores, create folders, and import new arrangements.",
29+
},
30+
{
31+
title: "Display Options",
32+
targetSelector: "[data-tutorial=\"display-options\"]",
33+
markerShape: "circle",
34+
description: "Click the gear icon to open display options: theme, zoom level, and other settings."
35+
+ " Choose between various light and dark themes.",
36+
},
37+
{
38+
title: "Playback",
39+
targetSelector: "[data-tutorial=\"playback\"]",
40+
markerShape: "rect",
41+
description: "Press the play button to start playback. You can select a range of measures"
42+
+ " to play only that section. Loop, count-in, and metronome controls"
43+
+ " are always visible in the playback bar.",
44+
},
45+
{
46+
title: "Mixer",
47+
targetSelector: "[data-tutorial=\"mixer\"]",
48+
markerShape: "rect",
49+
description: "On the left side you'll find the mixer with volume controls for each track."
50+
+ " Drag the slider left to lower the volume, or right for a focus boost."
51+
+ " This lets you set the perfect balance for your ensemble.",
52+
},
53+
{
54+
title: "Print / PDF",
55+
targetSelector: "[data-tutorial=\"print\"]",
56+
markerShape: "circle",
57+
description: "Click the PDF icon to open the print dialog. Choose how many bars per line"
58+
+ " and which tracks to include."
59+
+ " You can print the arrangement directly or save it as a PDF.",
60+
},
61+
{
62+
title: "MP3 Export",
63+
targetSelector: "#recordButton",
64+
markerShape: "circle",
65+
description: "Click the record button to export your arrangement as an MP3 file."
66+
+ " The app renders the full arrangement with all instruments"
67+
+ " and gives you an MP3 file to download.",
68+
},
69+
{
70+
title: "You're ready!",
71+
description: "That's it! You can re-enable the tutorial anytime in Display Options."
72+
+ " Have fun with Animada Score Book!",
73+
},
74+
];

0 commit comments

Comments
 (0)