+
-
setScratch({ name }))
- }
- />
+
+
+ setScratch({ name }))
+ }
+ />
+
-
+
)}
>
diff --git a/frontend/src/components/Scratch/ScratchTour.tsx b/frontend/src/components/Scratch/ScratchTour.tsx
new file mode 100644
index 000000000..c98fa4995
--- /dev/null
+++ b/frontend/src/components/Scratch/ScratchTour.tsx
@@ -0,0 +1,591 @@
+"use client";
+
+import { useEffect } from "react";
+
+import { useSearchParams } from "next/navigation";
+import { Boarding, type BoardingSteps } from "boarding.js";
+
+import diffStyles from "../Diff/Diff.module.scss";
+
+type TourStep = BoardingSteps[number];
+
+const CLICK_TARGET_CLASS = "scratch-tour-click-target";
+
+const SELECTOR = {
+ toolbar: '[data-tour="scratch-toolbar"]',
+ scratchView: '[data-tour="scratch-view"]',
+ leftPane: '[data-tour="scratch-layout-left"]',
+ rightPane: '[data-tour="scratch-layout-right"]',
+ tourButton: '[data-tour="scratch-action-tour"]',
+ compileButton: '[data-tour="scratch-action-compile"]',
+ saveButton: '[data-tour="scratch-action-save"]',
+ forkButton: '[data-tour="scratch-action-fork"]',
+ decompileButton: '[data-tour="scratch-action-decompile"]',
+ exportButton: '[data-tour="scratch-action-export"]',
+ aboutTab: '[data-tour="scratch-tab-about"]',
+ aboutPanel: '[data-tour="scratch-about-panel"]',
+ familyTab: '[data-tour="scratch-tab-family"]',
+ familyPanel: '[data-tour="scratch-family-panel"]',
+ sourceTab: '[data-tour="scratch-tab-source"]',
+ sourcePanel: '[data-tour="scratch-tab-source-panel"]',
+ contextTab: '[data-tour="scratch-tab-context"]',
+ contextPanel: '[data-tour="scratch-tab-context-panel"]',
+ optionsTab: '[data-tour="scratch-tab-options"]',
+ optionsPanel: '[data-tour="scratch-tab-options-panel"]',
+ optionsCompiler: '[data-tour="scratch-options-compiler"]',
+ optionsDiff: '[data-tour="scratch-options-diff"]',
+ optionsOther: '[data-tour="scratch-options-other"]',
+ compilationTab: '[data-tour="scratch-tab-compilation"]',
+ compilationPanel: '[data-tour="scratch-tab-compilation-panel"]',
+ targetColumn: '[data-tour="scratch-diff-column-base-full"]',
+ currentColumn: '[data-tour="scratch-diff-column-current-full"]',
+ thirdColumn: '[data-tour="scratch-diff-column-previous-full"]',
+ diffToggles: '[data-tour="scratch-diff-toggles"]',
+ targetToggle: '[data-tour="scratch-diff-toggle-target"]',
+ currentToggle: '[data-tour="scratch-diff-toggle-current"]',
+ threeWayToggle: '[data-tour="scratch-diff-toggle-three-way"]',
+ compressionToggle: '[data-tour="scratch-diff-toggle-compression"]',
+ objdiffTab: '[data-tour="scratch-tab-objdiff"]',
+ objdiffPanel: '[data-tour="scratch-tab-objdiff-panel"]',
+ problemsTab: '[data-tour="scratch-tab-problems"]',
+ problemsPanel: '[data-tour="scratch-problems-panel"]',
+ decompileTab: '[data-tour="scratch-tab-decompilation"]',
+ decompilePanel: '[data-tour="scratch-decompile-panel"]',
+ decompileContent: '[data-tour="scratch-decompile-content"]',
+};
+
+function element(selector: string) {
+ return document.querySelector
(selector);
+}
+
+function elementExists(selector: string) {
+ return !!element(selector);
+}
+
+function clearClickTargets() {
+ const clickTargets = document.querySelectorAll(`.${CLICK_TARGET_CLASS}`);
+ for (let i = 0; i < clickTargets.length; i++) {
+ clickTargets[i].classList.remove(CLICK_TARGET_CLASS);
+ }
+}
+
+function isElementVisible(selector: string) {
+ const el = element(selector);
+ if (!el) return false;
+
+ const rect = el.getBoundingClientRect();
+ return rect.width > 0 && rect.height > 0;
+}
+
+function afterReactPaint(callback: () => void) {
+ requestAnimationFrame(() => {
+ requestAnimationFrame(callback);
+ });
+}
+
+function makeStep(
+ selector: string,
+ title: string,
+ description: string,
+ preferredSide: TourStep["popover"]["preferredSide"] = "bottom",
+): TourStep {
+ return {
+ element: selector,
+ popover: {
+ title,
+ description,
+ preferredSide,
+ },
+ };
+}
+
+function makeClickToContinueStep({
+ boarding,
+ selector,
+ waitForSelector,
+ isComplete,
+ title,
+ description,
+ preferredSide = "bottom",
+}: {
+ boarding: Boarding;
+ selector: string;
+ waitForSelector?: string;
+ isComplete?: () => boolean;
+ title: string;
+ description: string;
+ preferredSide?: TourStep["popover"]["preferredSide"];
+}): TourStep {
+ let removeClickListener: (() => void) | undefined;
+ let clicked = false;
+ const isStepComplete = () =>
+ clicked ||
+ isComplete?.() ||
+ (waitForSelector ? isElementVisible(waitForSelector) : false);
+
+ return {
+ element: selector,
+ strictClickHandling: true,
+ onHighlighted: () => {
+ clearClickTargets();
+ element(selector)?.classList.add(CLICK_TARGET_CLASS);
+
+ const onClick = (event: MouseEvent) => {
+ const target = event.target;
+ if (!(target instanceof Element)) return;
+ if (!target.closest(selector)) return;
+
+ clicked = true;
+ afterReactPaint(() => {
+ boarding.clearMovePrevented();
+ boarding.next();
+ });
+ };
+
+ document.addEventListener("click", onClick, { capture: true });
+ removeClickListener = () => {
+ document.removeEventListener("click", onClick, {
+ capture: true,
+ });
+ };
+ },
+ onDeselected: () => {
+ element(selector)?.classList.remove(CLICK_TARGET_CLASS);
+ removeClickListener?.();
+ removeClickListener = undefined;
+ },
+ onNext: () => {
+ if (isStepComplete()) return;
+
+ boarding.preventMove();
+ },
+ popover: {
+ title,
+ description,
+ preferredSide,
+ showButtons: ["close", "previous", "next"],
+ disableButtons: ["next"],
+ },
+ };
+}
+
+function isToggleEnabled(selector: string) {
+ return element(selector)?.getAttribute("aria-pressed") === "true";
+}
+
+function resetBoarding(boarding: Boarding) {
+ clearClickTargets();
+
+ if (!boarding.isActivated && !boarding.hasHighlightedElement()) return;
+
+ try {
+ boarding.reset(true, "cancel");
+ } catch (error) {
+ if (
+ !(error instanceof Error) ||
+ error.message !== "No SVG found to unmount"
+ ) {
+ throw error;
+ }
+ }
+}
+
+function isTourViewportSupported() {
+ return window.matchMedia("(min-width: 768px)").matches;
+}
+
+function addIfPresent(steps: TourStep[], step: TourStep) {
+ if (elementExists(step.element as string)) {
+ steps.push(step);
+ }
+}
+
+function addTabSection({
+ steps,
+ boarding,
+ tabSelector,
+ panelSelector,
+ tabTitle,
+ tabDescription,
+ panelTitle,
+ panelDescription,
+ panelPreferredSide = "right",
+}: {
+ steps: TourStep[];
+ boarding: Boarding;
+ tabSelector: string;
+ panelSelector: string;
+ tabTitle: string;
+ tabDescription: string;
+ panelTitle: string;
+ panelDescription: string;
+ panelPreferredSide?: TourStep["popover"]["preferredSide"];
+}) {
+ if (!elementExists(tabSelector)) return;
+
+ steps.push(
+ makeClickToContinueStep({
+ boarding,
+ selector: tabSelector,
+ waitForSelector: panelSelector,
+ title: tabTitle,
+ description: tabDescription,
+ }),
+ );
+
+ steps.push(
+ makeStep(
+ panelSelector,
+ panelTitle,
+ panelDescription,
+ panelPreferredSide,
+ ),
+ );
+}
+
+function addDecompilationInfoStep(steps: TourStep[], selector: string) {
+ steps.push(
+ makeStep(
+ selector,
+ "Decompilation",
+ "When the platform supports it, this tab will show the results of running a decompiler against the target assembly and contents of the scratch context.",
+ ),
+ );
+}
+
+function buildTourSteps(boarding: Boarding): TourStep[] {
+ const steps: TourStep[] = [];
+
+ addIfPresent(
+ steps,
+ makeStep(
+ SELECTOR.toolbar,
+ "Scratch tour",
+ "Welcome to the guided tour of the scratch editor. Use Next and Back to move through it.
When a step says to Click a tab or button, the tour will wait for you to do that before continuing.",
+ "bottom",
+ ),
+ );
+
+ addIfPresent(
+ steps,
+ makeStep(
+ SELECTOR.leftPane,
+ "Editing area",
+ "The left side of the scratch editor is where you make your changes: edit source code and context, tweak compiler options, and update scratch metadata.",
+ "right",
+ ),
+ );
+
+ addIfPresent(
+ steps,
+ makeStep(
+ SELECTOR.rightPane,
+ "Results area",
+ "The right side is where you will see the results of your changes: assembly diff and any warnings or errors from the compiler.",
+ "left",
+ ),
+ );
+
+ addIfPresent(
+ steps,
+ makeStep(
+ SELECTOR.toolbar,
+ "Scratch toolbar",
+ "The toolbar contains scratch-level actions such as saving, forking, exporting, and decompiling.
If you own the scratch, you can rename it by clicking on its name and making your changes.",
+ ),
+ );
+
+ addIfPresent(
+ steps,
+ makeStep(
+ SELECTOR.forkButton,
+ "Fork to save",
+ "Forking a scratch creates your own editable copy. This is how you can save your changes when you aren't the owner.",
+ ),
+ );
+
+ addTabSection({
+ steps,
+ boarding,
+ tabSelector: SELECTOR.sourceTab,
+ panelSelector: SELECTOR.sourcePanel,
+ tabTitle: "Source code tab",
+ tabDescription:
+ "Click the Source code tab to select the code editor.",
+ panelTitle: "Source code",
+ panelDescription:
+ "The matching loop happens here: edit the code, compile, check the diff, repeat until the code matches.
By default, changes are compiled automatically after a short delay; you can configure this behavior in the editor settings menu at the top left of the screen.",
+ });
+
+ addTabSection({
+ steps,
+ boarding,
+ tabSelector: SELECTOR.contextTab,
+ panelSelector: SELECTOR.contextPanel,
+ tabTitle: "Context tab",
+ tabDescription:
+ "Now click the Context tab to see supporting declarations.",
+ panelTitle: "Context",
+ panelDescription:
+ "Use this space for shared typedefs, symbols, function definitions, etc., that are used by the function you are trying to match.",
+ });
+
+ addTabSection({
+ steps,
+ boarding,
+ tabSelector: SELECTOR.optionsTab,
+ panelSelector: SELECTOR.optionsPanel,
+ tabTitle: "Options tab",
+ tabDescription:
+ "Click the Options tab to open compiler and diff settings.",
+ panelTitle: "Options",
+ panelDescription:
+ "These options control how your source is compiled and compared.
Most scratches will have the compiler and flags set via a preset.",
+ });
+ steps.push(
+ makeStep(
+ SELECTOR.optionsCompiler,
+ "Compiler options",
+ "The compiler options section is where you can change the compiler and tweak the flags used to build your source code.",
+ ),
+ );
+ steps.push(
+ makeStep(
+ SELECTOR.optionsDiff,
+ "Diff options",
+ "Diff options control how the generated object is compared against the target.",
+ ),
+ );
+ steps.push(
+ makeStep(
+ SELECTOR.optionsOther,
+ "Other options",
+ "Match override lets an owner mark a scratch as matching even when naming or symbol details leave a mismatch.",
+ ),
+ );
+
+ addTabSection({
+ steps,
+ boarding,
+ tabSelector: SELECTOR.familyTab,
+ panelSelector: SELECTOR.familyPanel,
+ tabTitle: "Family tab",
+ tabDescription:
+ "Click Family to switch to the family tab to see related forks.",
+ panelTitle: "Scratch family",
+ panelDescription:
+ "Forks let people collaborate on the same target function. The family tab helps find related work and improvements.
If someone has improved or matched the scratch you are working on, a banner will appear at the top of the screen letting you know.",
+ });
+
+ addTabSection({
+ steps,
+ boarding,
+ tabSelector: SELECTOR.aboutTab,
+ panelSelector: SELECTOR.aboutPanel,
+ tabTitle: "About tab",
+ tabDescription: "Now click the About tab.",
+ panelTitle: "About",
+ panelDescription:
+ "The about tab shows the score, owner, platform, preset, timestamps, parent scratch, and any notes that have been added to the scratch.",
+ });
+
+ addTabSection({
+ steps,
+ boarding,
+ tabSelector: SELECTOR.compilationTab,
+ panelSelector: SELECTOR.compilationPanel,
+ tabTitle: "Compilation tab",
+ tabDescription:
+ "Click Compilation to inspect the current diff.",
+ panelTitle: "Compilation",
+ panelDescription:
+ "The compilation panel shows the current assembly diff and compiler output. The lower the score the better; a score of 0 means the generated output matches the target.",
+ panelPreferredSide: "left",
+ });
+ addIfPresent(
+ steps,
+ makeStep(
+ SELECTOR.targetColumn,
+ "Target column",
+ `The target column shows the assembly code that you are trying to match.
The meaning of the diff letters is as follows: - | generic diffs
- < missing instruction
- > added instruction
- i immediate diffs
- r register diffs
- s stack diffs
`,
+ ),
+ );
+ addIfPresent(
+ steps,
+ makeStep(
+ SELECTOR.currentColumn,
+ "Current column",
+ "The current column shows the result of your compiled source code.
The ~> symbols are used to highlight jumps within the code. Where supported, source line numbers are included in the diff.",
+ ),
+ );
+ addIfPresent(
+ steps,
+ makeStep(
+ SELECTOR.diffToggles,
+ "Column toggles",
+ "These controls hide or show diff columns, enable 3-way comparison, and collapse long unchanged diffs when you need more room.",
+ ),
+ );
+
+ if (elementExists(SELECTOR.threeWayToggle)) {
+ steps.push(
+ makeClickToContinueStep({
+ boarding,
+ selector: SELECTOR.threeWayToggle,
+ isComplete: () => isToggleEnabled(SELECTOR.threeWayToggle),
+ waitForSelector: SELECTOR.thirdColumn,
+ title: "3-way diff",
+ description:
+ "Click the 3 to add a third column comparing against your saved version or previous compile, depending on your editor setting.",
+ }),
+ );
+ steps.push(
+ makeStep(
+ SELECTOR.thirdColumn,
+ "Third diff column",
+ "This column allows you to better identify how your changes affect the diff.",
+ ),
+ );
+ steps.push({
+ ...makeClickToContinueStep({
+ boarding,
+ selector: SELECTOR.threeWayToggle,
+ isComplete: () =>
+ !isToggleEnabled(SELECTOR.threeWayToggle) &&
+ !isElementVisible(SELECTOR.thirdColumn),
+ title: "Turn 3-way diff off",
+ description:
+ "Click 3 again to return to the normal two-column diff.",
+ }),
+ });
+ }
+
+ if (elementExists(SELECTOR.compressionToggle)) {
+ steps.push(
+ makeStep(
+ SELECTOR.compressionToggle,
+ "Compress the diff",
+ "The fold button collapses long matches so you can focus on the differences.",
+ ),
+ );
+ }
+
+ addTabSection({
+ steps,
+ boarding,
+ tabSelector: SELECTOR.objdiffTab,
+ panelSelector: SELECTOR.objdiffPanel,
+ tabTitle: "objdiff tab",
+ tabDescription:
+ "Click the objdiff tab to switch from asm-differ to the objdiff backend.",
+ panelTitle: "objdiff",
+ panelDescription:
+ "objdiff provides an object-level comparison view that can be useful when inspecting data in addition to any instruction differences.",
+ panelPreferredSide: "left",
+ });
+
+ if (elementExists(SELECTOR.problemsTab)) {
+ addTabSection({
+ steps,
+ boarding,
+ tabSelector: SELECTOR.problemsTab,
+ panelSelector: SELECTOR.problemsPanel,
+ tabTitle: "Problems panel",
+ tabDescription:
+ "Click the Problems panel to see compiler output.",
+ panelTitle: "Problems",
+ panelDescription:
+ "Any compiler errors and warnings will be shown here.",
+ });
+ }
+
+ if (isElementVisible(SELECTOR.decompilePanel)) {
+ addDecompilationInfoStep(
+ steps,
+ elementExists(SELECTOR.decompileContent)
+ ? SELECTOR.decompileContent
+ : SELECTOR.decompilePanel,
+ );
+ } else if (elementExists(SELECTOR.decompileTab)) {
+ steps.push(
+ makeClickToContinueStep({
+ boarding,
+ selector: SELECTOR.decompileTab,
+ waitForSelector: SELECTOR.decompileContent,
+ title: "Decompilation tab",
+ description:
+ "Click the Decompilation tab to show the decompiler output.",
+ }),
+ );
+ addDecompilationInfoStep(steps, SELECTOR.decompileContent);
+ } else if (elementExists(SELECTOR.decompileButton)) {
+ steps.push(
+ makeClickToContinueStep({
+ boarding,
+ selector: SELECTOR.decompileButton,
+ waitForSelector: SELECTOR.decompileContent,
+ title: "Open decompilation panel",
+ description:
+ "Click Decompile to open the decompilation panel for this scratch.",
+ }),
+ );
+ addDecompilationInfoStep(steps, SELECTOR.decompileContent);
+ }
+
+ addIfPresent(
+ steps,
+ makeStep(
+ SELECTOR.tourButton,
+ "More help",
+ 'If you have more questions, the FAQ is a good next stop, or feel free to join the decomp.me Discord server, where people ask for help, share scratches, and discuss decompilation in a collaborative environment.
You can start the tour again by clicking the Tour button; otherwise, click Finish to end the tour.',
+ ),
+ );
+
+ return steps;
+}
+
+export default function ScratchTour(): null {
+ const searchParams = useSearchParams();
+ const tourEnabled = searchParams.get("tour") === "1";
+
+ useEffect(() => {
+ const boarding = new Boarding({
+ allowClose: true,
+ className: "scratch-tour-popover",
+ keyboardControl: true,
+ nextBtnText: "Next",
+ prevBtnText: "Back",
+ doneBtnText: "Finish",
+ overlayClickNext: false,
+ padding: 8,
+ radius: 4,
+ strictClickHandling: true,
+ scrollIntoViewOptions: { block: "center", inline: "center" },
+ });
+
+ const startTour = () => {
+ if (!isTourViewportSupported()) return;
+
+ resetBoarding(boarding);
+ const steps = buildTourSteps(boarding);
+ if (steps.length === 0) return;
+
+ boarding.defineSteps(steps);
+ boarding.start();
+ };
+
+ const timeout = tourEnabled
+ ? window.setTimeout(startTour, 300)
+ : undefined;
+
+ window.addEventListener("scratch-tour:start", startTour);
+
+ return () => {
+ if (timeout) window.clearTimeout(timeout);
+ window.removeEventListener("scratch-tour:start", startTour);
+ resetBoarding(boarding);
+ };
+ }, [tourEnabled]);
+
+ return null;
+}
diff --git a/frontend/src/components/Scratch/panels/AboutPanel.tsx b/frontend/src/components/Scratch/panels/AboutPanel.tsx
index ab2016aa2..01e2adf66 100644
--- a/frontend/src/components/Scratch/panels/AboutPanel.tsx
+++ b/frontend/src/components/Scratch/panels/AboutPanel.tsx
@@ -53,7 +53,7 @@ export default function AboutPanel({ scratch, setScratch }: Props) {
const preset: PresetBase = usePreset(scratch.preset);
return (
-
+
Name
diff --git a/frontend/src/components/Scratch/panels/DecompilePanel.tsx b/frontend/src/components/Scratch/panels/DecompilePanel.tsx
index 87eb6fcb8..b595aeb5f 100644
--- a/frontend/src/components/Scratch/panels/DecompilePanel.tsx
+++ b/frontend/src/components/Scratch/panels/DecompilePanel.tsx
@@ -50,8 +50,11 @@ export default function DecompilePanel({ scratch }: Props) {
decompiledCode === null || scratch.context !== debouncedContext;
return (
-
-
+
+
Modify the context or compiler to see how the decompilation
of the assembly changes.
diff --git a/frontend/src/components/Scratch/panels/FamilyPanel.tsx b/frontend/src/components/Scratch/panels/FamilyPanel.tsx
index 4a2912f8e..8e131e7ca 100644
--- a/frontend/src/components/Scratch/panels/FamilyPanel.tsx
+++ b/frontend/src/components/Scratch/panels/FamilyPanel.tsx
@@ -20,7 +20,10 @@ type Props = {
export default function FamilyPanel({ scratch }: Props) {
return (
-
+
);
diff --git a/frontend/src/components/Scratch/panels/ProblemPanel.tsx b/frontend/src/components/Scratch/panels/ProblemPanel.tsx
index f8336c1a9..e901dab8f 100644
--- a/frontend/src/components/Scratch/panels/ProblemPanel.tsx
+++ b/frontend/src/components/Scratch/panels/ProblemPanel.tsx
@@ -2,7 +2,10 @@ import CompilerMessageOutput from "../CompilerMessageOutput";
export default function ProblemPanel({ text }: { text: string }) {
return (
-
+
);
diff --git a/frontend/src/components/Tabs.tsx b/frontend/src/components/Tabs.tsx
index 4866ec670..2c9d7528f 100644
--- a/frontend/src/components/Tabs.tsx
+++ b/frontend/src/components/Tabs.tsx
@@ -45,6 +45,7 @@ export type TabContent = ReactNode | (() => ReactNode);
export type TabProps = {
children?: TabContent;
className?: string;
+ dataTour?: string;
tabKey: string;
label?: ReactNode;
disabled?: boolean;
@@ -77,6 +78,7 @@ export class Tab extends Component
{
role="tab"
aria-selected={ctx.activeTab === key}
className={styles.tabButton}
+ data-tour={this.props.dataTour}
disabled={this.props.disabled}
onClick={() => {
ctx.setActive(key);
@@ -259,6 +261,9 @@ export default function Tabs({
styles.tabPanelContent,
props.className,
)}
+ data-tour={
+ props.dataTour && `${props.dataTour}-panel`
+ }
>
{children}
diff --git a/frontend/src/components/compiler/CompilerOpts.tsx b/frontend/src/components/compiler/CompilerOpts.tsx
index db738a709..6e14b0b8a 100644
--- a/frontend/src/components/compiler/CompilerOpts.tsx
+++ b/frontend/src/components/compiler/CompilerOpts.tsx
@@ -414,7 +414,10 @@ export default function CompilerOpts({
return (
<>
-
+
Preset
@@ -426,7 +429,10 @@ export default function CompilerOpts({
-
-
+
-
-