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
@@ -0,0 +1,58 @@
// @vitest-environment happy-dom
/**
* Appearance words each page's title and puts the titles on the band. A page
* whose words are cleared goes back to its usual title.
*/
import { afterEach, describe, expect, it } from 'vitest'
import { useState } from 'react'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { defaultDesign, type PortalDesign } from '@/lib/shared/design/schema'
import { PageTitlesSection } from '../page-titles-section'

const GATES = {
feedback: true,
roadmap: true,
changelog: true,
help: false,
support: true,
status: true,
}

let latest: PortalDesign

function Harness() {
const [design, setDesign] = useState(defaultDesign())
latest = design
return (
<PageTitlesSection
design={design}
gates={GATES}
update={(change) =>
setDesign((current) => {
const draft = structuredClone(current)
change(draft)
return draft
})
}
/>
)
}

afterEach(cleanup)

describe('Appearance’s page titles', () => {
it('words the first shown page, and gives it back its usual title when cleared', () => {
render(<Harness />)
const title = screen.getByLabelText('Title')
fireEvent.change(title, { target: { value: 'What we are building' } })
expect(latest.pages.titles?.roadmap?.title).toEqual({ en: 'What we are building' })
fireEvent.change(title, { target: { value: '' } })
expect(latest.pages.titles).toBeUndefined()
})

it('puts the titles on the band', () => {
render(<Harness />)
fireEvent.click(screen.getByRole('radio', { name: /On the band/ }))
expect(latest.layout.pageTitles).toBe('band')
})
})
13 changes: 13 additions & 0 deletions apps/web/src/components/admin/settings/appearance/brand-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,19 @@ export function BrandPanel({
drawing: <ButtonsDrawing buttons={buttons} />,
}))}
/>
<SettingRow
label="Buttons in the text colour"
htmlFor="design-fill"
control={
<Switch
id="design-fill"
checked={design.shape.fill === 'ink'}
onCheckedChange={(on) =>
update((draft) => void (draft.shape.fill = on ? 'ink' : 'accent'))
}
/>
}
/>

