From ebf75ebfe0ff4a51308e52d666c34895bf0338ea Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Wed, 7 Oct 2026 12:41:36 -0700 Subject: [PATCH] feat(topbar): search button opens the command palette; Preview head parity - Icon button with tooltip 'Search (Ctrl/Cmd+K)' calls openCommandPalette() - Preview passes fontScanHtml: composedBody and trims metaTitle so the Preview head matches the published head Refs #49 Co-Authored-By: Claude Opus 5.5 --- craft/src/panels/topbar/TopBar.test.tsx | 46 +++++++++++++++++++++++-- craft/src/panels/topbar/TopBar.tsx | 13 ++++++- 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/craft/src/panels/topbar/TopBar.test.tsx b/craft/src/panels/topbar/TopBar.test.tsx index bebd63b..b28cfa4 100644 --- a/craft/src/panels/topbar/TopBar.test.tsx +++ b/craft/src/panels/topbar/TopBar.test.tsx @@ -34,12 +34,12 @@ import { act } from 'react-dom/test-utils'; // #54: what the pre-publish checks see. Default (no pages) means no checks, so // every pre-existing test publishes directly. -const checkFixture: { pages: any[]; liveState: string; activePageId: string } = { pages: [], liveState: '{}', activePageId: 'home' }; +const checkFixture: { pages: any[]; liveState: string; activePageId: string; footerState: string | null } = { pages: [], liveState: '{}', activePageId: 'home', footerState: null }; vi.mock('../../state/PageContext', () => ({ usePages: () => ({ pages: checkFixture.pages, headerPage: { id: '__header__', name: 'Header', slug: '__header__', craftState: null }, - footerPage: { id: '__footer__', name: 'Footer', slug: '__footer__', craftState: null }, + footerPage: { id: '__footer__', name: 'Footer', slug: '__footer__', craftState: checkFixture.footerState }, activePageId: checkFixture.activePageId, switchPage: vi.fn(), }), @@ -71,6 +71,8 @@ vi.mock('../../hooks/useWhpApi', () => ({ vi.mock('./TemplateModal', () => ({ TemplateModal: () => null })); vi.mock('./HeadCodeModal', () => ({ HeadCodeModal: () => null })); vi.mock('../sitesmith/SitesmithButton', () => ({ SitesmithButton: () => null })); +const openPaletteMock = vi.fn(); +vi.mock('../command-palette/CommandPalette', () => ({ openCommandPalette: () => openPaletteMock() })); import { TopBar } from './TopBar'; import { EditorConfigProvider } from '../../state/EditorConfigContext'; @@ -130,6 +132,8 @@ afterEach(() => { checkFixture.pages = []; checkFixture.liveState = '{}'; checkFixture.activePageId = 'home'; + checkFixture.footerState = null; + openPaletteMock.mockReset(); }); describe('TopBar publish-warnings wiring', () => { @@ -315,3 +319,41 @@ describe('TopBar pre-publish checklist', () => { expect(container.querySelector('[data-testid="publish-warnings"]')!.textContent).toContain('Generated sitemap.xml and robots.txt.'); }); }); + +describe('TopBar search button (#49 integration)', () => { + test('opens the command palette and carries the shortcut tooltip', () => { + render(); + const btn = container.querySelector('button[aria-label="Search"]'); + expect(btn).not.toBeNull(); + expect(btn!.getAttribute('data-tooltip')).toBe('Search (Ctrl/Cmd+K)'); + act(() => { btn!.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); + expect(openPaletteMock).toHaveBeenCalledTimes(1); + }); +}); + +describe('TopBar Preview head parity (item 14)', () => { + const rootState = (style: Record = {}) => JSON.stringify({ + ROOT: { type: { resolvedName: 'Container' }, isCanvas: true, props: { tag: 'div', style }, displayName: 'Container', custom: {}, hidden: false, nodes: [], linkedNodes: {} }, + }); + + test('scans the composed header+body+footer for preset fonts and trims metaTitle', async () => { + checkFixture.liveState = rootState(); + checkFixture.footerState = rootState({ fontFamily: 'Merriweather, serif' }); + checkFixture.pages = [{ id: 'home', name: 'Home', slug: 'index', craftState: null, seo: { metaTitle: ' ' } }]; + let blob: Blob | null = null; + const createUrl = vi.spyOn(URL, 'createObjectURL').mockImplementation((b) => { blob = b as Blob; return 'blob:x'; }); + const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null); + try { + render(); + const btn = container.querySelector('button[aria-label="Preview"]')!; + await act(async () => { btn.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); + await vi.waitFor(() => expect(blob).not.toBeNull()); + const html = await blob!.text(); + expect(html).toContain('family=Inter:wght@300;400;500;600;700&family=Merriweather:wght@300;400;700&display=swap'); + expect(html).toContain('Home'); + } finally { + createUrl.mockRestore(); + openSpy.mockRestore(); + } + }); +}); diff --git a/craft/src/panels/topbar/TopBar.tsx b/craft/src/panels/topbar/TopBar.tsx index 783b55a..4fe93a3 100644 --- a/craft/src/panels/topbar/TopBar.tsx +++ b/craft/src/panels/topbar/TopBar.tsx @@ -24,6 +24,7 @@ import { SitesmithButton } from '../sitesmith/SitesmithButton'; import { useSitesmithModal } from '../../state/SitesmithContext'; import { useRegisterCommands } from '../../editor/commands'; import { TopBarCommandMenu } from './TopBarCommandMenu'; +import { openCommandPalette } from '../command-palette/CommandPalette'; interface TopBarProps { /** Item 10: canvas dashed guide outlines, default ON. */ @@ -306,7 +307,7 @@ export const TopBar: React.FC = ({ showGuides, onToggleGuides }) => // html-export's buildSeoMeta/og:title fallback both key off it. const activePage = pages.find((p) => p.id === activePageId); const seo = activePage?.seo; - const title = seo?.metaTitle || activePage?.name || whpConfig?.siteName || 'Preview'; + const title = seo?.metaTitle?.trim() || activePage?.name || whpConfig?.siteName || 'Preview'; const result = exportToHtml(serialized, { title, @@ -323,6 +324,8 @@ export const TopBar: React.FC = ({ showGuides, onToggleGuides }) => ? `https://${whpConfig.siteDomain}/${activePage.slug === 'index' ? '' : activePage.slug}` : undefined, design, + // Fonts: scan header+body+footer like the published head does (head parity). + fontScanHtml: composedBody, }); // Replace the body in the full document with our composed version. @@ -528,6 +531,14 @@ export const TopBar: React.FC = ({ showGuides, onToggleGuides }) => > +