e.preventDefault()}>
-
- {/* need a container with position: relative; for SplitterLayout since it uses position: absolute; */}
-
-
setIsDragging(true)}
- onDragEnd={(): void => setIsDragging(false)}
- percentage={true}
- secondaryInitialSize={docsSplit}
- onSecondaryPaneSizeChange={setDocsSplit}
- >
-
-
-
- }
+
+
+
+
+
-
+
+
+
+
+
+
+ }
+ >
+
+
+
+
+
+ {sideViewButtons}
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
diff --git a/src/app/app.scss b/src/app/app.scss
index 97c7318fa..bd95a6193 100644
--- a/src/app/app.scss
+++ b/src/app/app.scss
@@ -12,194 +12,224 @@
@import '@blueprintjs/icons/lib/css/blueprint-icons.css';
+// Root app container: fills the viewport, stacks toolbar/body/statusbar vertically.
.pb-app {
width: 100%;
height: 100%;
- background-color: bp.$pt-app-background-color;
display: flex;
flex-direction: column;
+ overflow: hidden;
+ background-color: bp.$pt-app-background-color;
.#{bp.$ns}-dark & {
background-color: bp.$pt-dark-app-background-color;
}
- &-activities {
- display: flex;
- }
-
- &-terminal {
- height: 100%;
- padding-top: 8px;
- padding-left: 8px;
- background-color: white;
-
- .#{bp.$ns}-dark & {
- background-color: black;
- }
- }
-
- &-docs {
- height: 100%;
-
- &-drag-helper {
- height: 100%;
- width: 100%;
- position: absolute;
- }
- }
-
& iframe {
border: none;
}
}
+// Row containing the activities sidebar and the main content area.
.pb-app-body {
+ flex: 1 1 0;
min-height: 0;
- flex: 1 1 auto;
+ display: flex;
+ flex-direction: row;
+ overflow: hidden;
+}
+// Thin activities sidebar on the left, shown when an activity is selected.
+.pb-app-activities {
+ flex: none;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ @include pb.background-contrast(0%);
+}
+
+// Row containing the activities panel and the resizable editor/terminal panels.
+.pb-app-body-inner {
+ flex: 1 1 0;
+ min-height: 0;
display: flex;
flex-direction: row;
+ overflow: hidden;
+}
- .pb-app-main {
- min-width: 0;
- flex: 1 1 auto;
- @include pb.background-contrast(4%);
- }
+// Main content: toolbar on top, resizable panels below. Fills its Panel.
+.pb-app-main {
+ position: relative;
+ width: 100%;
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ @include pb.background-contrast(4%);
}
+// Wraps the horizontal editor+camera PanelGroup. position: relative anchors
+// the absolutely-positioned side-view buttons to this container instead of
+// the editor panel, so they stay visible even when the camera is expanded.
+.pb-app-editor-area {
+ position: relative;
+ width: 100%;
+ height: 100%;
+}
+
+// The editor
element lives inside a resizable Panel - fill it entirely.
.pb-app-editor {
+ width: 100%;
height: 100%;
display: flex;
flex-direction: column;
- position: relative;
+ overflow: hidden;
+}
+
+// Terminal panel fills its resizable panel.
+.pb-app-terminal {
+ width: 100%;
+ height: 100%;
+ padding-top: 8px;
+ padding-left: 8px;
+ box-sizing: border-box;
+ overflow: hidden;
+ background-color: white;
- & .pb-editor {
- min-height: 0;
- flex: 1 1 auto;
+ .#{bp.$ns}-dark & {
+ background-color: black;
}
}
-.pb-app-doc-button {
+// Side panel fills its resizable panel.
+.pb-app-side-panel {
+ position: relative;
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
+}
+
+.pb-app-side-view-buttons {
position: absolute;
bottom: bp.$pt-grid-size;
right: bp.$pt-grid-size;
- z-index: bp.$pt-z-index-overlay;
+ z-index: bp.$pt-z-index-content;
+ display: flex;
+ flex-direction: row;
+ gap: bp.$pt-grid-size * 0.5;
+
+ // match toolbar button appearance: no box-shadow border or active-state shadow
+ & * {
+ box-shadow: unset !important;
+ }
}
+// Splitter color variables
$splitter-background-color: bp.$light-gray2;
$splitter-background-color-hover: bp.$gray4;
$dark-splitter-background-color: bp.$dark-gray5;
$dark-splitter-background-color-hover: bp.$gray2;
-$splitter-constrast: -15%;
-$dark-splitter-constrast: 20%;
+$splitter-contrast: -15%;
+$dark-splitter-contrast: 20%;
$splitter-color: color.adjust(
$splitter-background-color,
- $lightness: $splitter-constrast
+ $lightness: $splitter-contrast
);
$splitter-color-hover: color.adjust(
$splitter-background-color-hover,
- $lightness: $splitter-constrast
+ $lightness: $splitter-contrast
);
$dark-splitter-color: color.adjust(
$dark-splitter-background-color,
- $lightness: $dark-splitter-constrast
+ $lightness: $dark-splitter-contrast
);
$dark-splitter-color-hover: color.adjust(
$dark-splitter-background-color-hover,
- $lightness: $dark-splitter-constrast
+ $lightness: $dark-splitter-contrast
);
-.layout-splitter:before {
- $width: 1.5;
- $height: 2.7;
- $radius: 0.4;
-
- content: map.get(bpi.$blueprint-icon-codepoints, 'drag-handle-vertical') / '';
- height: bp.$pt-grid-size * $height;
- width: bp.$pt-grid-size * $width;
- top: calc(50% - bp.$pt-grid-size * $height * 0.5);
- left: bp.$pt-grid-size * $width * -0.5 + 2px;
-
- .splitter-layout-vertical & {
- $width: 2.7;
- $height: 1.4;
-
- content: map.get(bpi.$blueprint-icon-codepoints, 'drag-handle-horizontal') / '';
- height: bp.$pt-grid-size * $height;
- width: bp.$pt-grid-size * $width;
- top: bp.$pt-grid-size * $height * -0.5 + 2px;
- left: calc(50% - bp.$pt-grid-size * $width * 0.5);
- }
-
- font-family: bpi.$blueprint-icons-16-font;
- font-size: large;
+// Panel resize handles (splitters).
+.pb-splitter {
display: flex;
- position: relative;
- border-radius: bp.$pt-grid-size * $radius;
- padding: bp.$pt-grid-size * 0.5;
align-items: center;
justify-content: center;
- z-index: bp.$pt-z-index-content;
-
- color: $splitter-color;
background-color: $splitter-background-color;
+ &:hover {
+ background-color: $splitter-background-color-hover;
+ }
+
.#{bp.$ns}-dark & {
- color: $dark-splitter-color;
background-color: $dark-splitter-background-color;
+
+ &:hover {
+ background-color: $dark-splitter-background-color-hover;
+ }
}
-}
-// make layout splitter match app color scheme
-.splitter-layout > .layout-splitter {
- color: $splitter-color;
- background-color: $splitter-background-color;
+ // Drag-handle icon via ::before pseudo-element.
+ &::before {
+ $radius: 0.4;
- &:hover,
- &:hover:before {
- color: $splitter-color-hover;
- background-color: $splitter-background-color-hover;
+ font-family: bpi.$blueprint-icons-16-font;
+ font-size: large;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: bp.$pt-grid-size * $radius;
+ padding: bp.$pt-grid-size * 0.5;
+ z-index: bp.$pt-z-index-content;
+
+ color: $splitter-color;
+ background-color: $splitter-background-color;
+
+ .#{bp.$ns}-dark & {
+ color: $dark-splitter-color;
+ background-color: $dark-splitter-background-color;
+ }
}
- .#{bp.$ns}-dark & {
- color: $dark-splitter-color;
- background-color: $dark-splitter-background-color;
+ &:hover::before {
+ color: $splitter-color-hover;
+ background-color: $splitter-background-color-hover;
- &:hover,
- &:hover:before {
+ .#{bp.$ns}-dark & {
color: $dark-splitter-color-hover;
background-color: $dark-splitter-background-color-hover;
}
}
-}
-// we want overflow visible for editor overlays but it breaks resizing
-// so we can only apply this when not resizing
-.splitter-layout:not(.layout-changing) > .layout-pane:has(.pb-app-editor) {
- overflow: visible;
-}
+ // Vertical splitter (between left/right panels): thin column, vertical drag icon.
+ &--vertical {
+ width: 4px;
+ cursor: col-resize;
-.splitter-layout .layout-pane {
- // fix double scroll bars in terminal when resizing
- overflow: hidden;
+ &::before {
+ $width: 1.5;
+ $height: 2.7;
- &.layout-pane-primary {
- // fix resizing window vertically
- min-height: 0;
+ content: map.get(bpi.$blueprint-icon-codepoints, 'drag-handle-vertical') /
+ '';
+ width: bp.$pt-grid-size * $width;
+ height: bp.$pt-grid-size * $height;
+ }
}
-}
-// hide the docs and resize separator
-// Use visibility rather than display:none so the iframe keeps its layout and
-// scroll position when the docs panel is toggled.
-// Collapse the secondary pane and splitter to zero width so they don't
-// consume space, while keeping the iframe in the DOM.
-div.pb-hide-docs > :not(.layout-pane-primary) {
- visibility: hidden;
- pointer-events: none;
- flex-basis: 0 !important;
- width: 0 !important;
- min-width: 0 !important;
+ // Horizontal splitter (between top/bottom panels): thin row, horizontal drag icon.
+ &--horizontal {
+ height: 4px;
+ cursor: row-resize;
+
+ &::before {
+ $width: 2.7;
+ $height: 1.4;
+
+ content: map.get(bpi.$blueprint-icon-codepoints, 'drag-handle-horizontal') /
+ '';
+ width: bp.$pt-grid-size * $width;
+ height: bp.$pt-grid-size * $height;
+ }
+ }
}
diff --git a/src/app/hooks.ts b/src/app/hooks.ts
new file mode 100644
index 000000000..65e9c5d34
--- /dev/null
+++ b/src/app/hooks.ts
@@ -0,0 +1,77 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 The Pybricks Authors
+
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { PanelSize, usePanelRef } from 'react-resizable-panels';
+
+/**
+ * Hook to manage a collapsible panel's size as a percentage (0–100).
+ *
+ * This bridges React's declarative state with the library's imperative
+ * collapse/expand/resize API:
+ * - `size` tracks the panel's percentage when visible.
+ * - `visible` controls whether the panel is collapsed or expanded.
+ * - Setting `visible` to true restores the previous `size`.
+ * - Setting `size` also updates `visible` accordingly.
+ * - The `onResize` callback syncs both states when the user drags.
+ *
+ * The panel should set `collapsible`, `collapsedSize="0%"`, and a `minSize`
+ * so that dragging below the minimum auto-collapses.
+ */
+export function useCollapsiblePanel(initiallyVisble: boolean, initialSize: number) {
+ const panelRef = usePanelRef();
+ const [size, _setSize] = useState(initialSize);
+ const [visible, _setVisible] = useState(initiallyVisble);
+
+ const setSize = useCallback((s: number) => {
+ _setSize(s);
+ _setVisible(s > 0);
+ }, []);
+
+ const setVisible = useCallback((v: boolean) => _setVisible(v), []);
+
+ useEffect(() => {
+ const panel = panelRef.current;
+ if (!panel) {
+ return;
+ }
+ if (!visible) {
+ if (!panel.isCollapsed()) {
+ panel.collapse();
+ }
+ } else {
+ if (panel.isCollapsed()) {
+ panel.expand();
+ }
+ if (panel.getSize().asPercentage !== size) {
+ panel.resize(size + '%');
+ }
+ }
+ }, [size, visible, panelRef]);
+
+ const onResize = useCallback(
+ (panelSize: PanelSize) => {
+ const collapsed = panelRef.current?.isCollapsed() ?? false;
+ if (collapsed) {
+ // Only update state if the user dragged to collapse
+ // (visible is still true). If we collapsed programmatically,
+ // visible is already false and size should be preserved.
+ if (visible) {
+ _setVisible(false);
+ // Reset to initial size so reopening looks normal
+ // rather than tiny.
+ _setSize(initialSize);
+ }
+ } else {
+ _setVisible(true);
+ _setSize(panelSize.asPercentage);
+ }
+ },
+ [panelRef, initialSize, visible],
+ );
+
+ return useMemo(
+ () => ({ panelRef, size, setSize, visible, setVisible, onResize }),
+ [panelRef, size, setSize, visible, setVisible, onResize],
+ );
+}
diff --git a/src/app/translations/en.json b/src/app/translations/en.json
index e5f384f17..93d96d72b 100644
--- a/src/app/translations/en.json
+++ b/src/app/translations/en.json
@@ -8,5 +8,9 @@
"docs": {
"show": "Show documentation",
"hide": "Hide documentation"
+ },
+ "terminal": {
+ "show": "Show terminal",
+ "hide": "Hide terminal"
}
}
diff --git a/src/editor/editor.scss b/src/editor/editor.scss
index aa12a1ee7..42b7e659c 100644
--- a/src/editor/editor.scss
+++ b/src/editor/editor.scss
@@ -1,5 +1,5 @@
// SPDX-License-Identifier: MIT
-// Copyright (c) 2020-2023 The Pybricks Authors
+// Copyright (c) 2020-2026 The Pybricks Authors
// Custom styling for the Editor control.
@@ -21,8 +21,12 @@
}
.pb-editor {
+ flex: 1 1 0;
+ min-height: 0;
+ min-width: 0;
display: flex;
flex-direction: column;
+ overflow: hidden;
&-tablist {
flex: none;
@@ -58,9 +62,12 @@
}
}
+ // The ContextMenu/tabpanel fills all remaining vertical space.
&-tabpanel {
+ flex: 1 1 0;
min-height: 0;
- flex: 1 1 auto;
+ position: relative;
+ overflow: hidden;
}
&-welcome {
@@ -73,13 +80,10 @@
}
}
+ // The monaco editor div fills the tabpanel entirely.
&-monaco {
width: 100%;
height: 100%;
-
- .pb-editor-tabpanel.pb-empty > & {
- display: none;
- }
}
&-placeholder {
diff --git a/src/setupTests.ts b/src/setupTests.ts
index 0d2fb2a4e..c3fa3851c 100644
--- a/src/setupTests.ts
+++ b/src/setupTests.ts
@@ -38,6 +38,43 @@ if (!Element.prototype.scrollTo) {
Element.prototype.scrollTo = jest.fn();
}
+// ResizeObserver is not implemented in jsdom but is required by react-resizable-panels
+global.ResizeObserver = class ResizeObserver {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+};
+
+// DOMRect is not implemented in jsdom but is required by react-resizable-panels
+if (typeof global.DOMRect === 'undefined') {
+ global.DOMRect = class DOMRect {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+ top: number;
+ right: number;
+ bottom: number;
+ left: number;
+ constructor(x = 0, y = 0, width = 0, height = 0) {
+ this.x = x;
+ this.y = y;
+ this.width = width;
+ this.height = height;
+ this.top = y;
+ this.right = x + width;
+ this.bottom = y + height;
+ this.left = x;
+ }
+ toJSON() {
+ return JSON.stringify(this);
+ }
+ static fromRect(rect?: DOMRectInit): DOMRect {
+ return new DOMRect(rect?.x, rect?.y, rect?.width, rect?.height);
+ }
+ } as unknown as typeof DOMRect;
+}
+
Object.defineProperty(global.self, 'crypto', {
value: crypto.webcrypto,
});
diff --git a/src/toolbar/Toolbar.tsx b/src/toolbar/Toolbar.tsx
index 67400c265..dae47ef73 100644
--- a/src/toolbar/Toolbar.tsx
+++ b/src/toolbar/Toolbar.tsx
@@ -1,13 +1,15 @@
// SPDX-License-Identifier: MIT
-// Copyright (c) 2020-2025 The Pybricks Authors
+// Copyright (c) 2020-2026 The Pybricks Authors
import { ButtonGroup } from '@blueprintjs/core';
import React from 'react';
import { useId } from 'react-aria';
import { Toolbar as UtilsToolbar } from '../components/toolbar/Toolbar';
import BluetoothButton from './buttons/bluetooth/BluetoothButton';
+import ExplorerButton from './buttons/explorer/ExplorerButton';
import ReplButton from './buttons/repl/ReplButton';
import RunButton from './buttons/run/RunButton';
+import SettingsButton from './buttons/settings/SettingsButton';
import SponsorButton from './buttons/sponsor/SponsorButton';
import StopButton from './buttons/stop/StopButton';
import UsbButton from './buttons/usb/UsbButton';
@@ -32,6 +34,10 @@ const Toolbar: React.FunctionComponent = () => {
className="pb-toolbar"
firstFocusableItemId={bluetoothButtonId}
>
+
+
+
+
diff --git a/src/toolbar/buttons/explorer/ExplorerButton.test.tsx b/src/toolbar/buttons/explorer/ExplorerButton.test.tsx
new file mode 100644
index 000000000..baefbf9cc
--- /dev/null
+++ b/src/toolbar/buttons/explorer/ExplorerButton.test.tsx
@@ -0,0 +1,56 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2025 The Pybricks Authors
+
+import { act, cleanup } from '@testing-library/react';
+import React from 'react';
+import { FocusScope } from 'react-aria';
+import { testRender } from '../../../../test';
+import { Activity } from '../../../activities/hooks';
+import ExplorerButton from './ExplorerButton';
+
+afterEach(() => {
+ cleanup();
+ jest.resetAllMocks();
+ localStorage.clear();
+ sessionStorage.clear();
+});
+
+const explorerButtonId = 'test-explorer-button';
+
+it('should open explorer activity when clicked', async () => {
+ sessionStorage.setItem(
+ 'activities.selectedActivity',
+ JSON.stringify(Activity.None),
+ );
+
+ const [user, button] = testRender(
+
+
+ ,
+ );
+
+ await act(() => user.click(button.getByRole('button', { name: 'File Explorer' })));
+
+ expect(sessionStorage.getItem('activities.selectedActivity')).toBe(
+ JSON.stringify(Activity.Explorer),
+ );
+});
+
+it('should close explorer activity when clicked while already active', async () => {
+ sessionStorage.setItem(
+ 'activities.selectedActivity',
+ JSON.stringify(Activity.Explorer),
+ );
+
+ const [user, button] = testRender(
+
+
+ ,
+ );
+
+ await act(() => user.click(button.getByRole('button', { name: 'File Explorer' })));
+
+ expect(sessionStorage.getItem('activities.selectedActivity')).toBe(
+ JSON.stringify(Activity.None),
+ );
+});
diff --git a/src/toolbar/buttons/explorer/ExplorerButton.tsx b/src/toolbar/buttons/explorer/ExplorerButton.tsx
new file mode 100644
index 000000000..c36b0b61d
--- /dev/null
+++ b/src/toolbar/buttons/explorer/ExplorerButton.tsx
@@ -0,0 +1,37 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 The Pybricks Authors
+
+import React from 'react';
+import { Activity, useActivitiesSelectedActivity } from '../../../activities/hooks';
+import ActionButton, { ActionButtonProps } from '../../ActionButton';
+import { useI18n } from './i18n';
+import icon from './icon.svg';
+
+type ExplorerButtonProps = Pick;
+
+const ExplorerButton: React.FunctionComponent = ({ id }) => {
+ const i18n = useI18n();
+ const [selectedActivity, setSelectedActivity] = useActivitiesSelectedActivity();
+
+ return (
+
+ setSelectedActivity(
+ selectedActivity === Activity.Explorer
+ ? Activity.None
+ : Activity.Explorer,
+ )
+ }
+ />
+ );
+};
+
+export default ExplorerButton;
diff --git a/src/toolbar/buttons/explorer/i18n.ts b/src/toolbar/buttons/explorer/i18n.ts
new file mode 100644
index 000000000..43b1db152
--- /dev/null
+++ b/src/toolbar/buttons/explorer/i18n.ts
@@ -0,0 +1,19 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2025 The Pybricks Authors
+
+import { useI18n as useShopifyI18n } from '@shopify/react-i18n';
+import type { TypedI18n } from '../../../i18n';
+
+type Translations = {
+ label: string;
+ tooltip: {
+ show: string;
+ hide: string;
+ };
+};
+
+export function useI18n(): TypedI18n {
+ // istanbul ignore next: babel-loader rewrites this line
+ const [i18n] = useShopifyI18n();
+ return i18n;
+}
diff --git a/src/toolbar/buttons/explorer/icon.svg b/src/toolbar/buttons/explorer/icon.svg
new file mode 100644
index 000000000..208867271
--- /dev/null
+++ b/src/toolbar/buttons/explorer/icon.svg
@@ -0,0 +1,4 @@
+
+
diff --git a/src/toolbar/buttons/explorer/translations/en.json b/src/toolbar/buttons/explorer/translations/en.json
new file mode 100644
index 000000000..a9c553ec6
--- /dev/null
+++ b/src/toolbar/buttons/explorer/translations/en.json
@@ -0,0 +1,7 @@
+{
+ "label": "File Explorer",
+ "tooltip": {
+ "show": "Show file explorer",
+ "hide": "Hide file explorer"
+ }
+}
diff --git a/src/toolbar/buttons/settings/SettingsButton.test.tsx b/src/toolbar/buttons/settings/SettingsButton.test.tsx
new file mode 100644
index 000000000..b20e07ca9
--- /dev/null
+++ b/src/toolbar/buttons/settings/SettingsButton.test.tsx
@@ -0,0 +1,56 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2025 The Pybricks Authors
+
+import { act, cleanup } from '@testing-library/react';
+import React from 'react';
+import { FocusScope } from 'react-aria';
+import { testRender } from '../../../../test';
+import { Activity } from '../../../activities/hooks';
+import SettingsButton from './SettingsButton';
+
+afterEach(() => {
+ cleanup();
+ jest.resetAllMocks();
+ localStorage.clear();
+ sessionStorage.clear();
+});
+
+const settingsButtonId = 'test-settings-button';
+
+it('should open settings activity when clicked', async () => {
+ sessionStorage.setItem(
+ 'activities.selectedActivity',
+ JSON.stringify(Activity.None),
+ );
+
+ const [user, button] = testRender(
+
+
+ ,
+ );
+
+ await act(() => user.click(button.getByRole('button', { name: 'Settings' })));
+
+ expect(sessionStorage.getItem('activities.selectedActivity')).toBe(
+ JSON.stringify(Activity.Settings),
+ );
+});
+
+it('should close settings activity when clicked while already active', async () => {
+ sessionStorage.setItem(
+ 'activities.selectedActivity',
+ JSON.stringify(Activity.Settings),
+ );
+
+ const [user, button] = testRender(
+
+
+ ,
+ );
+
+ await act(() => user.click(button.getByRole('button', { name: 'Settings' })));
+
+ expect(sessionStorage.getItem('activities.selectedActivity')).toBe(
+ JSON.stringify(Activity.None),
+ );
+});
diff --git a/src/toolbar/buttons/settings/SettingsButton.tsx b/src/toolbar/buttons/settings/SettingsButton.tsx
new file mode 100644
index 000000000..ed2f0dc66
--- /dev/null
+++ b/src/toolbar/buttons/settings/SettingsButton.tsx
@@ -0,0 +1,37 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 The Pybricks Authors
+
+import React from 'react';
+import { Activity, useActivitiesSelectedActivity } from '../../../activities/hooks';
+import ActionButton, { ActionButtonProps } from '../../ActionButton';
+import { useI18n } from './i18n';
+import icon from './icon.svg';
+
+type SettingsButtonProps = Pick;
+
+const SettingsButton: React.FunctionComponent = ({ id }) => {
+ const i18n = useI18n();
+ const [selectedActivity, setSelectedActivity] = useActivitiesSelectedActivity();
+
+ return (
+
+ setSelectedActivity(
+ selectedActivity === Activity.Settings
+ ? Activity.None
+ : Activity.Settings,
+ )
+ }
+ />
+ );
+};
+
+export default SettingsButton;
diff --git a/src/toolbar/buttons/settings/i18n.ts b/src/toolbar/buttons/settings/i18n.ts
new file mode 100644
index 000000000..43b1db152
--- /dev/null
+++ b/src/toolbar/buttons/settings/i18n.ts
@@ -0,0 +1,19 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2025 The Pybricks Authors
+
+import { useI18n as useShopifyI18n } from '@shopify/react-i18n';
+import type { TypedI18n } from '../../../i18n';
+
+type Translations = {
+ label: string;
+ tooltip: {
+ show: string;
+ hide: string;
+ };
+};
+
+export function useI18n(): TypedI18n {
+ // istanbul ignore next: babel-loader rewrites this line
+ const [i18n] = useShopifyI18n();
+ return i18n;
+}
diff --git a/src/toolbar/buttons/settings/icon.svg b/src/toolbar/buttons/settings/icon.svg
new file mode 100644
index 000000000..2de9db518
--- /dev/null
+++ b/src/toolbar/buttons/settings/icon.svg
@@ -0,0 +1,4 @@
+
+
diff --git a/src/toolbar/buttons/settings/translations/en.json b/src/toolbar/buttons/settings/translations/en.json
new file mode 100644
index 000000000..ad5a6fa15
--- /dev/null
+++ b/src/toolbar/buttons/settings/translations/en.json
@@ -0,0 +1,7 @@
+{
+ "label": "Settings",
+ "tooltip": {
+ "show": "Show settings and help",
+ "hide": "Hide settings and help"
+ }
+}
diff --git a/yarn.lock b/yarn.lock
index 4f49ce27c..c60a9b5a0 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2994,7 +2994,6 @@ __metadata:
"@types/node": ^18.19.14
"@types/react": ^18.2.47
"@types/react-dom": ^18.2.18
- "@types/react-splitter-layout": ^3.0.5
"@types/react-transition-group": ^4.4.10
"@types/redux-logger": ^3.0.12
"@types/semver": ^7.5.6
@@ -3075,7 +3074,7 @@ __metadata:
react-popper: ^2.3.0
react-redux: ^8.1.3
react-refresh: ^0.14.0
- react-splitter-layout: ^4.0.0
+ react-resizable-panels: ^4.8.0
redux: ^4.2.1
redux-logger: ^3.0.6
redux-saga: ^1.3.0
@@ -5341,15 +5340,6 @@ __metadata:
languageName: node
linkType: hard
-"@types/react-splitter-layout@npm:^3.0.5":
- version: 3.0.5
- resolution: "@types/react-splitter-layout@npm:3.0.5"
- dependencies:
- "@types/react": "*"
- checksum: 9c8e9cc7ac568a3f5c7a1237725ce084d4c0747f91f7da359b9d5bf2439b33c99f3b90223443854d9602af84a1fad1ffe159ac0a2b77d4a42588e17044c136ec
- languageName: node
- linkType: hard
-
"@types/react-transition-group@npm:^4.4.10":
version: 4.4.10
resolution: "@types/react-transition-group@npm:4.4.10"
@@ -14778,13 +14768,13 @@ __metadata:
languageName: node
linkType: hard
-"react-splitter-layout@npm:^4.0.0":
- version: 4.0.0
- resolution: "react-splitter-layout@npm:4.0.0"
+"react-resizable-panels@npm:^4.8.0":
+ version: 4.8.0
+ resolution: "react-resizable-panels@npm:4.8.0"
peerDependencies:
- prop-types: ^15.5.0
- react: ^15.5.0 || ^16.0.0
- checksum: 52fa6ec01f18df0938687089d85cfdf1950f04c7b611d973edc6fc6b553d1844b414c6d92e21e7de8043a29b21faca9a31b87457328bca7c424493db2c866700
+ react: ^18.0.0 || ^19.0.0
+ react-dom: ^18.0.0 || ^19.0.0
+ checksum: 387f54de46e0727539912878279046816374438f08f0ca2c31ded71a40a65bc68cbab592ba7410469737f3ae120a7c973caebebd19d9dc65ae29019e94c8b93c
languageName: node
linkType: hard