<WordsSection design={design} update={update} />
</div>
Expand Down
16 changes: 16 additions & 0 deletions apps/web/src/components/admin/settings/appearance/drawings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -356,3 +356,19 @@ export function FocusDrawing({ focus }: { focus: NonNullable<Layout['imageFocus'
</span>
)
}

export function PageTitlesDrawing({ place }: { place: NonNullable<Layout['pageTitles']> }) {
return (
<span className="flex h-full w-full flex-col">
<span
className={cn('flex h-5 w-full items-center px-1.5', place === 'band' && 'bg-primary/25')}
>
<span className="h-1.5 w-7 rounded-full bg-foreground/60" />
</span>
<span className="mt-1 flex flex-col gap-1 px-1.5">
<span className="h-2 w-full rounded-sm border border-foreground/40" />
<span className="h-2 w-full rounded-sm border border-foreground/40" />
</span>
</span>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -80,20 +80,26 @@ export function guessFamily(name: string): string {
return base.replace(/([a-z])([A-Z])/g, '$1 $2').trim()
}

/** The choice that leaves an optional font to another. */
const SAME = '__same__'

function FontSelect({
id,
label,
value,
uploaded,
onChange,
same,
}: {
id: string
label: string
value: DesignFont
value: DesignFont | undefined
uploaded: UploadedFont[]
onChange: (font: DesignFont) => void
onChange: (font: DesignFont | undefined) => void
/** Offers leaving it to another font, named so. */
same?: string
}) {
const current = value.kind === 'bundled' ? value.id : `${UPLOADED}${value.family}`
const current = !value ? SAME : value.kind === 'bundled' ? value.id : `${UPLOADED}${value.family}`
return (
<div className="flex items-center gap-2">
<label htmlFor={id} className="w-32 shrink-0 text-[13px]">
Expand All @@ -102,6 +108,7 @@ function FontSelect({
<Select
value={current}
onValueChange={(next) => {
if (next === SAME) return onChange(undefined)
const own = uploaded.find((font) => `${UPLOADED}${font.family}` === next)
onChange(own ?? { kind: 'bundled', id: next as BundledFontId })
}}
Expand All @@ -110,6 +117,7 @@ function FontSelect({
<SelectValue />
</SelectTrigger>
<SelectContent>
{same && <SelectItem value={SAME}>{same}</SelectItem>}
{uploaded.length > 0 && (
<SelectGroup>
<SelectLabel>Your fonts</SelectLabel>
Expand Down Expand Up @@ -349,10 +357,10 @@ function OwnFont({ update }: { update: Update }) {
/** Brand › Fonts: the heading and body fonts, the heading weight, and the workspace's own font. */
export function FontsSection({ design, update }: { design: PortalDesign; update: Update }) {
const { fonts } = design.brand
const uploaded = [fonts.heading, fonts.body].filter(
const uploaded = [fonts.heading, fonts.body, fonts.display].filter(
(font, index, all): font is UploadedFont =>
font.kind === 'uploaded' &&
all.findIndex((other) => other.kind === 'uploaded' && other.family === font.family) === index
font?.kind === 'uploaded' &&
all.findIndex((other) => other?.kind === 'uploaded' && other.family === font.family) === index
)
const weights = DESIGN_OPTIONS.headingWeight.filter((weight) =>
fontWeights(fonts.heading).includes(weight)
Expand All @@ -369,17 +377,31 @@ export function FontsSection({ design, update }: { design: PortalDesign; update:
uploaded={uploaded}
onChange={(font) =>
update((draft) => {
if (!font) return
draft.brand.fonts.heading = font
draft.brand.fonts.headingWeight = closestWeight(font, draft.brand.fonts.headingWeight)
})
}
/>
<FontSelect
id="font-display"
label="Page headlines"
value={fonts.display}
uploaded={uploaded}
same="Same as headings"
onChange={(font) =>
update((draft) => {
if (font) draft.brand.fonts.display = font
else delete draft.brand.fonts.display
})
}
/>
<FontSelect
id="font-body"
label="Body"
value={fonts.body}
uploaded={uploaded}
onChange={(font) => update((draft) => void (draft.brand.fonts.body = font))}
onChange={(font) => update((draft) => void (font && (draft.brand.fonts.body = font)))}
/>
<OwnFont update={update} />
</section>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { useState } from 'react'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { DESIGN_OPTIONS } from '@/lib/shared/design/options'
import type { PortalDesign } from '@/lib/shared/design/schema'
import type { SupportedLocale } from '@/lib/shared/i18n'
import type { PortalPageGates } from '@/lib/shared/design/paths'
import { DesignTiles } from './design-tiles'
import { LanguagePicker } from './language-picker'
import { PageTitlesDrawing } from './drawings'

type Update = (change: (draft: PortalDesign) => void) => void
type TitledPage = (typeof DESIGN_OPTIONS.titledPage)[number]

const PAGE_NAMES: Record<TitledPage, string> = {
roadmap: 'Roadmap',
changelog: 'Changelog',
help: 'Help Center',
support: 'Support',
}

/**
* The other pages' titles: on the band or plain, and each page's own words.
* A page left unworded keeps its usual title.
*/
export function PageTitlesSection({
design,
update,
gates,
}: {
design: PortalDesign
update: Update
gates: PortalPageGates
}) {
const pages = DESIGN_OPTIONS.titledPage.filter((page) => gates[page])
const [page, setPage] = useState<TitledPage | undefined>(pages[0])
const [locale, setLocale] = useState<SupportedLocale>('en')
if (!page || !pages.includes(page)) return null
const headline = design.pages.titles?.[page]
return (
<section id="design-section-titles" className="scroll-mt-4 space-y-3">
<DesignTiles
name="design-page-titles"
label="Page titles"
value={design.layout.pageTitles ?? 'plain'}
columns={2}
onChange={(place) => update((draft) => void (draft.layout.pageTitles = place))}
options={DESIGN_OPTIONS.pageTitles.map((place) => ({
value: place,
label: place === 'band' ? 'On the band' : 'Plain',
drawing: <PageTitlesDrawing place={place} />,
}))}
/>
{design.layout.pageTitles === 'band' && design.layout.band === 'none' && (
<p className="text-xs text-muted-foreground">Titles go on the band once it has a style.</p>
)}
<div className="flex items-center justify-between gap-2">
<Select value={page} onValueChange={(next) => setPage(next as TitledPage)}>
<SelectTrigger className="w-40" aria-label="Page">
<SelectValue />
</SelectTrigger>
<SelectContent>
{pages.map((option) => (
<SelectItem key={option} value={option}>
{PAGE_NAMES[option]}
</SelectItem>
))}
</SelectContent>
</Select>
<LanguagePicker value={locale} onChange={setLocale} label="Title language" />
</div>
{(['title', 'subtitle'] as const).map((field) => (
<div key={field} className="space-y-1.5">
<label htmlFor={`design-page-${field}`} className="text-[13px] font-medium">
{field === 'title' ? 'Title' : 'Subtitle'}
</label>
<Input
id={`design-page-${field}`}
value={headline?.[field][locale] ?? ''}
maxLength={280}
placeholder="Its usual words"
onChange={(event) =>
update((draft) => {
const titles = (draft.pages.titles ??= {})
const words = titles[page] ?? { title: {}, subtitle: {} }
const text = event.target.value
if (text) words[field][locale] = text
else delete words[field][locale]
if (Object.keys(words.title).length + Object.keys(words.subtitle).length) {
titles[page] = words
} else delete titles[page]
if (!Object.keys(titles).length) delete draft.pages.titles
})
}
/>
</div>
))}
</section>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import type { PortalPageGates } from '@/lib/shared/design/paths'
import { DesignTiles } from './design-tiles'
import { LanguagePicker } from './language-picker'
import { HomeSection } from './home-section'
import { PageTitlesSection } from './page-titles-section'
import { MenuSection } from './menu-section'
import { CssSection, type CssWarning } from './css-section'
import { ImageSlot } from './image-slot'
Expand Down Expand Up @@ -354,6 +355,8 @@ export function SectionsPanel({
)}
</section>

<PageTitlesSection design={design} update={update} gates={gates} />

<section id="design-section-welcome" className="scroll-mt-4 space-y-3">
<SettingRow
label="Welcome message"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ export function useDesignDraft() {
fontIds: view.fontIds,
headline: view.feedback.headline,
home: view.home,
titles: view.titles,
nav: view.nav,
vocabulary: view.vocabulary,
copy: view.feedback.copy,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,10 @@ export function HelpCenterArticleFeedback({
const showReasonBox = feedback === 'not-helpful' && feedbackId !== null && !reasonSent

return (
<div className="mt-10 rounded-xl border border-border/50 bg-card px-5 py-4 flex items-center justify-between gap-4 flex-wrap">
<div
data-qb-surface="card"
className="mt-10 rounded-xl border border-border/50 bg-card px-5 py-4 flex items-center justify-between gap-4 flex-wrap"
>
<div>
<p className="text-sm font-semibold text-foreground">
<FormattedMessage
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export function HelpCenterCategoryGrid({ categories, locale }: HelpCenterCategor
slug: cat.slug,
}) as '/hc'
}
data-qb-surface="card"
className="group flex items-start gap-4 rounded-2xl border border-border bg-card p-6 transition-all duration-200 hover:-translate-y-0.5 hover:border-primary hover:bg-primary/[0.04] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background animate-in fade-in fill-mode-backwards"
style={{ animationDelay: `${index * 40}ms` }}
>
Expand Down
16 changes: 16 additions & 0 deletions apps/web/src/components/help-center/help-center-hero.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { ReactNode } from 'react'
import { cn } from '@/lib/shared/utils'
import { PortalPageTitle, useDesignTitles } from '@/components/public/portal-page-title'

interface HelpCenterHeroProps {
/** 'home' shows the heading + subtitle above the search; 'compact' is search-only. */
Expand Down Expand Up @@ -29,6 +30,21 @@ const GRID_MASK = 'radial-gradient(ellipse 80% 62% at 50% 0%, #000 30%, transpar
* overflow below it; only the decorative background layer clips its own glow.
*/
export function HelpCenterHero({ variant, title, description, children }: HelpCenterHeroProps) {
// On the design engine the help centre's title is the design's, as every
// page's is, and its pages carry no decoration of their own.
const designTitles = useDesignTitles()
if (designTitles && variant === 'home' && title)
return (
<PortalPageTitle page="help" title={title} subtitle={description}>
<div className="max-w-2xl">{children}</div>
</PortalPageTitle>
)
if (designTitles)
return (
<section className="relative w-full py-8 sm:py-10">
<div className="relative z-10 mx-auto max-w-6xl px-4 sm:px-6">{children}</div>
</section>
)
return (
<section
className={cn('relative w-full', variant === 'home' ? 'py-16 sm:py-24' : 'py-8 sm:py-10')}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@ export function HelpCenterPopularArticles({ articles }: HelpCenterPopularArticle
<h2 id="hc-popular" className="mb-5 text-2xl font-semibold tracking-tight text-foreground">
<FormattedMessage id="portal.hc.popularArticles.title" defaultMessage="Popular articles" />
</h2>
<div className="divide-y divide-border overflow-hidden rounded-2xl border border-border bg-card">
<div
data-qb-surface="card"
className="divide-y divide-border overflow-hidden rounded-2xl border border-border bg-card"
>
{articles.map((article) => (
<Link
key={article.id}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export function HelpCenterRelatedArticles({ articles, locale }: HelpCenterRelate
<Link
key={article.id}
to={hrefFor(article)}
data-qb-surface="card"
className="group flex items-start justify-between gap-3 rounded-xl border border-border bg-card px-4 py-3.5 transition-colors hover:border-primary/40 hover:bg-primary/[0.04] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<span className="min-w-0">
Expand Down
Loading
Loading