diff --git a/craft/src/panels/right/GuidedStyles.tsx b/craft/src/panels/right/GuidedStyles.tsx
index 7a09988..b0087b9 100644
--- a/craft/src/panels/right/GuidedStyles.tsx
+++ b/craft/src/panels/right/GuidedStyles.tsx
@@ -30,7 +30,12 @@ import {
Main GuidedStyles component
================================================================ */
-export const GuidedStyles: React.FC = () => {
+export const GuidedStyles: React.FC<{
+ /** What to render with nothing selected: the Site Design panel (default,
+ * used by the mobile sheet) or a placeholder (desktop RightPanel, which
+ * has a dedicated Site tab). */
+ emptyFallback?: 'site' | 'placeholder';
+}> = ({ emptyFallback = 'site' }) => {
const { open: openSitesmith } = useSitesmithModal();
const { query } = useEditor();
@@ -57,6 +62,16 @@ export const GuidedStyles: React.FC = () => {
});
if (!selected) {
+ if (emptyFallback === 'placeholder') {
+ return (
+
+
Select something on the canvas to style it.
+
+ Looking for site colours, fonts or favicon? Open the Site tab.
+
+
+ );
+ }
return ;
}
diff --git a/craft/src/panels/right/RightPanel.test.tsx b/craft/src/panels/right/RightPanel.test.tsx
new file mode 100644
index 0000000..b4a0b6c
--- /dev/null
+++ b/craft/src/panels/right/RightPanel.test.tsx
@@ -0,0 +1,113 @@
+import { describe, test, expect, beforeEach } from 'vitest';
+import React from 'react';
+import { renderEditorHarness } from '../../test-utils/editorHarness';
+import { SiteDesignProvider } from '../../state/SiteDesignContext';
+import { PageProvider } from '../../state/PageContext';
+import { SitesmithProvider } from '../../state/SitesmithContext';
+import { Heading } from '../../components/basic/Heading';
+import { getPref, setPref } from '../../utils/local-prefs';
+import { RightPanel } from './RightPanel';
+
+function mount() {
+ const h = renderEditorHarness();
+ h.mountChild(
+
+
+
+
+
+
+ ,
+ );
+ return h;
+}
+
+const tab = (h: ReturnType, name: string) =>
+ Array.from(h.container.querySelectorAll('[role="tab"]')).find(
+ (t) => t.textContent === name,
+ ) as HTMLButtonElement;
+
+const addHeading = (h: ReturnType): string => {
+ let id = '';
+ h.act(() => {
+ const tree = h.query.parseReactElement().toNodeTree();
+ h.actions.addNodeTree(tree, 'ROOT');
+ id = tree.rootNodeId;
+ });
+ return id;
+};
+
+describe('RightPanel tabs', () => {
+ beforeEach(() => window.localStorage.clear());
+
+ test('nothing selected: Element shows placeholder, Site shows Site Design', () => {
+ const h = mount();
+ expect(h.container.querySelectorAll('[role="tablist"] [role="tab"]')).toHaveLength(2);
+ expect(tab(h, 'Element').getAttribute('aria-selected')).toBe('true');
+ expect(h.container.querySelector('[data-testid="element-placeholder"]')).not.toBeNull();
+ expect(h.container.textContent).not.toContain('Primary Color');
+
+ h.act(() => tab(h, 'Site').click());
+ expect(tab(h, 'Site').getAttribute('aria-selected')).toBe('true');
+ expect(h.container.querySelector('[data-testid="element-placeholder"]')).toBeNull();
+ expect(h.container.textContent).toContain('Primary');
+ h.unmount();
+ });
+
+ test('selecting a Heading with Element active shows the Text panel', () => {
+ const h = mount();
+ const id = addHeading(h);
+ h.act(() => h.actions.selectNode(id));
+ expect(h.container.querySelector('[data-testid="element-placeholder"]')).toBeNull();
+ expect(h.container.textContent).toContain('Text Color');
+ expect(h.container.textContent).not.toContain('Primary Color');
+ h.unmount();
+ });
+
+ test('switching to Site while a node is selected still shows Site Design, and stays on a later selection', () => {
+ const h = mount();
+ const id = addHeading(h);
+ h.act(() => h.actions.selectNode(id));
+ h.act(() => tab(h, 'Site').click());
+ expect(h.container.textContent).toContain('Primary');
+ expect(h.container.textContent).not.toContain('Text Color');
+
+ h.act(() => h.actions.selectNode(undefined));
+ h.act(() => h.actions.selectNode(id));
+ expect(tab(h, 'Site').getAttribute('aria-selected')).toBe('true');
+ h.unmount();
+ });
+
+ test('auto-switches Site -> Element on empty->selected when Site came from the saved pref only', () => {
+ setPref('rightPanelTab', 'site');
+ const h = mount();
+ expect(tab(h, 'Site').getAttribute('aria-selected')).toBe('true');
+ const id = addHeading(h);
+ h.act(() => h.actions.selectNode(id));
+ expect(tab(h, 'Element').getAttribute('aria-selected')).toBe('true');
+ h.unmount();
+ });
+
+ test('deselecting keeps the active tab', () => {
+ const h = mount();
+ const id = addHeading(h);
+ h.act(() => h.actions.selectNode(id));
+ h.act(() => h.actions.selectNode(undefined));
+ expect(tab(h, 'Element').getAttribute('aria-selected')).toBe('true');
+ expect(h.container.querySelector('[data-testid="element-placeholder"]')).not.toBeNull();
+ h.unmount();
+ });
+
+ test('pref round-trips', () => {
+ const h = mount();
+ h.act(() => tab(h, 'Site').click());
+ expect(getPref('rightPanelTab', 'element')).toBe('site');
+ h.unmount();
+
+ const h2 = mount();
+ expect(tab(h2, 'Site').getAttribute('aria-selected')).toBe('true');
+ h2.act(() => tab(h2, 'Element').click());
+ expect(getPref('rightPanelTab', 'x')).toBe('element');
+ h2.unmount();
+ });
+});
diff --git a/craft/src/panels/right/RightPanel.tsx b/craft/src/panels/right/RightPanel.tsx
index 6c3216a..973dfbe 100644
--- a/craft/src/panels/right/RightPanel.tsx
+++ b/craft/src/panels/right/RightPanel.tsx
@@ -1,18 +1,77 @@
-import React from 'react';
+import React, { useEffect, useRef, useState } from 'react';
+import { useEditor } from '@craftjs/core';
import { GuidedStyles } from './GuidedStyles';
+import { SiteDesignPanel } from './SiteDesignPanel';
+import { getPref, setPref } from '../../utils/local-prefs';
+
+type RightTab = 'element' | 'site';
+
+const PREF_KEY = 'rightPanelTab';
/**
- * Right panel -- shows component settings when an element is selected,
- * or Site Design Tokens (including Head code) when nothing is selected.
+ * Right panel -- two tabs: "Element" (GuidedStyles for the selection) and
+ * "Site" (Site Design tokens). Site is always one click away, so reaching
+ * site colours no longer requires deselecting everything.
*/
export const RightPanel: React.FC = () => {
+ const { hasSelection } = useEditor((state) => ({
+ hasSelection: !!state.events.selected && state.events.selected.size > 0,
+ }));
+
+ const [activeTab, setActiveTab] = useState(() =>
+ getPref(PREF_KEY, 'element') === 'site' ? 'site' : 'element',
+ );
+ // True once the user has clicked the Site tab this session; from then on a
+ // new selection no longer yanks them back to Element.
+ const pickedSite = useRef(false);
+ const hadSelection = useRef(hasSelection);
+
+ useEffect(() => {
+ if (hasSelection && !hadSelection.current && !pickedSite.current) {
+ setActiveTab('element');
+ }
+ hadSelection.current = hasSelection;
+ }, [hasSelection]);
+
+ const pick = (tab: RightTab) => {
+ if (tab === 'site') pickedSite.current = true;
+ setActiveTab(tab);
+ setPref(PREF_KEY, tab);
+ };
+
return (
-
-
+
+
+
-
-
+
+ {activeTab === 'site' ? : }
);
diff --git a/craft/src/styles/editor.css b/craft/src/styles/editor.css
index adf849c..4c44d3c 100644
--- a/craft/src/styles/editor.css
+++ b/craft/src/styles/editor.css
@@ -2184,3 +2184,21 @@ body {
bottom: calc(var(--mobile-tabbar-height) + env(safe-area-inset-bottom, 0px) + 12px);
}
}
+
+/* #36 right panel tabs */
+.right-panel-placeholder {
+ padding: 24px 12px;
+ text-align: center;
+ color: var(--color-text-muted);
+ font-size: 13px;
+ line-height: 1.5;
+}
+
+.right-panel-placeholder p {
+ margin: 0 0 8px;
+}
+
+.right-panel-placeholder-hint {
+ font-size: 12px;
+ opacity: 0.8;
+}