From 1bc4ab4b60b264dcea7f529c2c5a8e559b5330b3 Mon Sep 17 00:00:00 2001 From: James Morton Date: Sat, 31 Oct 2026 12:41:34 +0000 Subject: [PATCH] feat(design): type with character, and the header shows the logo a design chooses A design can now set its headline scale (standard, large or display), tight heading tracking, upper-case headlines and upper-case labels for the menu and buttons, and set a phrase of a headline apart in the accent colour or highlighted. The accent colour is moved until it reads on the band. Absent choices read as today's type. The header now follows the design's logo display: a wordmark file, with its dark file in dark mode; the mark alone; or, new, the name set in the heading font as a wordmark for brands without a logo file. A wordmark with no file yet shows the mark and name. The admin preview follows the draft's logo. --- .../settings/appearance/use-design-draft.ts | 1 + .../public/__tests__/portal-band.test.tsx | 31 +++++ .../__tests__/portal-header-logo.test.tsx | 131 ++++++++++++++++++ .../feedback/feedback-header-animated.tsx | 2 + .../components/public/home/home-blocks.tsx | 1 + .../web/src/components/public/portal-band.tsx | 31 ++++- .../src/components/public/portal-header.tsx | 73 +++++++--- .../public/portal-preview-design.ts | 39 +++++- .../public/portal-preview-listener.tsx | 17 ++- .../public/preview-draft-context.tsx | 13 +- .../__tests__/design.service.db.test.ts | 2 +- .../design/__tests__/components.test.ts | 54 ++++++++ .../design/__tests__/design-view.test.ts | 20 +++ .../design/__tests__/fixtures/kiln.json | 11 +- .../design/__tests__/fixtures/orbit.json | 12 +- .../design/__tests__/fixtures/plume.json | 6 +- .../design/__tests__/reference-brands.test.ts | 24 +++- .../shared/design/__tests__/resolve.test.ts | 4 +- .../design/__tests__/stored-design.test.ts | 4 +- .../lib/shared/design/__tests__/view.test.ts | 25 ++++ apps/web/src/lib/shared/design/components.ts | 50 +++++++ apps/web/src/lib/shared/design/design-view.ts | 18 ++- apps/web/src/lib/shared/design/options.ts | 11 +- apps/web/src/lib/shared/design/parts.ts | 9 +- apps/web/src/lib/shared/design/resolve.ts | 2 +- apps/web/src/lib/shared/design/schema.ts | 18 ++- apps/web/src/lib/shared/design/view.ts | 14 +- 27 files changed, 572 insertions(+), 51 deletions(-) create mode 100644 apps/web/src/components/public/__tests__/portal-header-logo.test.tsx diff --git a/apps/web/src/components/admin/settings/appearance/use-design-draft.ts b/apps/web/src/components/admin/settings/appearance/use-design-draft.ts index bf01effb6e..3f5e5e5e49 100644 --- a/apps/web/src/components/admin/settings/appearance/use-design-draft.ts +++ b/apps/web/src/components/admin/settings/appearance/use-design-draft.ts @@ -149,6 +149,7 @@ export function useDesignDraft() { nav: view.nav, vocabulary: view.vocabulary, copy: view.feedback.copy, + logo: view.logo, } }, [design]) diff --git a/apps/web/src/components/public/__tests__/portal-band.test.tsx b/apps/web/src/components/public/__tests__/portal-band.test.tsx index c7229177b7..0c38d6dcc5 100644 --- a/apps/web/src/components/public/__tests__/portal-band.test.tsx +++ b/apps/web/src/components/public/__tests__/portal-band.test.tsx @@ -81,4 +81,35 @@ describe('PortalBand', () => { mount(null) expect(screen.queryByRole('heading')).toBeNull() }) + + it('sets the accent phrase apart, in the visitor’s language', () => { + root.portalDesign = view({ + title: { en: 'Charge anywhere. Shape what’s next.', de: 'Überall laden. Mitgestalten.' }, + subtitle: {}, + accent: { style: 'colour', text: { en: 'Shape what’s next.', de: 'Mitgestalten.' } }, + }) + mount() + const accent = screen.getByText('Mitgestalten.') + expect(accent.getAttribute('data-qb-accent')).toBe('colour') + expect(accent.className).toContain('var(--qb-band-accent)') + expect(screen.getByRole('heading').textContent).toBe('Überall laden. Mitgestalten.') + }) + + it('highlights a phrase, and leaves a title alone that does not hold it', () => { + root.portalDesign = view({ + title: { en: 'Make it work harder' }, + subtitle: {}, + accent: { style: 'highlight', text: { en: 'harder' } }, + }) + mount() + expect(screen.getByText('harder').className).toContain('var(--qb-band-highlight)') + cleanup() + root.portalDesign = view({ + title: { en: 'Make it work' }, + subtitle: {}, + accent: { style: 'highlight', text: { en: 'harder' } }, + }) + mount() + expect(screen.getByRole('heading').innerHTML).toBe('Make it work') + }) }) diff --git a/apps/web/src/components/public/__tests__/portal-header-logo.test.tsx b/apps/web/src/components/public/__tests__/portal-header-logo.test.tsx new file mode 100644 index 0000000000..26f2f570e8 --- /dev/null +++ b/apps/web/src/components/public/__tests__/portal-header-logo.test.tsx @@ -0,0 +1,131 @@ +// @vitest-environment happy-dom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, render, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { IntlProvider } from 'react-intl' +import { + RouterProvider, + createMemoryHistory, + createRootRouteWithContext, + createRoute, + createRouter, +} from '@tanstack/react-router' +import { DEFAULT_FEATURE_FLAGS } from '@/lib/shared/types/settings' + +vi.mock('next-themes', () => ({ useTheme: () => ({ theme: 'system', setTheme: vi.fn() }) })) +vi.mock('@/components/auth/auth-popover-context', () => ({ + useAuthPopoverSafe: () => ({ openAuthPopover: vi.fn() }), +})) +vi.mock('@/lib/server/functions/conversation', () => ({ getMyConversationsFn: vi.fn() })) +vi.mock('@/lib/client/hooks/use-auth-broadcast', () => ({ useAuthBroadcast: () => {} })) +vi.mock('@/lib/client/auth-client', () => ({ signOut: vi.fn() })) +vi.mock('@/components/notifications', () => ({ NotificationBell: () => null })) +vi.mock('@/components/shared/user-stats', () => ({ UserStatsBar: () => null })) + +const { PortalHeader } = await import('../portal-header') +const { PreviewLogoContext } = await import('../preview-draft-context') + +afterEach(cleanup) + +type Logo = { + display: 'mark_and_name' | 'mark' | 'wordmark' | 'name' + name: string | null + wordmark: { light: string | null; dark: string | null } +} + +const design = (logo: Partial) => ({ + engine: 'v2', + attributes: {}, + logo: { display: 'mark_and_name', name: null, wordmark: { light: null, dark: null }, ...logo }, +}) + +async function mount(portalDesign?: unknown, preview?: Logo) { + const rootRoute = createRootRouteWithContext()({ + beforeLoad: () => ({ + session: null, + settings: { featureFlags: DEFAULT_FEATURE_FLAGS, portalDesign: portalDesign as never }, + registeredAuthProviders: [], + }), + component: () => ( + + + + ), + }) + const home = createRoute({ getParentRoute: () => rootRoute, path: '/' }) + const router = createRouter({ + routeTree: rootRoute.addChildren([home]), + history: createMemoryHistory({ initialEntries: ['/'] }), + context: {}, + }) + const view = render( + + {}}> + + + + ) + await waitFor(() => expect(view.container.querySelector('[data-qb-part=logo]')).not.toBeNull()) + return view.container.querySelector('[data-qb-part=logo]')! +} + +describe('the header logo a design chooses', () => { + it('shows the mark and the workspace name by default, and on the legacy theme', async () => { + const logo = await mount() + expect(logo.textContent).toBe('HHarbor') + }) + + it('shows the wordmark file, with its dark file in dark mode', async () => { + const logo = await mount( + design({ + display: 'wordmark', + wordmark: { light: '/api/storage/logos/ink.svg', dark: '/api/storage/logos/paper.svg' }, + }) + ) + const files = [...logo.querySelectorAll('img')] + expect(files.map((img) => [img.getAttribute('src'), img.getAttribute('alt')])).toEqual([ + ['/api/storage/logos/ink.svg', 'Harbor'], + ['/api/storage/logos/paper.svg', 'Harbor'], + ]) + expect(files[0]!.className).toContain('dark:hidden') + expect(files[1]!.className).toContain('dark:block') + expect(logo.textContent).toBe('') + }) + + it('keeps the one wordmark file in dark mode when there is no dark file', async () => { + const logo = await mount( + design({ display: 'wordmark', wordmark: { light: '/api/storage/logos/ink.svg', dark: null } }) + ) + const files = [...logo.querySelectorAll('img')] + expect(files).toHaveLength(1) + expect(files[0]!.className).not.toContain('dark:hidden') + }) + + it('falls back to the mark and name while a wordmark has no file', async () => { + const logo = await mount(design({ display: 'wordmark' })) + expect(logo.getAttribute('data-qb-logo')).toBe('mark_and_name') + expect(logo.textContent).toBe('HHarbor') + }) + + it('sets the name as a wordmark in the heading font, without the mark', async () => { + const logo = await mount(design({ display: 'name', name: 'Harbor & Pine' })) + const name = logo.querySelector('.portal-header__name')! + expect(logo.textContent).toBe('Harbor & Pine') + expect(name.className).toContain('[font-family:var(--qb-font-heading)]') + expect(name.className).not.toContain('hidden') + }) + + it('shows the mark alone for mark', async () => { + const logo = await mount(design({ display: 'mark' })) + expect(logo.textContent).toBe('H') + }) + + it('follows the draft’s logo in the preview', async () => { + const logo = await mount(design({ display: 'mark' }), { + display: 'name', + name: 'Draft name', + wordmark: { light: null, dark: null }, + }) + expect(logo.textContent).toBe('Draft name') + }) +}) diff --git a/apps/web/src/components/public/feedback/feedback-header-animated.tsx b/apps/web/src/components/public/feedback/feedback-header-animated.tsx index d12c1fd414..e3d41e1746 100644 --- a/apps/web/src/components/public/feedback/feedback-header-animated.tsx +++ b/apps/web/src/components/public/feedback/feedback-header-animated.tsx @@ -541,6 +541,7 @@ export function FeedbackHeaderAnimated({ type="button" variant="ghost" size="sm" + data-qb-label="" onClick={handleCancel} disabled={createPost.isPending} > @@ -548,6 +549,7 @@ export function FeedbackHeaderAnimated({