diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 8ab75d222..58de1ef37 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -15,7 +15,7 @@ env:
# renovate: datasource=npm depName=npm
NPM_VERSION: "11"
# renovate: datasource=npm depName=@microbit-foundation/python-editor-v3-microbit registryUrl=https://npm.pkg.github.com
- THEME_VERSION: 0.0.0-home.page.124
+ THEME_VERSION: 0.0.0-home.page.125
jobs:
stages:
diff --git a/docs/analytics-events.md b/docs/analytics-events.md
index 857b98e3d..8e3648be0 100644
--- a/docs/analytics-events.md
+++ b/docs/analytics-events.md
@@ -181,7 +181,8 @@ Projects page only. Fires when the user changes the sort field or direction.
### `idea_open`
-User opened an idea from the documentation as a new project.
+User opened an idea as a new project, from the home page idea dialog or the
+editor's Ideas tab.
| Param | Values |
| ----- | -------------------------- |
diff --git a/lang/ui.en.json b/lang/ui.en.json
index c352d45b2..494fde5e7 100644
--- a/lang/ui.en.json
+++ b/lang/ui.en.json
@@ -245,7 +245,7 @@
},
"create-project-action": {
"defaultMessage": "Create",
- "description": "Confirm button of the dialog naming a new project"
+ "description": "Button creating a new project: confirms the dialog naming a new project, and creates one from the home page idea dialog"
},
"create-python": {
"defaultMessage": "Create a new Python file in this project",
@@ -391,6 +391,10 @@
"defaultMessage": "For more support, see the Python Editor guide",
"description": "Text with an external link to support/help content"
},
+ "guide-tab": {
+ "defaultMessage": "Guide",
+ "description": "Sidebar tab button text. Tab shows the guide for the idea the project was opened from."
+ },
"help": {
"defaultMessage": "Help",
"description": "Help menu label"
diff --git a/src/common/AreaHeading.tsx b/src/common/AreaHeading.tsx
index 63b9a9904..a4f00a182 100644
--- a/src/common/AreaHeading.tsx
+++ b/src/common/AreaHeading.tsx
@@ -9,7 +9,7 @@ import { Box } from "styled-system/jsx";
interface AreaHeadingProps {
name: string;
- description: ReactNode;
+ description?: ReactNode;
}
const AreaHeading = ({ name, description }: AreaHeadingProps) => (
@@ -17,7 +17,7 @@ const AreaHeading = ({ name, description }: AreaHeadingProps) => (
{name}
- {description}
+ {description !== undefined && {description}}
);
diff --git a/src/documentation/GuideArea.tsx b/src/documentation/GuideArea.tsx
new file mode 100644
index 000000000..ad50c7480
--- /dev/null
+++ b/src/documentation/GuideArea.tsx
@@ -0,0 +1,58 @@
+/**
+ * (c) 2026, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+import { Text } from "@microbit/ui";
+import { FormattedMessage } from "react-intl";
+import { Stack } from "styled-system/jsx";
+import AreaHeading from "../common/AreaHeading";
+import { docStylesClass } from "../common/documentation-styles";
+import HeadedScrollablePanel from "../common/HeadedScrollablePanel";
+import Spinner from "../common/Spinner";
+import { useOpenIdeaSlug } from "../project/projects-hooks";
+import { useDocumentation } from "./documentation-hooks";
+import IdeaGuideContent from "./ideas/IdeaGuideContent";
+import { Idea } from "./ideas/model";
+
+/**
+ * The guide for the idea the open project came from: its content without
+ * the program, which is in the editor.
+ */
+const GuideArea = () => {
+ const slug = useOpenIdeaSlug();
+ const { ideas } = useDocumentation();
+ if (ideas.status === "loading") {
+ return ;
+ }
+ const idea =
+ ideas.status === "ok"
+ ? ideas.content.find((idea) => idea.slug.current === slug)
+ : undefined;
+ if (!idea) {
+ return (
+
+
+
+ );
+ }
+ return ;
+};
+
+const Guide = ({ idea }: { idea: Idea }) => (
+ }>
+
+
+
+
+);
+
+export default GuideArea;
diff --git a/src/documentation/common/DocumentationContent.tsx b/src/documentation/common/DocumentationContent.tsx
index 1846ac01c..f17214c37 100644
--- a/src/documentation/common/DocumentationContent.tsx
+++ b/src/documentation/common/DocumentationContent.tsx
@@ -43,6 +43,11 @@ interface DocumentationContextValue {
parentSlug?: string;
toolkitType?: string;
title?: string;
+ /**
+ * Links to Reference and API, which navigate the editor's sidebar. False
+ * outside the editor, where they show as plain text.
+ */
+ editorLinks?: boolean;
}
const DocumentationContext = React.createContext({});
@@ -67,10 +72,17 @@ const useCodeEmbedContext = () => {
return { parentSlug, toolkitType, title };
};
+const useEditorLinks = () =>
+ useContext(DocumentationContext).editorLinks !== false;
+
const DocumentationApiLinkMark = (
props: SerializerMarkProps
) => {
const [, setState] = useRouterState();
+ const editorLinks = useEditorLinks();
+ if (!editorLinks) {
+ return <>{props.children}>;
+ }
return (
) => {
const [state, setState] = useRouterState();
+ const editorLinks = useEditorLinks();
+ if (!editorLinks) {
+ return <>{props.children}>;
+ }
return (
{
+ // Runtime value derived from the image reference.
+ const imagePadding = idea.image
+ ? getAspectRatioPadding(idea.image.asset._ref)
+ : undefined;
+ return (
+ <>
+ {inEditor && idea.image && (
+
+ }
+ alt=""
+ borderTopRadius="lg"
+ />
+
+ )}
+
+
+
+ >
+ );
+};
+
+export default IdeaGuideContent;
diff --git a/src/documentation/ideas/idea-code.ts b/src/documentation/ideas/idea-code.ts
new file mode 100644
index 000000000..c5982ffad
--- /dev/null
+++ b/src/documentation/ideas/idea-code.ts
@@ -0,0 +1,19 @@
+/**
+ * (c) 2026, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+import { PortableText } from "../../common/sanity";
+import { Idea } from "./model";
+
+const isCode = (
+ block: PortableText[number]
+): block is { _type: "python"; main: string } => block._type === "python";
+
+/** The idea's program: its content's first code block. */
+export const ideaCode = (idea: Idea): string | undefined =>
+ idea.content?.find(isCode)?.main;
+
+/** The idea's content without its code, for the guide beside the editor. */
+export const withoutCode = (content: PortableText | undefined): PortableText =>
+ (content ?? []).filter((block) => !isCode(block));
diff --git a/src/e2e/app.ts b/src/e2e/app.ts
index 5449ffc2d..7373f17dc 100644
--- a/src/e2e/app.ts
+++ b/src/e2e/app.ts
@@ -301,7 +301,7 @@ export class App {
}
}
- async switchTab(tabName: "Files" | "API" | "Reference" | "Ideas") {
+ async switchTab(tabName: "Files" | "API" | "Reference" | "Ideas" | "Guide") {
await this.page.getByRole("tab", { name: tabName }).click();
}
diff --git a/src/e2e/home.test.ts b/src/e2e/home.test.ts
index 9c6c3a5d2..c05ea8069 100644
--- a/src/e2e/home.test.ts
+++ b/src/e2e/home.test.ts
@@ -42,6 +42,29 @@ test.describe("home page", () => {
await app.expectEditorContainText("from microbit import");
});
+ test("opens an idea from its dialog as a project with its guide", async ({
+ app,
+ homePage,
+ }) => {
+ const ideaName = "Emotion badge";
+ await homePage.goto();
+ await homePage.page.getByRole("button", { name: ideaName }).click();
+ const dialog = homePage.page.getByRole("dialog");
+ await expect(
+ dialog.getByRole("heading", { name: ideaName, exact: true })
+ ).toBeVisible();
+
+ await dialog.getByRole("button", { name: "Create", exact: true }).click();
+
+ await expect(app.page).toHaveURL(appUrlPattern("project/guide"));
+ await app.expectProjectName(ideaName);
+ await expect(
+ app.page.getByRole("tab", { name: "Guide", selected: true })
+ ).toBeVisible();
+ await app.expectDocumentationTopLevelHeading(ideaName);
+ await app.expectEditorContainText("from microbit import");
+ });
+
test("returns home from the editor with the project listed", async ({
app,
homePage,
diff --git a/src/fs/projects-db.ts b/src/fs/projects-db.ts
index 98ed3223e..3f3339a28 100644
--- a/src/fs/projects-db.ts
+++ b/src/fs/projects-db.ts
@@ -17,6 +17,8 @@ export interface ProjectMeta {
name: string | undefined;
/** Last modified or opened, for ordering by recency. */
timestamp: number;
+ /** The idea the project was opened from, whose guide it shows. */
+ ideaSlug?: string;
}
/** A project as listed on the pages: its metadata plus what it contains. */
diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx
index ad1de444a..7ddcefce8 100644
--- a/src/pages/HomePage.tsx
+++ b/src/pages/HomePage.tsx
@@ -31,11 +31,11 @@ import { useSettings } from "../settings/settings";
import { createProjectsPageUrl } from "../urls";
import ActionCard from "./ActionCard";
import DefaultPageLayout from "./DefaultPageLayout";
+import IdeaDialog from "./IdeaDialog";
import ProjectIcon from "./ProjectIcon";
import HomepageBanner from "./HomepageBanner";
import HomepageFooter from "./HomepageFooter";
import {
- useCreateProject,
useImportProjectFiles,
usePageProjects,
useProjectPageActions,
@@ -55,7 +55,14 @@ const HomePage = () => {
const intl = useIntl();
const [{ languageId }] = useSettings();
const brand = useDeployment();
- const createProject = useCreateProject("home");
+ const [selectedIdea, setSelectedIdea] = useState<{
+ slug: string;
+ title: string;
+ }>();
+ const selectIdea = useCallback(
+ (slug: string, title: string) => setSelectedIdea({ slug, title }),
+ []
+ );
const importFiles = useImportProjectFiles();
const handleDrop = useCallback(
(files: File[]) => void importFiles(files, "drop"),
@@ -63,6 +70,12 @@ const HomePage = () => {
);
return (
+ {selectedIdea && (
+ setSelectedIdea(undefined)}
+ />
+ )}
{
}
navigation
/>
diff --git a/src/pages/IdeaDialog.tsx b/src/pages/IdeaDialog.tsx
new file mode 100644
index 000000000..acba418dd
--- /dev/null
+++ b/src/pages/IdeaDialog.tsx
@@ -0,0 +1,92 @@
+/**
+ * (c) 2026, Micro:bit Educational Foundation and contributors
+ *
+ * SPDX-License-Identifier: MIT
+ */
+import {
+ Button,
+ Modal,
+ ModalBody,
+ ModalCloseButton,
+ ModalFooter,
+ ModalHeader,
+ Text,
+} from "@microbit/ui";
+import { FormattedMessage } from "react-intl";
+import { Box, Grid, Stack } from "styled-system/jsx";
+import { docStylesClass } from "../common/documentation-styles";
+import Spinner from "../common/Spinner";
+import { useDocumentation } from "../documentation/documentation-hooks";
+import { ideaCode } from "../documentation/ideas/idea-code";
+import IdeaGuideContent from "../documentation/ideas/IdeaGuideContent";
+import CodeMirrorView from "../editor/codemirror/CodeMirrorView";
+import { useOpenIdea } from "./project-page-actions";
+
+interface IdeaDialogProps {
+ slug: string;
+ /** The idea's name, shown before its content loads and given to the project. */
+ title: string;
+ onClose: () => void;
+}
+
+/**
+ * An idea from the home page: its program beside text explaining it, and a
+ * way to open it as a new project.
+ */
+const IdeaDialog = ({ slug, title, onClose }: IdeaDialogProps) => {
+ const { ideas } = useDocumentation();
+ const openIdea = useOpenIdea();
+ const idea =
+ ideas.status === "ok"
+ ? ideas.content.find((idea) => idea.slug.current === slug)
+ : undefined;
+ const code = idea && ideaCode(idea);
+ return (
+
+
+
+ {title}
+
+
+
+ {/* Always rendered, so the code stays in the right-hand column. */}
+
+ {idea && }
+
+
+ {ideas.status === "loading" ? (
+
+ ) : code !== undefined ? (
+
+ ) : (
+
+
+
+ )}
+
+
+
+
+
+
+
+ );
+};
+
+export default IdeaDialog;
diff --git a/src/pages/project-page-actions.tsx b/src/pages/project-page-actions.tsx
index 0e1f95dd9..4e21a345a 100644
--- a/src/pages/project-page-actions.tsx
+++ b/src/pages/project-page-actions.tsx
@@ -17,6 +17,7 @@ import {
useNavigation,
} from "react-router";
import useActionFeedback from "../common/use-action-feedback";
+import { MAIN_FILE } from "../fs/fs";
import { useLogging } from "../logging/logging-hooks";
import { useProjectImporter } from "../project/project-hooks";
import { ImportSource } from "../project/project-import";
@@ -44,19 +45,41 @@ interface CreateProjectFailure {
error: unknown;
}
+/** What the editor route's action reads from its form. */
+interface CreateProjectFields {
+ name: string;
+ /** An idea's program for main.py, with the idea's slug. */
+ idea?: { code: string; slug: string };
+}
+
/**
- * The editor route's action: creates a project named by the form and opens
- * the editor on it at the same URL. Part of the navigation, so the router
- * knows a create is in progress until the editor is showing.
+ * The editor route's action: creates a project from the form and opens the
+ * editor on it at the same URL. Part of the navigation, so the router knows
+ * a create is in progress until the editor is showing.
*/
export const createProjectAction =
(projects: Projects) =>
async ({
request,
}: ActionFunctionArgs): Promise => {
- const name = (await request.formData()).get("name");
+ const form = await request.formData();
+ const field = (key: string) => {
+ const value = form.get(key);
+ return typeof value === "string" ? value : undefined;
+ };
+ const name = field("name") ?? "";
+ const code = field("code");
+ const ideaSlug = field("ideaSlug");
try {
- await projects.create(typeof name === "string" ? name : "");
+ if (code !== undefined && ideaSlug !== undefined) {
+ await projects.createFromFiles(
+ name,
+ { [MAIN_FILE]: new TextEncoder().encode(code) },
+ ideaSlug
+ );
+ } else {
+ await projects.create(name);
+ }
} catch (error) {
return { error };
}
@@ -124,6 +147,37 @@ const useAttempt = () => {
*/
export const useCreateProject = (surface: ProjectSurface) => {
const logging = useLogging();
+ const submit = useSubmitCreateProject();
+ return useCallback(
+ async (name: string, state?: RouterState) => {
+ logging.event({ type: "project_create", detail: { surface } });
+ await submit({ name }, state);
+ },
+ [logging, submit, surface]
+ );
+};
+
+/**
+ * Opens an idea as a new project with its program in main.py, showing its
+ * guide beside the editor.
+ */
+export const useOpenIdea = () => {
+ const logging = useLogging();
+ const submit = useSubmitCreateProject();
+ return useCallback(
+ async (name: string, slug: string, code: string) => {
+ logging.event({ type: "idea_open", detail: { id: slug } });
+ await submit({ name, idea: { code, slug } }, { tab: "guide" });
+ },
+ [logging, submit]
+ );
+};
+
+/**
+ * Submits to the editor route's action (createProjectAction), reporting a
+ * failure as a toast.
+ */
+const useSubmitCreateProject = () => {
const reportError = useReportError();
const fetcher = useFetcher({
key: createProjectFetcherKey,
@@ -136,14 +190,18 @@ export const useCreateProject = (surface: ProjectSurface) => {
}, [failure, reportError]);
const { submit } = fetcher;
return useCallback(
- async (name: string, state?: RouterState) => {
- logging.event({ type: "project_create", detail: { surface } });
- await submit(
- { name },
+ (fields: CreateProjectFields, state?: RouterState) =>
+ submit(
+ {
+ name: fields.name,
+ ...(fields.idea && {
+ code: fields.idea.code,
+ ideaSlug: fields.idea.slug,
+ }),
+ },
{ method: "post", action: createEditorUrl(state) }
- );
- },
- [logging, submit, surface]
+ ),
+ [submit]
);
};
diff --git a/src/pages/resource-cards.tsx b/src/pages/resource-cards.tsx
index ed94ca714..93bbc32cf 100644
--- a/src/pages/resource-cards.tsx
+++ b/src/pages/resource-cards.tsx
@@ -37,13 +37,12 @@ import {
microbitOrgLessonUrl,
microbitOrgProjectUrl,
} from "../external-links";
-import { RouterState } from "../router-hooks";
import { Level } from "./LevelIndicator";
import ResourceCard from "./ResourceCard";
interface Idea {
titleId: string;
- /** The idea's slug in the editor's ideas tab. */
+ /** The idea's slug in the CMS ideas. */
slug: string;
imgSrc: string;
}
@@ -82,23 +81,17 @@ const ideas: Idea[] = [
},
];
-/**
- * Idea cards open the idea in a new project, so following one can't change
- * the user's existing projects.
- */
+/** Idea cards show the idea, by slug, for the user to open or not. */
export const createIdeaCards = (
intl: IntlShape,
- createProject: (name: string, state: RouterState) => Promise
+ onSelect: (slug: string, title: string) => void
) =>
ideas.map((idea) => (
- void createProject(intl.formatMessage({ id: idea.titleId }), {
- tab: "ideas",
- slug: { id: idea.slug },
- })
+ onSelect(idea.slug, intl.formatMessage({ id: idea.titleId }))
}
imgSrc={idea.imgSrc}
// The ideas tab's images are 2:1; show them whole.
diff --git a/src/project/projects-hooks.tsx b/src/project/projects-hooks.tsx
index 11be741e9..a5d5afcc3 100644
--- a/src/project/projects-hooks.tsx
+++ b/src/project/projects-hooks.tsx
@@ -49,3 +49,19 @@ export const useProjectList = (): ProjectListEntry[] => {
);
return useSyncExternalStore(subscribe, () => projects.projects);
};
+
+/**
+ * The idea the editor's open project was opened from, if any. Undefined in
+ * iframe controller mode, which has no projects.
+ */
+export const useOpenIdeaSlug = (): string | undefined => {
+ const projects = useProjectsIfAvailable();
+ const subscribe = useCallback(
+ (listener: () => void) => {
+ projects?.addEventListener("open", listener);
+ return () => projects?.removeEventListener("open", listener);
+ },
+ [projects]
+ );
+ return useSyncExternalStore(subscribe, () => projects?.currentIdeaSlug);
+};
diff --git a/src/project/projects.test.ts b/src/project/projects.test.ts
index 9fe3d9045..85a0aa7b3 100644
--- a/src/project/projects.test.ts
+++ b/src/project/projects.test.ts
@@ -107,6 +107,42 @@ describe("Projects", () => {
expect(fileNames(fs)).toEqual([MAIN_FILE, "helper.py"]);
});
+ it("knows the idea the open project came from", async () => {
+ const { fs, projects } = await setup();
+ await projects.openCurrent();
+ await fs.initialize();
+ const plain = projects.currentId!;
+ const opened = vi.fn();
+ projects.addEventListener("open", opened);
+
+ await projects.createFromFiles(
+ "Dice",
+ { [MAIN_FILE]: new TextEncoder().encode("# dice") },
+ "dice"
+ );
+ expect(projects.currentIdeaSlug).toEqual("dice");
+ expect(opened).toHaveBeenCalledTimes(1);
+
+ await projects.open(plain);
+ expect(projects.currentIdeaSlug).toBeUndefined();
+ expect(opened).toHaveBeenCalledTimes(2);
+ });
+
+ it("duplicates keep the idea", async () => {
+ const { db, fs, projects } = await setup();
+ await projects.openCurrent();
+ await fs.initialize();
+ const idea = await projects.createFromFiles(
+ "Dice",
+ { [MAIN_FILE]: new TextEncoder().encode("# dice") },
+ "dice"
+ );
+
+ const copy = await projects.duplicate(idea, "Dice copy");
+
+ expect((await db!.get(copy))?.ideaSlug).toEqual("dice");
+ });
+
it("renames the open project through the file system and others directly", async () => {
const { db, fs, projects } = await setup();
await projects.openCurrent();
diff --git a/src/project/projects.ts b/src/project/projects.ts
index c89c8c842..40dad662c 100644
--- a/src/project/projects.ts
+++ b/src/project/projects.ts
@@ -41,9 +41,17 @@ export class ProjectSaveErrorEvent extends Event {
}
}
+/** The editor's open project changed. */
+export class ProjectOpenedEvent extends Event {
+ constructor() {
+ super("open");
+ }
+}
+
interface EventMap {
change: ProjectsChangedEvent;
saveerror: ProjectSaveErrorEvent;
+ open: ProjectOpenedEvent;
}
/**
@@ -72,6 +80,7 @@ export class Projects extends TypedEventTarget {
private readonly ready: Promise;
private firstStorageResolved = false;
private openId: string | undefined;
+ private openIdeaSlug: string | undefined;
private openStorage: IndexedDBFSStorage | undefined;
private opening: Promise | undefined;
private cachedList: ProjectListEntry[] = [];
@@ -125,6 +134,11 @@ export class Projects extends TypedEventTarget {
return this.openId;
}
+ /** The idea the open project was opened from, if any. */
+ get currentIdeaSlug(): string | undefined {
+ return this.openIdeaSlug;
+ }
+
async refresh(): Promise {
await this.ready;
this.cachedList = this.db ? await this.db.listWithFileNames() : [];
@@ -182,16 +196,17 @@ export class Projects extends TypedEventTarget {
}
/**
- * Creates a project with the given files, e.g. from an imported hex, and
- * opens it in the editor.
+ * Creates a project with the given files, e.g. from an imported hex or an
+ * idea, and opens it in the editor.
*/
async createFromFiles(
name: string | undefined,
- files: Record
+ files: Record,
+ ideaSlug?: string
): Promise {
const db = await this.requireDb();
const id = generateId();
- await db.create({ id, name, timestamp: Date.now() }, files);
+ await db.create({ id, name, timestamp: Date.now(), ideaSlug }, files);
await this.switchTo(id);
await this.changed([id]);
return id;
@@ -215,7 +230,13 @@ export class Projects extends TypedEventTarget {
await this.openStorage?.flush();
}
const newId = generateId();
- await db.duplicate(id, { id: newId, name, timestamp: Date.now() });
+ const { ideaSlug } = (await db.get(id)) ?? {};
+ await db.duplicate(id, {
+ id: newId,
+ name,
+ timestamp: Date.now(),
+ ideaSlug,
+ });
await this.changed([newId]);
return newId;
}
@@ -266,8 +287,10 @@ export class Projects extends TypedEventTarget {
this.logging
);
this.openId = id;
+ this.openIdeaSlug = (await db.get(id))?.ideaSlug;
this.openStorage = indexed;
setCurrentProjectId(this.session, id);
+ this.dispatchTypedEvent("open", new ProjectOpenedEvent());
if (this.firstStorageResolved) {
await this.fs.switchStorage(storage);
} else {
@@ -282,6 +305,7 @@ export class Projects extends TypedEventTarget {
*/
private letGoOfOpenProject(): void {
this.openId = undefined;
+ this.openIdeaSlug = undefined;
this.openStorage = undefined;
if (getCurrentProjectId(this.session)) {
setCurrentProjectId(this.session, undefined);
diff --git a/src/router-hooks.tsx b/src/router-hooks.tsx
index 62eb7d14f..2786580fe 100644
--- a/src/router-hooks.tsx
+++ b/src/router-hooks.tsx
@@ -15,9 +15,15 @@ import { useLogging } from "./logging/logging-hooks";
import { createEditorUrl, createIframeEditorUrl } from "./urls";
import { useIframeMode } from "./iframe-mode-hooks";
-export type TabName = "api" | "ideas" | "reference" | "project";
+export type TabName = "api" | "guide" | "ideas" | "reference" | "project";
-const tabNames: readonly string[] = ["api", "ideas", "reference", "project"];
+const tabNames: readonly string[] = [
+ "api",
+ "guide",
+ "ideas",
+ "reference",
+ "project",
+];
export const isTabName = (value: string | undefined): value is TabName =>
value !== undefined && tabNames.includes(value);
diff --git a/src/workbench/SideBar.tsx b/src/workbench/SideBar.tsx
index 335593577..04b2742a7 100644
--- a/src/workbench/SideBar.tsx
+++ b/src/workbench/SideBar.tsx
@@ -3,7 +3,7 @@
*
* SPDX-License-Identifier: MIT
*/
-import { Icon, usePrevious } from "@microbit/ui";
+import { usePrevious } from "@microbit/ui";
import {
ReactNode,
useCallback,
@@ -14,23 +14,29 @@ import {
} from "react";
import { TabList, TabPanel, TabPanels, Tabs } from "react-aria-components";
import { IconType } from "react-icons";
-import { RiHome2Line, RiLightbulbFlashLine } from "react-icons/ri";
+import {
+ RiBookOpenLine,
+ RiHome2Line,
+ RiLightbulbFlashLine,
+} from "react-icons/ri";
import { VscFiles, VscLibrary } from "react-icons/vsc";
import { useIntl } from "react-intl";
import { Link as RouterLink } from "react-router";
import { css, cx } from "styled-system/css";
-import { Flex, styled, VStack } from "styled-system/jsx";
+import { Box, Flex, styled, VStack } from "styled-system/jsx";
import { button } from "styled-system/recipes";
import { SystemStyleObject } from "styled-system/types";
import ErrorBoundary from "../common/ErrorBoundary";
import PythonLogo from "../common/PythonLogo";
import ApiArea from "../documentation/ApiArea";
+import GuideArea from "../documentation/GuideArea";
import IdeasArea from "../documentation/IdeasArea";
import ReferenceArea from "../documentation/ReferenceArea";
import { flags } from "../flags";
import { useProjectsDatabaseActive } from "../fs/storage-status";
import { useIframeMode } from "../iframe-mode-hooks";
import ProjectArea from "../project/ProjectArea";
+import { useOpenIdeaSlug } from "../project/projects-hooks";
import { TabName, useRouterState } from "../router-hooks";
import SettingsMenu from "../settings/SettingsMenu";
import { createHomePageUrl } from "../urls";
@@ -48,7 +54,6 @@ export interface Pane {
title: string;
contents: ReactNode;
color: string;
- mb?: string;
}
interface SideBarProps {
@@ -81,8 +86,22 @@ const SideBar = ({
}: SideBarProps) => {
const intl = useIntl();
const [releaseDialog, setReleaseDialog] = useReleaseDialogState();
+ // Projects opened from an idea on the home page have its guide instead of
+ // the list of ideas.
+ const hasGuide = useOpenIdeaSlug() !== undefined;
const panes: Pane[] = useMemo(() => {
- const result = [
+ const result: Pane[] = [
+ ...(hasGuide
+ ? [
+ {
+ id: "guide" as const,
+ title: intl.formatMessage({ id: "guide-tab" }),
+ icon: RiBookOpenLine,
+ contents: ,
+ color: "gray.75",
+ },
+ ]
+ : []),
{
id: "reference" as const,
title: intl.formatMessage({ id: "reference-tab" }),
@@ -90,20 +109,23 @@ const SideBar = ({
contents: ,
color: "gray.75",
},
- {
- id: "ideas" as const,
- title: intl.formatMessage({ id: "ideas-tab" }),
- icon: RiLightbulbFlashLine,
- contents: ,
- color: "gray.75",
- },
+ ...(hasGuide
+ ? []
+ : [
+ {
+ id: "ideas" as const,
+ title: intl.formatMessage({ id: "ideas-tab" }),
+ icon: RiLightbulbFlashLine,
+ contents: ,
+ color: "gray.75",
+ },
+ ]),
{
id: "api" as const,
title: intl.formatMessage({ id: "api-tab" }),
icon: PythonLogo as IconType,
contents: ,
color: "gray.75",
- mb: "auto",
},
{
id: "project" as const,
@@ -119,7 +141,7 @@ const SideBar = ({
},
];
return result;
- }, [onSelectedFileChanged, selectedFile, intl]);
+ }, [hasGuide, onSelectedFileChanged, selectedFile, intl]);
const [{ tab, slug, focus }, setParams] = useRouterState();
const tabPanelsRef = useRef(null);
const setPanelFocus = () => {
@@ -240,20 +262,9 @@ const SideBar = ({
branded background (a wrapper because react-aria's TabList may
only contain tabs). */}
-
-
-
+
+
@@ -326,8 +338,8 @@ const SideBar = ({
};
/**
- * Home, above the tabs and apart from them. Only where there is a home page,
- * as for the header logo.
+ * Home, above the settings and help menus and styled as they are. Only where
+ * there is a home page, as for the header logo.
*/
const SideBarHomeLink = () => {
const intl = useIntl();
@@ -342,11 +354,11 @@ const SideBarHomeLink = () => {
aria-label={intl.formatMessage({ id: "home-action" })}
className={cx(
button({ variant: "sidebar", size: "lg" }),
- css({ px: "0" })
+ // As IconButton (px) and the menus' fontSize.
+ css({ px: "0", fontSize: "xl" })
)}
>
- {/* Sized as the tab icons rather than the menus below. */}
-
+
);
};
diff --git a/src/workbench/SideBarTab.tsx b/src/workbench/SideBarTab.tsx
index 1029d912f..ce42e71cf 100644
--- a/src/workbench/SideBarTab.tsx
+++ b/src/workbench/SideBarTab.tsx
@@ -12,7 +12,6 @@ import { cornerSize, Pane } from "./SideBar";
interface SideBarTabProps extends Pane {
color: string;
- mb?: string;
handleTabClick: (id: Pane["id"]) => void;
active: boolean;
tabIndex: number;
@@ -23,7 +22,6 @@ const SideBarTab = ({
icon,
title,
color,
- mb,
handleTabClick,
active,
}: SideBarTabProps) => {
@@ -51,7 +49,6 @@ const SideBarTab = ({
p: "0",
position: "relative",
ml: "6px",
- mb: mb ? "auto" : "0",
borderRadius: "32px 0 0 32px",
transition: "none",
cursor: "pointer",