Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ export function useDesignDraft() {
nav: view.nav,
vocabulary: view.vocabulary,
copy: view.feedback.copy,
logo: view.logo,
}
}, [design])

Expand Down
31 changes: 31 additions & 0 deletions apps/web/src/components/public/__tests__/portal-band.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
})
131 changes: 131 additions & 0 deletions apps/web/src/components/public/__tests__/portal-header-logo.test.tsx
Original file line number Diff line number Diff line change
@@ -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<Logo>) => ({
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<object>()({
beforeLoad: () => ({
session: null,
settings: { featureFlags: DEFAULT_FEATURE_FLAGS, portalDesign: portalDesign as never },
registeredAuthProviders: [],
}),
component: () => (
<PreviewLogoContext.Provider value={preview}>
<PortalHeader orgName="Harbor" userRole="user" />
</PreviewLogoContext.Provider>
),
})
const home = createRoute({ getParentRoute: () => rootRoute, path: '/' })
const router = createRouter({
routeTree: rootRoute.addChildren([home]),
history: createMemoryHistory({ initialEntries: ['/'] }),
context: {},
})
const view = render(
<QueryClientProvider client={new QueryClient()}>
<IntlProvider locale="en" messages={{}} onError={() => {}}>
<RouterProvider router={router} />
</IntlProvider>
</QueryClientProvider>
)
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')
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -541,13 +541,15 @@ export function FeedbackHeaderAnimated({
type="button"
variant="ghost"
size="sm"
data-qb-label=""
onClick={handleCancel}
disabled={createPost.isPending}
>
<FormattedMessage id="portal.feedback.header.cancel" defaultMessage="Cancel" />
</Button>
<Button
size="sm"
data-qb-label=""
onClick={handleSubmit}
disabled={createPost.isPending || !canSubmit}
title={
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/public/home/home-blocks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ function ComposerBlock({ block, index }: { block: HomeBlock; index: number }) {
/>
<button
type="submit"
data-qb-label=""
className="shrink-0 rounded-full px-3 py-1.5 text-sm font-medium text-[var(--qb-accent-text,var(--primary))] hover:bg-muted"
>
{copy.submit ?? intl.formatMessage(homeMessages.composerContinue)}
Expand Down
31 changes: 29 additions & 2 deletions apps/web/src/components/public/portal-band.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useIntl } from 'react-intl'
import { useRouteContext } from '@tanstack/react-router'
import type { ReactNode } from 'react'
import { usePreviewHeadline, usePreviewHome } from './preview-draft-context'

/** Text in the visitor's language, else its base language, else English, else any. */
Expand All @@ -12,6 +13,31 @@ export function textForLocale(
return text[locale] || text[base] || text.en || Object.values(text).find(Boolean)
}

const ACCENT_CLASS = {
colour: 'text-[var(--qb-band-accent)]',
highlight:
'px-[0.08em] [box-decoration-break:clone] [background:linear-gradient(transparent_58%,var(--qb-band-highlight)_58%,var(--qb-band-highlight)_92%,transparent_92%)]',
} as const

/** The title with its accent phrase set apart, when the title holds it. */
function accented(
title: string,
phrase: string | undefined,
style: keyof typeof ACCENT_CLASS | undefined
): ReactNode {
const at = phrase && style ? title.indexOf(phrase) : -1
if (at < 0) return title
return (
<>
{title.slice(0, at)}
<span data-qb-accent={style} className={ACCENT_CLASS[style!]}>
{phrase}
</span>
{title.slice(at + phrase!.length)}
</>
)
}

/**
* The band at the top of the feedback page or Home, behind its headline. Its look
* comes from the design's band tokens and data-qb-band. With no headline
Expand All @@ -35,6 +61,7 @@ export function PortalBand({ page = 'feedback' }: { page?: 'feedback' | 'home' }
: design.feedback.headline
const title = textForLocale(headline?.title, intl.locale)
const subtitle = textForLocale(headline?.subtitle, intl.locale)
const phrase = textForLocale(headline?.accent?.text, intl.locale)
if (!title && !subtitle) return null
return (
<section
Expand All @@ -48,9 +75,9 @@ export function PortalBand({ page = 'feedback' }: { page?: 'feedback' | 'home' }
{title && (
<h1
data-qb-part="headline"
className="text-2xl font-semibold text-[var(--qb-band-ink)] sm:text-3xl"
className="font-semibold text-[var(--qb-band-ink)] [font-size:var(--qb-headline-size)] [line-height:var(--qb-headline-leading)]"
>
{title}
{accented(title, phrase, headline?.accent?.style)}
</h1>
)}
{subtitle && (
Expand Down
73 changes: 57 additions & 16 deletions apps/web/src/components/public/portal-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ import { removeViewerScopedPortalQueries } from '@/lib/client/queries/portal'
import { useAuthBroadcast } from '@/lib/client/hooks/use-auth-broadcast'
import { NotificationBell } from '@/components/notifications'
import { useSessionContext, useWorkspaceSettings } from '@/lib/client/hooks/use-root-context'
import { usePreviewDesignAttributes } from './preview-draft-context'
import { usePreviewDesignAttributes, usePreviewLogo } from './preview-draft-context'
import { PORTAL_LANDING } from '@/lib/shared/design/paths'

interface PortalHeaderProps {
Expand Down Expand Up @@ -263,6 +263,7 @@ export function PortalHeader({
<Button
variant={showSignup ? 'ghost' : 'default'}
size="sm"
data-qb-label=""
onClick={() =>
soleOidcProviderId ? redirectToSoleProvider() : openAuthPopover({ mode: 'login' })
}
Expand All @@ -272,6 +273,7 @@ export function PortalHeader({
{showSignup && (
<Button
size="sm"
data-qb-label=""
onClick={() =>
soleOidcProviderId ? redirectToSoleProvider() : openAuthPopover({ mode: 'signup' })
}
Expand All @@ -292,29 +294,68 @@ export function PortalHeader({
(savedDesign?.engine === 'v2' ? savedDesign.attributes['data-qb-header'] : undefined) ??
'stacked'

// How the design shows the brand; in the admin preview, the draft's.
const previewLogo = usePreviewLogo()
const designLogo = previewLogo ?? (savedDesign?.engine === 'v2' ? savedDesign.logo : undefined)
const brandName = designLogo?.name || orgName
const wordmark = designLogo?.wordmark
// A wordmark without a file shows the mark and name, as before one is uploaded.
const display =
designLogo?.display === 'wordmark' && !wordmark?.light && !wordmark?.dark
? 'mark_and_name'
: (designLogo?.display ?? 'mark_and_name')

const mark = orgLogo ? (
<img src={orgLogo} alt={orgName} className="h-8 w-8 [border-radius:calc(var(--radius)*0.6)]" />
) : (
<div className="h-8 w-8 [border-radius:calc(var(--radius)*0.6)] bg-primary flex items-center justify-center text-primary-foreground font-semibold">
{nameInitial(orgName)}
</div>
)
const logo = (
<Link
to={PORTAL_LANDING}
className="portal-header__logo flex items-center gap-2"
data-qb-part="logo"
data-qb-logo={display}
>
{orgLogo ? (
<img
src={orgLogo}
alt={orgName}
className="h-8 w-8 [border-radius:calc(var(--radius)*0.6)]"
/>
{display === 'wordmark' ? (
<>
{/* The light file shows in dark mode too when there is no dark one. */}
<img
src={(wordmark?.light ?? wordmark?.dark)!}
alt={brandName}
className={`h-8 w-auto max-w-[12rem] object-contain ${wordmark?.dark && wordmark.light ? 'dark:hidden' : ''}`}
/>
{wordmark?.dark && wordmark.light ? (
<img
src={wordmark.dark}
alt={brandName}
className="hidden h-8 w-auto max-w-[12rem] object-contain dark:block"
/>
) : null}
</>
) : display === 'name' ? (
// The name as a wordmark: set in the heading font, and shown on phones too.
<span
title={brandName}
className="portal-header__name max-w-[18ch] truncate text-xl leading-none text-[var(--header-foreground)] [font-family:var(--qb-font-heading)] [font-weight:var(--qb-heading-weight)] [letter-spacing:var(--qb-heading-tracking)]"
>
{brandName}
</span>
) : (
<div className="h-8 w-8 [border-radius:calc(var(--radius)*0.6)] bg-primary flex items-center justify-center text-primary-foreground font-semibold">
{nameInitial(orgName)}
</div>
<>
{mark}
{display === 'mark_and_name' ? (
<span
title={brandName}
className="portal-header__name font-semibold hidden sm:block max-w-[18ch] truncate text-[var(--header-foreground)]"
>
{brandName}
</span>
) : null}
</>
)}
<span
title={orgName}
className="portal-header__name font-semibold hidden sm:block max-w-[18ch] truncate text-[var(--header-foreground)]"
>
{orgName}
</span>
</Link>
)
const navigation = (
Expand Down
Loading
Loading