`
+ const { result, say } = picking()
+ act(() => result.current.select('block-aaaaaaaa', null, document.getElementById('more')))
+ expect(result.current.selection).toBe('block-aaaaaaaa')
+ expect(result.current.element).toEqual({
+ element: 'link',
+ part: null,
+ section: 'block-aaaaaaaa',
+ index: 0,
+ })
+ expect(say).toHaveBeenLastCalledWith('Link selected. Its settings are on the right.')
+ act(() => result.current.stepBack())
+ expect(result.current.selection).toBe('block-aaaaaaaa')
+ expect(result.current.element).toBeNull()
+ })
+})
diff --git a/apps/web/src/components/designer/__tests__/use-designer-events.test.tsx b/apps/web/src/components/designer/__tests__/use-designer-events.test.tsx
new file mode 100644
index 0000000000..67e41f474d
--- /dev/null
+++ b/apps/web/src/components/designer/__tests__/use-designer-events.test.tsx
@@ -0,0 +1,133 @@
+// @vitest-environment happy-dom
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { act, cleanup, renderHook } from '@testing-library/react'
+import type { PostHog } from 'posthog-js'
+import { setAnalyticsClient } from '@/lib/client/analytics'
+import { defaultDesign, type PortalDesign } from '@/lib/shared/design/schema'
+import type { DesignerPage, Selection } from '@/lib/shared/design/outline'
+import { useCountedUpdate, useDesignerEvents, useDesignerVisit } from '../use-designer-events'
+
+const capture = vi.fn()
+const events = () => capture.mock.calls.map(([event, props]) => [event, props])
+
+beforeEach(() => {
+ capture.mockReset()
+ setAnalyticsClient({ capture } as unknown as PostHog)
+})
+afterEach(() => {
+ cleanup()
+ setAnalyticsClient(null)
+})
+
+type State = {
+ page: DesignerPage
+ selection: Selection | null
+ panel: 'settings' | 'ai'
+ versions: boolean
+ help: boolean
+}
+
+function renderEvents() {
+ const design = defaultDesign()
+ design.pages.home.blocks = [{ id: 'abcdefgh12', type: 'links' } as never]
+ const initial: State = {
+ page: 'home',
+ selection: null,
+ panel: 'settings',
+ versions: false,
+ help: false,
+ }
+ const view = renderHook((state: State) => useDesignerEvents({ ...state, design }), {
+ initialProps: initial,
+ })
+ let state = initial
+ return (next: Partial) => {
+ state = { ...state, ...next }
+ view.rerender(state)
+ }
+}
+
+describe('useDesignerEvents', () => {
+ it('sends nothing as the builder first draws', () => {
+ renderEvents()
+ expect(capture).not.toHaveBeenCalled()
+ })
+
+ it('counts a page switch once', () => {
+ const set = renderEvents()
+ set({ page: 'roadmap' })
+ set({ page: 'roadmap' })
+ expect(events()).toEqual([['designer_page_switched', { page: 'roadmap' }]])
+ })
+
+ it('counts a pick once, naming a block by its type', () => {
+ const set = renderEvents()
+ set({ selection: 'block-abcdefgh12' })
+ set({ selection: 'block-abcdefgh12' })
+ set({ selection: 'header' })
+ expect(events()).toEqual([
+ ['designer_part_picked', { part: 'block:links' }],
+ ['designer_part_picked', { part: 'header' }],
+ ])
+ })
+
+ it('counts the panels: Quackback AI, the settings, and the overview when nothing is picked', () => {
+ const set = renderEvents()
+ set({ panel: 'ai' })
+ set({ panel: 'settings' })
+ set({ selection: 'header' })
+ set({ panel: 'ai' })
+ set({ panel: 'settings' })
+ set({ selection: null })
+ expect(events()).toEqual([
+ ['designer_panel_opened', { panel: 'ai' }],
+ ['designer_panel_opened', { panel: 'overview' }],
+ ['designer_part_picked', { part: 'header' }],
+ ['designer_panel_opened', { panel: 'ai' }],
+ ['designer_panel_opened', { panel: 'settings' }],
+ ['designer_panel_opened', { panel: 'overview' }],
+ ])
+ })
+
+ it('counts Versions and the shortcuts each time they open', () => {
+ const set = renderEvents()
+ set({ versions: true })
+ set({ versions: false })
+ set({ versions: true })
+ set({ help: true })
+ expect(events()).toEqual([
+ ['designer_versions_opened', {}],
+ ['designer_versions_opened', {}],
+ ['designer_panel_opened', { panel: 'help' }],
+ ])
+ })
+})
+
+describe('useDesignerVisit', () => {
+ it('counts the open, and the close once when the page goes', () => {
+ const view = renderHook(() => useDesignerVisit('appearance'))
+ expect(events()).toEqual([['designer_opened', { entry: 'appearance' }]])
+ act(() => void window.dispatchEvent(new Event('pagehide')))
+ view.unmount()
+ expect(capture.mock.calls.filter(([event]) => event === 'designer_closed')).toHaveLength(1)
+ })
+})
+
+describe('useCountedUpdate', () => {
+ it('passes the change on and counts it by its setting', () => {
+ let design = defaultDesign()
+ const draft = {
+ update: (change: (next: PortalDesign) => void) => {
+ const next = structuredClone(design)
+ change(next)
+ design = next
+ },
+ }
+ const { result } = renderHook(() =>
+ useCountedUpdate(draft as unknown as Parameters[0], 'help')
+ )
+ result.current((next) => void (next.layout.width = 'wide'), 'Width')
+ expect(design.layout.width).toBe('wide')
+ expect(events()).toEqual([['designer_setting_changed', { setting: 'layout.width' }]])
+ })
+})
diff --git a/apps/web/src/components/designer/__tests__/use-designer-page.test.tsx b/apps/web/src/components/designer/__tests__/use-designer-page.test.tsx
new file mode 100644
index 0000000000..630f17079d
--- /dev/null
+++ b/apps/web/src/components/designer/__tests__/use-designer-page.test.tsx
@@ -0,0 +1,48 @@
+// @vitest-environment happy-dom
+/**
+ * A sample is something to look at while designing, never a page of the
+ * portal: the page picker names it while it shows, but Review's pages and
+ * its before-and-after frames only ever hold the portal's real pages.
+ */
+import { describe, expect, it, vi } from 'vitest'
+import { act, renderHook } from '@testing-library/react'
+import { defaultDesign } from '@/lib/shared/design/schema'
+
+const settings = vi.hoisted(() => ({
+ featureFlags: { feedback: true, changelog: false, helpCenter: false },
+}))
+vi.mock('@tanstack/react-router', () => ({
+ useRouteContext: (options: { select: (context: unknown) => unknown }) =>
+ options.select({ settings }),
+}))
+vi.mock('@/lib/server/functions/portal-designer', () => ({
+ listDesignerPostsFn: vi.fn(async () => ({ ok: true, posts: [] })),
+}))
+
+import { useDesignerPage } from '../use-designer-page'
+
+function view(returnPath = '/roadmap') {
+ return renderHook(() => useDesignerPage(returnPath, defaultDesign()))
+}
+
+describe('useDesignerPage and samples', () => {
+ it('names a shown sample in the page picker but keeps it out of the real pages', () => {
+ const { result } = view()
+ expect(result.current.pages).not.toContain('changelog')
+ act(() => result.current.showSample('changelog'))
+ expect(result.current.page).toBe('changelog')
+ expect(result.current.shownPages).toContain('changelog')
+ expect(result.current.pages).not.toContain('changelog')
+ // Review opens on the real page the builder was on.
+ expect(result.current.realPage).toBe('roadmap')
+ })
+
+ it('keeps the real page under a sample of a part that is on', () => {
+ settings.featureFlags = { ...settings.featureFlags, changelog: true }
+ const { result } = view('/changelog')
+ act(() => result.current.showSample('changelog'))
+ expect(result.current.realPage).toBe('changelog')
+ expect(result.current.pages.filter((page) => page === 'changelog')).toHaveLength(1)
+ settings.featureFlags = { ...settings.featureFlags, changelog: false }
+ })
+})
diff --git a/apps/web/src/components/designer/__tests__/use-menu-editing.test.tsx b/apps/web/src/components/designer/__tests__/use-menu-editing.test.tsx
new file mode 100644
index 0000000000..106391483b
--- /dev/null
+++ b/apps/web/src/components/designer/__tests__/use-menu-editing.test.tsx
@@ -0,0 +1,81 @@
+// @vitest-environment happy-dom
+/**
+ * The menu edited on the page: a click opens an item's menu and a second
+ * click hands it the keyboard again, a click elsewhere or Escape closes it,
+ * and every change reaches the draft as a named step.
+ */
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { act, cleanup, renderHook } from '@testing-library/react'
+import { defaultDesign } from '@/lib/shared/design/schema'
+import { useMenuEditing } from '../use-menu-editing'
+
+vi.mock('@/lib/client/builder-events', () => ({ trackBuilder: vi.fn() }))
+
+afterEach(cleanup)
+
+const gates = {
+ feedback: true,
+ roadmap: true,
+ changelog: true,
+ help: false,
+ support: false,
+ status: false,
+}
+
+function show() {
+ const design = defaultDesign()
+ design.nav.home = { on: true, tab: true }
+ const props = {
+ design,
+ gates,
+ update: vi.fn(),
+ page: 'home' as const,
+ setPage: vi.fn(),
+ pick: vi.fn(),
+ say: vi.fn(),
+ }
+ const { result } = renderHook(() => useMenuEditing(props))
+ return { result, ...props }
+}
+
+describe('useMenuEditing', () => {
+ it('opens an item’s menu on a click, and again for the keyboard on a second', () => {
+ const { result } = show()
+ act(() => result.current.onMenu('roadmap', 'click'))
+ expect(result.current.open).toEqual({ target: 'roadmap', mode: 'menu', seq: 0 })
+ expect(result.current.menu?.entries[0]?.label).toBe('Go to Roadmap')
+ act(() => result.current.onMenu('roadmap', 'click'))
+ expect(result.current.open?.seq).toBe(1)
+ act(() => result.current.onMenu('roadmap', 'rename'))
+ expect(result.current.open?.mode).toBe('rename')
+ })
+
+ it('closes the menu on a click elsewhere, saying whether one was open', () => {
+ const { result } = show()
+ let was = true
+ act(() => void (was = result.current.dismiss()))
+ expect(was).toBe(false)
+ act(() => result.current.onMenu('feedback', 'click'))
+ act(() => void (was = result.current.dismiss()))
+ expect(was).toBe(true)
+ expect(result.current.open).toBeNull()
+ })
+
+ it('goes to the page, and makes each change a named step', () => {
+ const { result, setPage, update } = show()
+ act(() => result.current.act('roadmap', 'go'))
+ expect(setPage).toHaveBeenCalledWith('roadmap', null)
+ act(() => result.current.act('roadmap', 'hide'))
+ expect(update).toHaveBeenCalledWith(expect.any(Function), 'Hide Roadmap')
+ act(() => result.current.rename('changelog', 'News'))
+ expect(update).toHaveBeenCalledWith(expect.any(Function), 'Rename Changelog to News')
+ act(() => result.current.land('roadmap'))
+ expect(update).toHaveBeenLastCalledWith(expect.any(Function), 'Roadmap as the landing page')
+ })
+
+ it('leads Edit link to the link’s settings', () => {
+ const { result, pick } = show()
+ act(() => result.current.act('link:abc', 'edit-link'))
+ expect(pick).toHaveBeenCalledWith('link:abc')
+ })
+})
diff --git a/apps/web/src/components/designer/__tests__/use-preview-mode.test.tsx b/apps/web/src/components/designer/__tests__/use-preview-mode.test.tsx
new file mode 100644
index 0000000000..6841058b8d
--- /dev/null
+++ b/apps/web/src/components/designer/__tests__/use-preview-mode.test.tsx
@@ -0,0 +1,38 @@
+// @vitest-environment happy-dom
+/**
+ * Leaving Preview hands focus back to the Preview button, however it was
+ * left: from the top bar, or by an Escape the frame passed on.
+ */
+import { afterEach, describe, expect, it } from 'vitest'
+import { act, cleanup, render, screen } from '@testing-library/react'
+import { usePreviewMode } from '../use-preview-mode'
+
+afterEach(cleanup)
+
+let mode: ReturnType
+function Builder() {
+ mode = usePreviewMode()
+ return mode.previewing ? (
+
+ ) : (
+
+ )
+}
+
+describe('usePreviewMode', () => {
+ it('focuses Preview once the top bar is back, even when left outside a React event', () => {
+ render()
+ act(() => mode.enter())
+ expect(mode.previewing).toBe(true)
+ // A message from the frame: not a React event, so nothing is drawn yet when it is handled.
+ act(() => mode.leave())
+ expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Preview' }))
+ })
+
+ it('leaves focus alone when Preview was never left', () => {
+ render()
+ expect(document.activeElement).toBe(document.body)
+ })
+})
diff --git a/apps/web/src/components/designer/__tests__/view-options.test.tsx b/apps/web/src/components/designer/__tests__/view-options.test.tsx
new file mode 100644
index 0000000000..6cf9c09dcb
--- /dev/null
+++ b/apps/web/src/components/designer/__tests__/view-options.test.tsx
@@ -0,0 +1,52 @@
+// @vitest-environment happy-dom
+/**
+ * View options: how the page is shown while editing, as rows of tiles. They
+ * change nothing in the design. Preview as shows only when visitors choose
+ * light or dark, and nothing explains itself in a paragraph.
+ */
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'
+
+const { ViewOptions } = await import('../view-options')
+
+afterEach(cleanup)
+
+function setup(mode: 'light' | 'dark' | 'auto') {
+ const onTheme = vi.fn()
+ const onVisitor = vi.fn()
+ const view = render(
+ {}}
+ visitor="out"
+ onVisitor={onVisitor}
+ onKeys={() => {}}
+ />
+ )
+ return { onTheme, onVisitor, view }
+}
+
+const radio = (group: string, name: string) =>
+ within(screen.getByRole('radiogroup', { name: group })).getByRole('radio', { name })
+
+describe('ViewOptions', () => {
+ it('previews light or dark only where visitors choose', () => {
+ setup('light')
+ expect(screen.queryByRole('radiogroup', { name: 'Preview as' })).toBeNull()
+ cleanup()
+ const { onTheme } = setup('auto')
+ fireEvent.click(radio('Preview as', 'Dark'))
+ expect(onTheme).toHaveBeenCalledWith('dark')
+ })
+
+ it('picks the visitor from tiles, with no paragraph', () => {
+ const { onVisitor, view } = setup('auto')
+ expect(radio('Visitor', 'Signed out').getAttribute('aria-checked')).toBe('true')
+ fireEvent.click(radio('Visitor', 'Signed in'))
+ expect(onVisitor).toHaveBeenCalledWith('in')
+ expect(view.container.querySelector('p')).toBeNull()
+ })
+})
diff --git a/apps/web/src/components/designer/block-move.tsx b/apps/web/src/components/designer/block-move.tsx
new file mode 100644
index 0000000000..4e470ca8ef
--- /dev/null
+++ b/apps/web/src/components/designer/block-move.tsx
@@ -0,0 +1,89 @@
+import { useId, useRef } from 'react'
+import { ArrowDownIcon, ArrowUpIcon } from '@heroicons/react/16/solid'
+import { Button } from '@/components/ui/button'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
+
+/**
+ * The place to hand a move that is counted before the block is taken out,
+ * as a drop between two blocks reads, for a block ending at a position.
+ */
+export function placeOfPosition(from: number, to: number): number {
+ return to > from ? to + 1 : to
+}
+
+/**
+ * Moves a Home block without dragging: one place up or down, or to a place
+ * picked from the list.
+ */
+export function BlockMove({
+ names,
+ index,
+ onMove,
+ onPlace,
+}: {
+ /** Every block's name, in order. */
+ names: readonly string[]
+ /** Where this block is. */
+ index: number
+ onMove: (by: -1 | 1) => void
+ /** Moves it to end at a position, counted as the list reads now. */
+ onPlace: (position: number) => void
+}) {
+ const id = useId()
+ const up = useRef(null)
+ const down = useRef(null)
+ // A move to an end disables the button pressed; focus goes to the other one.
+ const move = (by: -1 | 1) => {
+ onMove(by)
+ const end = by < 0 ? index === 1 : index === names.length - 2
+ if (end) requestAnimationFrame(() => (by < 0 ? down : up).current?.focus())
+ }
+ return (
+
+
+
+
+ Move to
+
+
+
+ )
+}
diff --git a/apps/web/src/components/designer/words/__tests__/words-field.test.tsx b/apps/web/src/components/designer/words/__tests__/words-field.test.tsx
new file mode 100644
index 0000000000..e5e14edd97
--- /dev/null
+++ b/apps/web/src/components/designer/words/__tests__/words-field.test.tsx
@@ -0,0 +1,385 @@
+// @vitest-environment happy-dom
+/**
+ * The words editor in the panel: the format bar shows on a selection with
+ * only the marks the words allow, Enter keeps a line, Escape puts back what
+ * was there, a quiet count shows near the limit, and the other languages
+ * show as a hint.
+ */
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'
+import { useState } from 'react'
+import {
+ docText,
+ headlineDoc,
+ headlineFromDoc,
+ plainDoc,
+ readRichMarkdown,
+ type WordsDoc,
+ type WordsKind,
+} from '@/lib/shared/design/rich-words'
+import { WordsField } from '../words-field'
+import { resetWordsMore, WordsBubble } from '../words-bubble'
+import type { WordsRole } from '@/lib/shared/design/colour-roles'
+import { pressKey, retype, selectWords, typeWords, wordsEditor } from './words-test-utils'
+import type { WordsState } from '@/lib/shared/words-editor/extensions'
+
+afterEach(() => {
+ cleanup()
+ resetWordsMore()
+})
+
+const ROLES: WordsRole[] = [
+ { role: 'accent', name: 'Brand colour', colour: '#2255ff' },
+ { role: 'support', name: 'Second colour', colour: '#ffcc00' },
+]
+
+function Harness({
+ kind,
+ start,
+ onChange,
+ limit,
+ elsewhere,
+ roles,
+}: {
+ kind: WordsKind
+ roles?: WordsRole[]
+ start: WordsDoc
+ onChange?: (doc: WordsDoc) => void
+ limit?: number
+ elsewhere?: Array<{ locale: string; text: string }>
+}) {
+ const [value, setValue] = useState(start)
+ return (
+ {
+ setValue(doc)
+ onChange?.(doc)
+ }}
+ />
+ )
+}
+
+const rich = (text: string) => {
+ const read = readRichMarkdown(text)
+ if (!read.ok) throw new Error(read.refusal)
+ return read.doc
+}
+
+describe('the format bar', () => {
+ it('offers Accent, Highlight and Lighter on a headline, and nothing else', async () => {
+ render(
+
+ )
+ const editor = await wordsEditor('Words')
+ expect(screen.queryByRole('toolbar')).toBeNull()
+ selectWords(editor, 'Acme')
+ const bar = await screen.findByRole('toolbar', { name: 'Set words apart' })
+ expect(
+ within(bar)
+ .getAllByRole('button')
+ .map((b) => b.getAttribute('aria-label'))
+ ).toEqual(['Accent', 'Highlight', 'Lighter'])
+ })
+
+ it('offers bold, italic, a link and lists on rich words', async () => {
+ render()
+ const editor = await wordsEditor('Words')
+ selectWords(editor, 'Hello')
+ const bar = await screen.findByRole('toolbar', { name: 'Format words' })
+ expect(
+ within(bar)
+ .getAllByRole('button')
+ .map((b) => b.getAttribute('aria-label'))
+ ).toEqual(['Bold', 'Italic', 'Link', 'Bulleted list', 'Numbered list', 'More'])
+ })
+
+ it('shows no bar for plain words', async () => {
+ render()
+ selectWords(await wordsEditor('Words'), 'Top')
+ expect(screen.queryByRole('toolbar')).toBeNull()
+ })
+
+ it('moves the one phrase of a headline, and says which look is on', async () => {
+ const onChange = vi.fn()
+ render(
+
+ )
+ const editor = await wordsEditor('Words')
+ selectWords(editor, 'Help')
+ fireEvent.click(await screen.findByRole('button', { name: 'Highlight' }))
+ expect(headlineFromDoc(onChange.mock.lastCall![0])).toEqual({
+ title: 'Help shape Acme',
+ accent: { text: 'Help', style: 'highlight' },
+ })
+ expect(screen.getByRole('button', { name: 'Highlight' }).getAttribute('aria-pressed')).toBe(
+ 'true'
+ )
+ })
+
+ it('links the selected words from the address field', async () => {
+ const onChange = vi.fn()
+ render()
+ const editor = await wordsEditor('Words')
+ selectWords(editor, 'docs')
+ fireEvent.click(await screen.findByRole('button', { name: 'Link' }))
+ const field = screen.getByLabelText('Link address')
+ fireEvent.change(field, { target: { value: 'nope' } })
+ fireEvent.keyDown(field, { key: 'Enter' })
+ expect(screen.getByText('A web or email address')).toBeTruthy()
+ fireEvent.change(field, { target: { value: 'acme.com/docs' } })
+ fireEvent.keyDown(field, { key: 'Enter' })
+ expect(JSON.stringify(onChange.mock.lastCall![0])).toContain('https://acme.com/docs')
+ })
+
+ it('opens the address field on Mod-K', async () => {
+ render()
+ const editor = await wordsEditor('Words')
+ selectWords(editor, 'docs')
+ pressKey(editor, 'k', { ctrlKey: true })
+ expect(await screen.findByLabelText('Link address')).toBeTruthy()
+ })
+})
+
+describe('keys', () => {
+ it('puts back what was there on Escape', async () => {
+ const onChange = vi.fn()
+ render()
+ const editor = await wordsEditor('Words')
+ retype(editor, 'Loved')
+ expect(docText(onChange.mock.lastCall![0])).toBe('Loved')
+ pressKey(editor, 'Escape')
+ expect(docText(onChange.mock.lastCall![0])).toBe('Top posts')
+ })
+
+ it('keeps a line on Enter, with no second line', async () => {
+ const onChange = vi.fn()
+ render()
+ const editor = await wordsEditor('Words')
+ editor.commands.focus('end')
+ pressKey(editor, 'Enter')
+ typeWords(editor, ' posts')
+ expect(editor.getJSON().content).toHaveLength(1)
+ expect(editor.getText()).toBe('Top posts')
+ })
+})
+
+describe('limits and languages', () => {
+ it('shows a quiet count only near the limit', async () => {
+ render()
+ const editor = await wordsEditor('Words')
+ expect(document.querySelector('[data-words-count]')).toBeNull()
+ retype(editor, 'abcdefghi')
+ expect(document.querySelector('[data-words-count]')?.textContent).toBe('9/10')
+ })
+
+ it('shows the same words in the other languages as a hint', async () => {
+ render(
+
+ )
+ expect(await screen.findByText(/German: “Beliebte Ideen”/)).toBeTruthy()
+ })
+})
+
+describe('the bar alone', () => {
+ const base: WordsState = {
+ kind: 'headline',
+ selected: true,
+ selectedLength: 80,
+ accent: null,
+ accentElsewhere: false,
+ bold: false,
+ italic: false,
+ link: null,
+ bulletList: false,
+ orderedList: false,
+ heading: 0,
+ align: 'start',
+ colour: null,
+ accentColour: null,
+ length: 80,
+ limit: 280,
+ }
+
+ it('keeps a phrase over 60 characters from being set apart', () => {
+ render()
+ for (const name of ['Accent', 'Highlight', 'Lighter'])
+ expect((screen.getByRole('button', { name }) as HTMLButtonElement).disabled).toBe(true)
+ })
+
+ it('takes focus when asked, moves along with the arrows and goes back on Escape', () => {
+ const onReturn = vi.fn()
+ render(
+
+ )
+ expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Accent' }))
+ fireEvent.keyDown(document.activeElement!, { key: 'ArrowRight' })
+ expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Highlight' }))
+ fireEvent.keyDown(document.activeElement!, { key: 'Escape' })
+ expect(onReturn).toHaveBeenCalled()
+ })
+})
+
+describe('More in the format bar', () => {
+ const names = (bar: HTMLElement) =>
+ within(bar)
+ .getAllByRole('button')
+ .map((b) => b.getAttribute('aria-label'))
+
+ it('holds headings, alignment and the brand’s colours for rich words, and stays open', async () => {
+ const onChange = vi.fn()
+ render()
+ const editor = await wordsEditor('Words')
+ selectWords(editor, 'Hello')
+ const bar = await screen.findByRole('toolbar', { name: 'Format words' })
+ const more = within(bar).getByRole('button', { name: 'More' })
+ expect(more.getAttribute('aria-expanded')).toBe('false')
+ fireEvent.click(more)
+ expect(more.getAttribute('aria-expanded')).toBe('true')
+ expect(names(bar).slice(6)).toEqual([
+ 'Paragraph',
+ 'Heading 2',
+ 'Heading 3',
+ 'Start',
+ 'Centre',
+ 'End',
+ 'Text colour',
+ 'Brand colour',
+ 'Second colour',
+ ])
+ fireEvent.click(screen.getByRole('button', { name: 'Second colour' }))
+ fireEvent.click(screen.getByRole('button', { name: 'Heading 2' }))
+ fireEvent.click(screen.getByRole('button', { name: 'Centre' }))
+ expect(onChange.mock.lastCall![0].content[0]).toEqual({
+ type: 'heading',
+ attrs: { level: 2, textAlign: 'center' },
+ content: [
+ { type: 'text', text: 'Hello', marks: [{ type: 'colour', attrs: { role: 'support' } }] },
+ { type: 'text', text: ' there' },
+ ],
+ })
+ expect(screen.getByRole('button', { name: 'Heading 2' }).getAttribute('aria-pressed')).toBe(
+ 'true'
+ )
+ cleanup()
+ render()
+ selectWords(await wordsEditor('Words'), 'Again')
+ expect(await screen.findByRole('button', { name: 'Heading 3' })).toBeTruthy()
+ })
+
+ it('picks the colour of a headline’s phrase', async () => {
+ const onChange = vi.fn()
+ render(
+
+ )
+ const editor = await wordsEditor('Words')
+ selectWords(editor, 'Acme')
+ fireEvent.click(await screen.findByRole('button', { name: 'More' }))
+ expect(
+ screen.getByRole('button', { name: 'Phrase in brand colour' }).getAttribute('aria-pressed')
+ ).toBe('true')
+ fireEvent.click(screen.getByRole('button', { name: 'Phrase in second colour' }))
+ expect(headlineFromDoc(onChange.mock.lastCall![0]).accent).toEqual({
+ text: 'Acme',
+ style: 'colour',
+ colour: 'support',
+ })
+ })
+
+ it('is not offered on a headline with no phrase set apart, or one colour', async () => {
+ render(
+
+ )
+ selectWords(await wordsEditor('Words'), 'Acme')
+ const bar = await screen.findByRole('toolbar', { name: 'Set words apart' })
+ expect(names(bar)).toEqual(['Accent', 'Highlight', 'Lighter'])
+ })
+
+ it('keeps headings out of lists', () => {
+ render(
+
+ )
+ fireEvent.click(screen.getByRole('button', { name: 'More' }))
+ expect((screen.getByRole('button', { name: 'Heading 2' }) as HTMLButtonElement).disabled).toBe(
+ true
+ )
+ })
+})
+
+describe('Typing in a field', () => {
+ it('keeps the editor as it is, rather than setting it up again on each change', async () => {
+ const changes = vi.fn()
+ render()
+ const editor = await wordsEditor('Words')
+ const setOptions = vi.spyOn(editor, 'setOptions')
+ selectWords(editor, 'idea')
+ typeWords(editor, 'thought')
+ typeWords(editor, ' or two')
+ expect(changes).toHaveBeenCalled()
+ expect(docText(changes.mock.lastCall![0] as WordsDoc)).toBe('Got an thought or two?')
+ expect(setOptions).not.toHaveBeenCalled()
+ })
+
+ it('makes a new editor for a new kind of words, holding the words as they are then', async () => {
+ const { rerender } = render(
+ {}} />
+ )
+ const first = await wordsEditor('Words')
+ rerender(
+ {}} />
+ )
+ const second = await wordsEditor('Words')
+ expect(second).not.toBe(first)
+ expect(second.schema.nodes.bulletList).toBeDefined()
+ expect(second.getText()).toBe('Hello there')
+ })
+})
diff --git a/apps/web/src/components/designer/words/__tests__/words-layer.test.tsx b/apps/web/src/components/designer/words/__tests__/words-layer.test.tsx
new file mode 100644
index 0000000000..a1e245629e
--- /dev/null
+++ b/apps/web/src/components/designer/words/__tests__/words-layer.test.tsx
@@ -0,0 +1,180 @@
+// @vitest-environment happy-dom
+/**
+ * The canvas's format bar: drawn over the frame where the selected words
+ * are, from what the frame's words editor says, and sending back what to do.
+ */
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { defaultDesign } from '@/lib/shared/design/schema'
+import type { WordsState } from '@/lib/shared/words-editor/extensions'
+import { editOnCanvas } from '../edit-on-canvas'
+import { WordsLayer } from '../words-layer'
+
+afterEach(cleanup)
+
+const STATE: WordsState = {
+ kind: 'headline',
+ selected: true,
+ selectedLength: 4,
+ accent: null,
+ accentElsewhere: false,
+ bold: false,
+ italic: false,
+ link: null,
+ bulletList: false,
+ orderedList: false,
+ heading: 0,
+ align: 'start',
+ colour: null,
+ accentColour: null,
+ length: 15,
+ limit: 280,
+}
+
+function mount() {
+ const frame = document.createElement('iframe')
+ document.body.append(frame)
+ const sent = vi.spyOn(frame.contentWindow!, 'postMessage').mockImplementation(() => {})
+ const say = vi.fn()
+ const design = defaultDesign()
+ design.pages.home.headline = {
+ title: { en: 'Help shape Acme', de: 'Gestalte Acme' },
+ subtitle: {},
+ }
+ render(
+
+ )
+ const fromFrame = (data: Record) =>
+ act(() => {
+ window.dispatchEvent(
+ new MessageEvent('message', {
+ data,
+ origin: window.location.origin,
+ source: frame.contentWindow,
+ })
+ )
+ })
+ return { frame, sent, say, fromFrame }
+}
+
+describe('the words the frame edits', () => {
+ it('are the words the design stores, sent when the frame asks for them', () => {
+ const { sent, fromFrame } = mount()
+ fromFrame({ type: 'quackback:words-ask', field: 'home.title' })
+ expect(sent).toHaveBeenCalledWith(
+ {
+ type: 'quackback:words-open',
+ field: 'home.title',
+ doc: {
+ type: 'doc',
+ content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Help shape Acme' }] }],
+ },
+ },
+ window.location.origin
+ )
+ })
+
+ it('are none where the design keeps no words, so the frame reads the page’s', () => {
+ const { sent, fromFrame } = mount()
+ fromFrame({ type: 'quackback:words-ask', field: 'home.subtitle' })
+ expect(sent).toHaveBeenCalledWith(
+ { type: 'quackback:words-open', field: 'home.subtitle', doc: null },
+ window.location.origin
+ )
+ })
+
+ it('are never sent to a page that is not the frame', () => {
+ const { sent } = mount()
+ act(() => {
+ window.dispatchEvent(
+ new MessageEvent('message', {
+ data: { type: 'quackback:words-ask', field: 'home.title' },
+ origin: 'https://elsewhere.example',
+ source: window,
+ })
+ )
+ })
+ expect(sent).not.toHaveBeenCalled()
+ })
+})
+
+describe('words a panel asks to edit', () => {
+ it('are opened where they sit, by their field, in the frame', () => {
+ const { sent } = mount()
+ act(() => editOnCanvas('block-ideas0001.heading'))
+ expect(sent).toHaveBeenCalledWith(
+ { type: 'quackback:words-edit', field: 'block-ideas0001.heading' },
+ window.location.origin
+ )
+ })
+
+ it('are never asked of the frame for a field that is not words', () => {
+ const { sent } = mount()
+ act(() => editOnCanvas('nowhere'))
+ expect(sent).not.toHaveBeenCalled()
+ })
+})
+
+describe('the canvas’s format bar', () => {
+ it('shows the headline’s marks over the selected words, at the canvas’s scale', () => {
+ const { fromFrame } = mount()
+ fromFrame({
+ type: 'quackback:words-state',
+ field: 'home.title',
+ state: STATE,
+ box: { left: 100, top: 200, width: 400, height: 60 },
+ selection: { left: 300, top: 210, width: 80, height: 40 },
+ })
+ const bar = screen.getByRole('toolbar', { name: 'Set words apart' })
+ const place = bar.parentElement!.style
+ expect(place.left).toBe('150px')
+ // Above the words, by its foot, so it grows upwards as More opens.
+ expect(place.top).toBe(`${105 - 8}px`)
+ expect(bar.parentElement!.className).toContain('-translate-y-full')
+ expect(screen.getByText(/German: “Gestalte Acme”/)).toBeTruthy()
+ })
+
+ it('sends the frame what the bar asks', () => {
+ const { fromFrame, sent } = mount()
+ fromFrame({
+ type: 'quackback:words-state',
+ field: 'home.title',
+ state: STATE,
+ box: { left: 0, top: 200, width: 400, height: 60 },
+ selection: { left: 0, top: 210, width: 80, height: 40 },
+ })
+ fireEvent.click(screen.getByRole('button', { name: 'Lighter' }))
+ expect(sent).toHaveBeenCalledWith(
+ { type: 'quackback:words-command', command: { type: 'accent', style: 'muted' } },
+ window.location.origin
+ )
+ })
+
+ it('shows no bar for plain words, and goes when the edit ends', () => {
+ const { fromFrame } = mount()
+ const plain = {
+ type: 'quackback:words-state',
+ field: 'home.subtitle',
+ state: { ...STATE, kind: 'plain' },
+ box: { left: 0, top: 0, width: 10, height: 10 },
+ selection: { left: 0, top: 0, width: 10, height: 10 },
+ }
+ fromFrame(plain)
+ expect(screen.queryByRole('toolbar')).toBeNull()
+ fromFrame({ ...plain, field: 'home.title', state: STATE })
+ expect(screen.getByRole('toolbar')).toBeTruthy()
+ fromFrame({ type: 'quackback:words-end', field: 'home.title' })
+ expect(screen.queryByRole('toolbar')).toBeNull()
+ })
+
+ it('says when a Welcome body is left to its panel', () => {
+ const { fromFrame, say } = mount()
+ fromFrame({ type: 'quackback:words-blocked', field: 'block-welcome001.body' })
+ expect(say).toHaveBeenCalledWith(expect.stringMatching(/edited in its panel/))
+ })
+})
diff --git a/apps/web/src/components/designer/words/__tests__/words-test-utils.ts b/apps/web/src/components/designer/words/__tests__/words-test-utils.ts
new file mode 100644
index 0000000000..5f5cfde804
--- /dev/null
+++ b/apps/web/src/components/designer/words/__tests__/words-test-utils.ts
@@ -0,0 +1,49 @@
+import { act, screen } from '@testing-library/react'
+import type { Editor } from '@tiptap/core'
+
+/** The words editor a field shows, once it has mounted. */
+export async function wordsEditor(name: string | RegExp): Promise {
+ const box = await screen.findByRole('textbox', { name })
+ const editor = (box as HTMLElement & { editor?: Editor }).editor
+ if (!editor) throw new Error(`No words editor for ${String(name)}`)
+ return editor
+}
+
+/** Selects the first place a phrase is in the editor's words, as a person dragging over it. */
+export function selectWords(editor: Editor, phrase: string): void {
+ let at = -1
+ editor.state.doc.descendants((node, pos) => {
+ if (at < 0 && node.isText && node.text!.includes(phrase)) at = pos + node.text!.indexOf(phrase)
+ })
+ if (at < 0) throw new Error(`"${phrase}" is not in the words`)
+ act(() => {
+ editor.commands.focus()
+ editor.commands.setTextSelection({ from: at, to: at + phrase.length })
+ })
+}
+
+/** Types words where the cursor is. */
+export function typeWords(editor: Editor, text: string): void {
+ act(() => {
+ editor.commands.insertContent(text)
+ })
+}
+
+/** Replaces every word in the editor, as typing over a selected field does. */
+export function retype(editor: Editor, text: string): void {
+ act(() => {
+ editor.commands.focus()
+ editor.commands.selectAll()
+ if (text) editor.commands.insertContent(text)
+ else editor.commands.deleteSelection()
+ })
+}
+
+/** Presses a key in the editor, as its keymap hears it. */
+export function pressKey(editor: Editor, key: string, init: KeyboardEventInit = {}): void {
+ act(() => {
+ editor.view.someProp('handleKeyDown', (handle) =>
+ handle(editor.view, new KeyboardEvent('keydown', { key, ...init }))
+ )
+ })
+}
diff --git a/apps/web/src/lib/client/__tests__/autosave.test.ts b/apps/web/src/lib/client/__tests__/autosave.test.ts
index d614f85f70..212f8a6087 100644
--- a/apps/web/src/lib/client/__tests__/autosave.test.ts
+++ b/apps/web/src/lib/client/__tests__/autosave.test.ts
@@ -1,4 +1,5 @@
import { readFileSync } from 'node:fs'
+import { resolve } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { MutationCache, QueryClient } from '@tanstack/react-query'
@@ -30,7 +31,7 @@ describe('autosave mutation errors', () => {
})
it('loads sonner lazily so it stays out of the entry chunk', () => {
- const source = readFileSync(new URL('../autosave.ts', import.meta.url), 'utf8')
+ const source = readFileSync(resolve(__dirname, '../autosave.ts'), 'utf8')
expect(source).not.toMatch(/^import .* from 'sonner'/m)
expect(source).toContain("import('sonner')")
})
@@ -114,7 +115,7 @@ describe('revision conflict detection', () => {
})
describe('settings autosave hooks', () => {
- const source = readFileSync(new URL('../mutations/settings.ts', import.meta.url), 'utf8')
+ const source = readFileSync(resolve(__dirname, '../mutations/settings.ts'), 'utf8')
const body = (hook: string) => {
const start = source.indexOf(`export function ${hook}(`)
expect(start, hook).toBeGreaterThan(-1)
diff --git a/apps/web/src/lib/client/__tests__/brand-check.test.ts b/apps/web/src/lib/client/__tests__/brand-check.test.ts
new file mode 100644
index 0000000000..6a4e6d7337
--- /dev/null
+++ b/apps/web/src/lib/client/__tests__/brand-check.test.ts
@@ -0,0 +1,277 @@
+// @vitest-environment happy-dom
+/**
+ * The builder's side of the check: it reads the drawn preview and its
+ * picture, asks the server to score and to revise (saving the draft first),
+ * shows a candidate and reads it once it has drawn, and puts the best back
+ * when the candidate measures worse. The reader finds the portal's parts by
+ * the rig's selectors.
+ */
+import { describe, expect, it, vi } from 'vitest'
+import { defaultDesign } from '@/lib/shared/design/schema'
+import { BRAND_CHECK_URL, checkBrandMatch } from '../brand-check'
+import { readFingerprint } from '../preview-fingerprint'
+
+const json = (body: unknown) =>
+ new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json' } })
+
+describe('checkBrandMatch', () => {
+ it('scores, revises, shows the candidate, and puts the best back when it measures worse', async () => {
+ const candidate = { ...defaultDesign(), name: 'candidate' } as never
+ const scores = [
+ { ok: true, measured: 80, critic: 6, combined: 7, answer: { glance: 6 }, gaps: [] },
+ { ok: true, measured: 75, critic: 9, combined: 8.25, answer: null, gaps: [] },
+ ]
+ const bodies: Array> = []
+ const fetcher = vi.fn(async (url: string, init: RequestInit) => {
+ expect(url).toBe(BRAND_CHECK_URL)
+ const body = JSON.parse(String(init.body)) as Record
+ bodies.push(body)
+ if (body.op === 'score') return json(scores.shift())
+ return json({
+ ok: true,
+ changes: [{ path: 'tune.headline.size', value: 48, why: 'measured' }],
+ design: candidate,
+ })
+ })
+ const order: string[] = []
+ const shown: unknown[] = []
+ const reads: Array<{ before?: unknown }> = []
+ const start = defaultDesign()
+ const report = await checkBrandMatch(
+ {
+ target: { headline: { size: 48 } },
+ shots: ['data:image/jpeg;base64,AAAA'],
+ brand: 'Acme',
+ paths: ['tune.headline.size'],
+ palette: ['#0058a3'],
+ design: start,
+ },
+ {
+ frame: () => null,
+ show: (d) => {
+ order.push('show')
+ shown.push(d)
+ },
+ flush: async () => {
+ order.push('flush')
+ },
+ progress: () => {},
+ fetcher: fetcher as unknown as typeof fetch,
+ capture: async () => 'data:image/jpeg;base64,PIC',
+ read: (async (_frame: unknown, opts: { before?: unknown }) => {
+ reads.push(opts)
+ return { headline: { size: reads.length === 1 ? 56 : 50 } }
+ }) as never,
+ }
+ )
+ expect(bodies[0]).toMatchObject({
+ op: 'score',
+ round: 0,
+ target: { headline: { size: 48 } },
+ actual: { headline: { size: 56 } },
+ picture: 'data:image/jpeg;base64,PIC',
+ shots: ['data:image/jpeg;base64,AAAA'],
+ brand: 'Acme',
+ })
+ expect(bodies[1]).toMatchObject({
+ op: 'revise',
+ round: 1,
+ critic: { glance: 6 },
+ paths: ['tune.headline.size'],
+ palette: ['#0058a3'],
+ })
+ // The second read waits for the candidate to draw over the first reading.
+ expect(reads[1]).toEqual({ before: { headline: { size: 56 } } })
+ expect(order).toEqual(['flush', 'show', 'show'])
+ expect(shown).toEqual([candidate, start])
+ expect(report.stopped).toBe('no_gain')
+ expect(report.rounds[1]).toMatchObject({ kept: false })
+ })
+
+ it('stops on a refusal, keeping the design as applied', async () => {
+ const fetcher = vi.fn(async () =>
+ json({
+ ok: false,
+ code: 'AI_LIMIT',
+ message: 'Quackback AI has used this month’s allowance.',
+ })
+ )
+ const show = vi.fn()
+ const report = await checkBrandMatch(
+ { target: {}, shots: ['x'], paths: [], palette: [], design: defaultDesign() },
+ {
+ frame: () => null,
+ show,
+ flush: async () => {},
+ progress: () => {},
+ fetcher: fetcher as unknown as typeof fetch,
+ capture: async () => null,
+ read: (async () => ({})) as never,
+ }
+ )
+ expect(report).toMatchObject({ stopped: 'failed', error: expect.stringMatching(/allowance/) })
+ expect(show).not.toHaveBeenCalled()
+ })
+})
+
+describe('checkBrandMatch on its own frame', () => {
+ it('reads Home in the check’s frame, shows each design there too, and closes it after', async () => {
+ const candidate = { ...defaultDesign(), name: 'candidate' } as never
+ const start = defaultDesign()
+ const probeFrame = { id: 'probe' } as unknown as HTMLIFrameElement
+ const probe = { frame: () => probeFrame, show: vi.fn(), close: vi.fn() }
+ const open = vi.fn(async () => probe)
+ const frames: unknown[] = []
+ const scores = [
+ { ok: true, measured: 80, critic: 6, combined: 7, answer: null, gaps: [] },
+ { ok: true, measured: 90, critic: 7, combined: 8, answer: null, gaps: [] },
+ ]
+ const fetcher = vi.fn(async (_url: string, init: RequestInit) => {
+ const body = JSON.parse(String(init.body)) as Record
+ if (body.op === 'score') return json(scores.shift())
+ return body.round === 1
+ ? json({
+ ok: true,
+ changes: [{ path: 'tune.headline.weight', value: 300, why: 'w' }],
+ design: candidate,
+ })
+ : json({ ok: true, changes: [], design: null })
+ })
+ const show = vi.fn()
+ let reads = 0
+ const report = await checkBrandMatch(
+ {
+ target: { headline: { weight: 300 } },
+ shots: ['x'],
+ paths: ['tune.headline.weight'],
+ palette: [],
+ design: start,
+ },
+ {
+ frame: () => null,
+ open,
+ show,
+ flush: async () => {},
+ progress: () => {},
+ fetcher: fetcher as unknown as typeof fetch,
+ capture: async (frame) => {
+ frames.push(frame)
+ return null
+ },
+ read: (async (frame: () => unknown) => {
+ frames.push(frame())
+ reads++
+ return { headline: { weight: reads === 1 ? 500 : 300 } }
+ }) as never,
+ }
+ )
+ expect(open).toHaveBeenCalledWith(start)
+ expect(frames.every((f) => f === probeFrame)).toBe(true)
+ expect(show).toHaveBeenCalledWith(candidate)
+ expect(probe.show).toHaveBeenCalledWith(candidate)
+ expect(probe.close).toHaveBeenCalledTimes(1)
+ expect(report.rounds[1]).toMatchObject({ kept: true })
+ })
+
+ it('reads the canvas when its own frame cannot open', async () => {
+ const canvas = { id: 'canvas' } as unknown as HTMLIFrameElement
+ const frames: unknown[] = []
+ await checkBrandMatch(
+ { target: {}, shots: ['x'], paths: [], palette: [], design: defaultDesign() },
+ {
+ frame: () => canvas,
+ open: async () => {
+ throw new Error('not ready')
+ },
+ show: () => {},
+ flush: async () => {},
+ progress: () => {},
+ fetcher: (async () =>
+ json({
+ ok: true,
+ measured: 99,
+ critic: 9,
+ combined: 9.5,
+ answer: null,
+ gaps: [],
+ })) as never,
+ capture: async (frame) => {
+ frames.push(frame)
+ return null
+ },
+ read: (async (frame: () => unknown) => {
+ frames.push(frame())
+ return {}
+ }) as never,
+ }
+ )
+ expect(frames).toEqual([canvas, canvas])
+ })
+
+ it('does not ask the critic about a candidate whose measured score fell', async () => {
+ const candidate = { ...defaultDesign(), name: 'candidate' } as never
+ const ops: string[] = []
+ let reads = 0
+ const report = await checkBrandMatch(
+ {
+ target: { headline: { size: 48 } },
+ shots: ['x'],
+ paths: ['tune.headline.size'],
+ palette: [],
+ design: defaultDesign(),
+ },
+ {
+ frame: () => null,
+ show: () => {},
+ flush: async () => {},
+ progress: () => {},
+ fetcher: (async (_url: string, init: RequestInit) => {
+ const body = JSON.parse(String(init.body)) as Record
+ ops.push(String(body.op))
+ return body.op === 'score'
+ ? json({ ok: true, measured: 100, critic: 6, combined: 8, answer: null, gaps: [] })
+ : json({
+ ok: true,
+ changes: [{ path: 'tune.headline.size', value: 80, why: 'w' }],
+ design: candidate,
+ })
+ }) as never,
+ capture: async () => null,
+ read: (async () => {
+ reads++
+ return { headline: { size: reads === 1 ? 48 : 80 } }
+ }) as never,
+ }
+ )
+ expect(ops).toEqual(['score', 'revise'])
+ expect(report.rounds[1]).toMatchObject({ kept: false, score: { critic: null } })
+ })
+})
+
+describe('readFingerprint', () => {
+ it('reads the portal’s parts by the rig’s selectors', () => {
+ document.body.innerHTML = `
+ Feedback
+
+
Hello
+
Popular
+
A paragraph long enough to count as the body text of this page.
+
+
Card
+ `
+ // happy-dom lays nothing out: give every element a size, and an opacity to read.
+ for (const el of document.querySelectorAll('*')) (el as HTMLElement).style.opacity = '1'
+ for (const el of document.querySelectorAll('*'))
+ el.getBoundingClientRect = () =>
+ ({ width: 200, height: 40, top: 10, left: 0, right: 200, bottom: 50 }) as DOMRect
+ const fp = readFingerprint(document)
+ expect(fp.headline).toMatchObject({ size: 48, weight: 300, transform: 'uppercase' })
+ expect(fp.heading).toMatchObject({ size: 20 })
+ expect(fp.body).toMatchObject({ size: 16 })
+ expect(fp.link).toMatchObject({ size: 14, color: 'rgb(1, 2, 3)' })
+ expect(fp.button).toMatchObject({ bg: 'rgb(99, 91, 255)', radius: 4, height: 40 })
+ expect(fp.buttonText).toBe('Send')
+ // Corners as round as half the height read as fully round.
+ expect(fp.card).toMatchObject({ radius: 12 })
+ })
+})
diff --git a/apps/web/src/lib/client/__tests__/brand-match-stream.test.ts b/apps/web/src/lib/client/__tests__/brand-match-stream.test.ts
new file mode 100644
index 0000000000..d824d0beb7
--- /dev/null
+++ b/apps/web/src/lib/client/__tests__/brand-match-stream.test.ts
@@ -0,0 +1,69 @@
+/**
+ * The builder's side of the streamed brand match: steps reach the chat as
+ * they arrive, even when an event is split across chunks, and the result
+ * ends it; a refused request is a result, a stream with none is an error.
+ */
+import { describe, expect, it, vi } from 'vitest'
+import { BRAND_MATCH_URL, sseEvents, streamBrandMatch } from '../brand-match-stream'
+
+const event = (data: unknown) => `data: ${JSON.stringify(data)}\n\n`
+
+/** A response whose body arrives in the chunks given. */
+function streamed(chunks: string[], status = 200): Response {
+ const encoder = new TextEncoder()
+ const body = new ReadableStream({
+ start(controller) {
+ for (const chunk of chunks) controller.enqueue(encoder.encode(chunk))
+ controller.close()
+ },
+ })
+ return new Response(body, { status, headers: { 'Content-Type': 'text/event-stream' } })
+}
+
+const body = { message: 'match my brand', images: [] }
+
+describe('streamBrandMatch', () => {
+ it('reports each step as it arrives, then gives the result', async () => {
+ const all =
+ event({ type: 'STEP_STARTED', stepName: 'Reading your screenshots' }) +
+ event({ type: 'STEP_STARTED', stepName: 'Matching fonts' }) +
+ event({ type: 'CUSTOM', name: 'brand-match-result', value: { ok: true, reply: 'Done.' } })
+ // Split mid-event, as a network does.
+ const fetcher = vi.fn(async () =>
+ streamed([all.slice(0, 30), all.slice(30, 90), all.slice(90)])
+ )
+ const steps: string[] = []
+ const result = await streamBrandMatch(body, (s) => steps.push(s), fetcher as never)
+ expect(fetcher).toHaveBeenCalledWith(
+ BRAND_MATCH_URL,
+ expect.objectContaining({ method: 'POST' })
+ )
+ expect(steps).toEqual(['Reading your screenshots', 'Matching fonts'])
+ expect(result).toEqual({ ok: true, reply: 'Done.' })
+ })
+
+ it('gives a refused request as a result', async () => {
+ const fetcher = vi.fn(
+ async () =>
+ new Response(JSON.stringify({ error: { message: 'The screenshots could not be read.' } }), {
+ status: 400,
+ })
+ )
+ expect(await streamBrandMatch(body, () => {}, fetcher as never)).toEqual({
+ ok: false,
+ code: 'AI_FAILED',
+ message: 'The screenshots could not be read.',
+ })
+ })
+
+ it('throws when the stream ends without a result', async () => {
+ const fetcher = vi.fn(async () => streamed([event({ type: 'STEP_STARTED', stepName: 'x' })]))
+ await expect(streamBrandMatch(body, () => {}, fetcher as never)).rejects.toThrow()
+ })
+
+ it('skips lines that are not events', () => {
+ expect(sseEvents(': keepalive\n\ndata: [DONE]\n\ndata: {bad\n\n' + event({ a: 1 }))).toEqual([
+ { a: 1 },
+ ])
+ })
+})
diff --git a/apps/web/src/lib/client/__tests__/brand-shots.test.ts b/apps/web/src/lib/client/__tests__/brand-shots.test.ts
new file mode 100644
index 0000000000..87a90262ef
--- /dev/null
+++ b/apps/web/src/lib/client/__tests__/brand-shots.test.ts
@@ -0,0 +1,65 @@
+/**
+ * The header strip sent with a screenshot: the top of the page at the
+ * capture's own resolution, lossless, tall enough to hold the headline and
+ * its subtitle for the font stage, and shortened until its PNG fits the
+ * request's bound (a photo-heavy page encodes large).
+ */
+import { describe, expect, it } from 'vitest'
+import {
+ boundedJpeg,
+ HERO_MAX_CHARS,
+ heroRows,
+ STRIP_MAX_CHARS,
+ stripHeight,
+ tallestStrip,
+} from '../brand-shots'
+
+describe('tallestStrip', () => {
+ it('keeps the first height whose encoding fits, tallest first', () => {
+ const tried: number[] = []
+ const strip = tallestStrip(1800, 2, (h) => {
+ tried.push(h)
+ return 'x'.repeat(h >= 1800 ? STRIP_MAX_CHARS + 1 : 100)
+ })
+ expect(tried[0]).toBe(1800)
+ expect(strip).toEqual({ url: 'x'.repeat(100), height: tried[1] })
+ expect(tried[1]).toBeLessThan(1800)
+ })
+
+ it('never asks for more rows than the capture has', () => {
+ const tried: number[] = []
+ tallestStrip(500, 1, (h) => (tried.push(h), 'ok'))
+ expect(tried).toEqual([500])
+ })
+
+ it('gives nothing when even the header alone does not fit or cannot be encoded', () => {
+ expect(tallestStrip(1800, 2, () => 'x'.repeat(STRIP_MAX_CHARS + 1))).toBeNull()
+ expect(tallestStrip(1800, 2, () => null)).toBeNull()
+ })
+})
+
+describe('stripHeight', () => {
+ it('covers the first screen, about 900 CSS px, at the capture’s scale', () => {
+ expect(stripHeight(2)).toBe(1800)
+ expect(stripHeight(1)).toBe(900)
+ })
+})
+
+describe('the first screen for a hero photo', () => {
+ it('spans about 1100 CSS px at the capture’s scale, never more rows than it has', () => {
+ expect(heroRows(4000, 2)).toBe(2200)
+ expect(heroRows(900, 2)).toBe(900)
+ })
+
+ it('takes the first quality whose JPEG fits the bound, best first', () => {
+ const tried: number[] = []
+ const url = boundedJpeg(1800, (q) => {
+ tried.push(q)
+ return 'x'.repeat(q > 0.85 ? HERO_MAX_CHARS + 1 : 100)
+ })
+ expect(url).toBe('x'.repeat(100))
+ expect(tried[0]).toBeGreaterThan(tried[1]!)
+ expect(boundedJpeg(1800, () => 'x'.repeat(HERO_MAX_CHARS + 1))).toBeNull()
+ expect(boundedJpeg(0, () => 'ok')).toBeNull()
+ })
+})
diff --git a/apps/web/src/lib/client/__tests__/builder-events.test.ts b/apps/web/src/lib/client/__tests__/builder-events.test.ts
new file mode 100644
index 0000000000..dbf61806a1
--- /dev/null
+++ b/apps/web/src/lib/client/__tests__/builder-events.test.ts
@@ -0,0 +1,318 @@
+// @vitest-environment happy-dom
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type { PostHog } from 'posthog-js'
+import { setAnalyticsClient } from '@/lib/client/analytics'
+import {
+ BUILDER_EVENTS,
+ designerChanged,
+ designerAdvancedOpened,
+ designerClosed,
+ designerLookChosen,
+ designerOpened,
+ designerPublished,
+ fitsEvent,
+ partIdOf,
+ settingId,
+ trackBuilder,
+ type BuilderEvent,
+} from '../builder-events'
+import { defaultDesign, type PortalDesign } from '@/lib/shared/design/schema'
+
+const capture = vi.fn()
+const client = { capture } as unknown as PostHog
+
+/** Words, a link and a colour an admin might type. */
+const TYPED = ['Acme spring launch', 'https://example.com/pricing', '#ff3366', 'en']
+
+const sent = () => capture.mock.calls.map(([event, props]) => [event, props])
+const named = (event: string) => capture.mock.calls.filter(([name]) => name === event)
+
+function changed(change: (design: PortalDesign) => void) {
+ const before = defaultDesign()
+ const after = structuredClone(before)
+ change(after)
+ return [before, after] as const
+}
+
+let clock = Date.UTC(2026, 0, 1)
+beforeEach(() => {
+ vi.useFakeTimers()
+ capture.mockReset()
+ localStorage.clear()
+ setAnalyticsClient(client)
+ // Each test starts an hour after the last, past any earlier burst.
+ clock += 3_600_000
+ vi.setSystemTime(clock)
+})
+afterEach(() => {
+ designerClosed()
+ setAnalyticsClient(null)
+ vi.useRealTimers()
+})
+
+describe('the event list', () => {
+ const events = Object.entries(BUILDER_EVENTS) as Array<[BuilderEvent, Record]>
+
+ it.each(events)('%s takes no free text in any property', (event, spec) => {
+ for (const key of Object.keys(spec))
+ for (const text of TYPED)
+ expect(fitsEvent(event, { [key]: text }), `${key}=${text}`).toBe(false)
+ })
+
+ it.each(events)('%s takes no property it does not list', (event) => {
+ expect(fitsEvent(event, { note: 'Acme spring launch' })).toBe(false)
+ })
+
+ it('counts what a brand match from screenshots did, never the screenshots', () => {
+ expect(fitsEvent('designer_chat_shot_kept', { warning: 'dimmed' })).toBe(true)
+ expect(fitsEvent('designer_chat_shot_kept', { warning: 'blurry' })).toBe(false)
+ expect(fitsEvent('designer_brand_matched', { images: 2, changes: 14 })).toBe(true)
+ expect(fitsEvent('designer_brand_logo_changed', { how: 'full_logo' })).toBe(true)
+ })
+
+ it('takes counts and durations as whole numbers, and a ratio between 0 and 1', () => {
+ expect(fitsEvent('designer_brand_matched', { images: 1, changes: 3 })).toBe(true)
+ expect(fitsEvent('designer_brand_matched', { images: 1, changes: -1 })).toBe(false)
+ expect(fitsEvent('designer_brand_matched', { images: 1, changes: 1.5 })).toBe(false)
+ const published = {
+ changes: 2,
+ fullReview: true,
+ logoOffered: false,
+ workspaceLogo: false,
+ firstPublish: true,
+ seconds: 30,
+ }
+ expect(fitsEvent('designer_published', published)).toBe(true)
+ expect(fitsEvent('designer_published', { ...published, lookKept: 0.5 })).toBe(true)
+ expect(fitsEvent('designer_published', { ...published, lookKept: 2 })).toBe(false)
+ expect(fitsEvent('designer_published', { ...published, seconds: undefined })).toBe(false)
+ })
+
+ it('takes a setting only as a path the design schema names', () => {
+ expect(fitsEvent('designer_setting_changed', { setting: 'layout.band' })).toBe(true)
+ expect(fitsEvent('designer_setting_changed', { setting: 'layout.Acme' })).toBe(false)
+ })
+
+ it('does not send an event whose properties break its list', () => {
+ trackBuilder('designer_page_switched', { page: 'Acme pricing' as 'home' })
+ expect(capture).not.toHaveBeenCalled()
+ trackBuilder('designer_page_switched', { page: 'roadmap' })
+ expect(sent()).toEqual([['designer_page_switched', { page: 'roadmap' }]])
+ })
+})
+
+describe('with analytics off', () => {
+ it('sends nothing', () => {
+ setAnalyticsClient(null)
+ designerOpened('portal')
+ trackBuilder('designer_page_switched', { page: 'roadmap' })
+ designerChanged(...changed((d) => void (d.layout.band = 'brand')), 'home')
+ designerClosed()
+ expect(capture).not.toHaveBeenCalled()
+ })
+
+ it('holds the open until the SDK starts, and drops it when tracking pauses', () => {
+ setAnalyticsClient(null)
+ designerOpened('appearance')
+ expect(capture).not.toHaveBeenCalled()
+ setAnalyticsClient(client)
+ expect(named('designer_opened')).toEqual([['designer_opened', { entry: 'appearance' }]])
+
+ setAnalyticsClient(null)
+ designerOpened('portal')
+ setAnalyticsClient(null)
+ setAnalyticsClient(client)
+ expect(named('designer_opened')).toHaveLength(1)
+ })
+})
+
+describe('a visit', () => {
+ it('counts the open, and the close once with its length and whether it published', () => {
+ designerOpened('portal')
+ vi.advanceTimersByTime(90_000)
+ designerPublished(
+ { changes: [1], published: { number: 1 }, design: defaultDesign() },
+ { logoOffered: false, workspaceLogo: false, fullReview: true }
+ )
+ designerClosed()
+ designerClosed()
+ expect(named('designer_opened')).toEqual([['designer_opened', { entry: 'portal' }]])
+ expect(named('designer_closed')).toEqual([
+ ['designer_closed', { seconds: 90, published: true }],
+ ])
+ })
+})
+
+describe('designerChanged', () => {
+ it('counts a change once by its setting id, never its value', () => {
+ designerChanged(...changed((d) => void (d.layout.band = 'brand')), 'home')
+ expect(sent()).toEqual([['designer_setting_changed', { setting: 'layout.band' }]])
+ })
+
+ it('counts a burst of the same change, such as typing, once', () => {
+ designerChanged(...changed((d) => void (d.css = 'a')), 'roadmap')
+ vi.advanceTimersByTime(400)
+ designerChanged(...changed((d) => void (d.css = 'ab')), 'roadmap')
+ vi.advanceTimersByTime(400)
+ designerChanged(...changed((d) => void (d.css = 'abc')), 'roadmap')
+ expect(named('designer_setting_changed')).toHaveLength(1)
+ expect(named('designer_custom_css_edited')).toEqual([
+ ['designer_custom_css_edited', { page: 'roadmap' }],
+ ])
+ vi.advanceTimersByTime(1500)
+ designerChanged(...changed((d) => void (d.css = 'abcd')), 'roadmap')
+ expect(named('designer_setting_changed')).toHaveLength(2)
+ })
+
+ it('counts an exact value as a setting and as an exact value', () => {
+ designerChanged(...changed((d) => void (d.tune = { button: { radius: 4 } })), 'feedback')
+ expect(sent()).toEqual([
+ ['designer_setting_changed', { setting: 'tune.button.radius' }],
+ ['designer_exact_value_changed', { setting: 'tune.button.radius' }],
+ ])
+ })
+
+ it('names words by their field, not their language or text', () => {
+ designerChanged(
+ ...changed((d) => void (d.pages.feedback.copy = { prompt: { 'Acme launch': 'Hi' } })),
+ 'feedback'
+ )
+ expect(sent()).toEqual([
+ ['designer_setting_changed', { setting: 'pages.feedback.copy.prompt' }],
+ ])
+ })
+
+ it('counts a chosen look as one look, and what survives of it at publish', () => {
+ designerOpened('direct')
+ designerLookChosen('preset')
+ const [before, after] = changed((d) => {
+ d.layout.band = 'brand'
+ d.shape.corners = 'sharp'
+ })
+ designerChanged(before, after, 'home')
+ expect(named('designer_look_applied')).toEqual([
+ ['designer_look_applied', { source: 'preset', fields: 2 }],
+ ])
+ expect(named('designer_setting_changed')).toHaveLength(0)
+
+ // The next change is a change again.
+ const changedBack = structuredClone(after)
+ changedBack.shape.corners = 'rounded'
+ designerChanged(after, changedBack, 'home')
+ expect(named('designer_setting_changed')).toHaveLength(1)
+
+ designerPublished(
+ { changes: [1, 2], published: null, design: changedBack },
+ { logoOffered: true, workspaceLogo: true, fullReview: true }
+ )
+ expect(named('designer_published')[0]![1]).toMatchObject({
+ changes: 2,
+ logoOffered: true,
+ workspaceLogo: true,
+ firstPublish: true,
+ lookFields: 2,
+ lookKept: 0.5,
+ })
+ // Counted towards one publish only.
+ designerPublished(
+ { changes: [1], published: { number: 1 }, design: changedBack },
+ { logoOffered: false, workspaceLogo: false, fullReview: true }
+ )
+ expect(named('designer_published')[1]![1]).not.toHaveProperty('lookKept')
+ })
+
+ it('times the first publish from the first open on this device', () => {
+ designerOpened('direct')
+ designerClosed()
+ vi.advanceTimersByTime(600_000)
+ designerOpened('direct')
+ vi.advanceTimersByTime(60_000)
+ designerPublished(
+ { changes: [1], published: null, design: defaultDesign() },
+ { logoOffered: false, workspaceLogo: false, fullReview: false }
+ )
+ expect(named('designer_published')[0]![1]).toMatchObject({
+ seconds: 60,
+ secondsSinceFirstOpen: 660,
+ })
+ })
+})
+
+describe('publishing from the summary or the full review', () => {
+ it('says which one the publish came from', () => {
+ const draft = { changes: [1], published: { number: 1 }, design: defaultDesign() }
+ designerPublished(draft, { logoOffered: false, workspaceLogo: false, fullReview: false })
+ designerPublished(draft, { logoOffered: false, workspaceLogo: false, fullReview: true })
+ expect(named('designer_published').map(([, props]) => props.fullReview)).toEqual([false, true])
+ })
+})
+
+describe('the builder’s panels and sheets', () => {
+ it('counts each one opened by name', () => {
+ for (const panel of ['pages', 'add', 'theme', 'theme_more', 'ai'] as const)
+ trackBuilder('designer_panel_opened', { panel })
+ expect(named('designer_panel_opened').map(([, props]) => props.panel)).toEqual([
+ 'pages',
+ 'add',
+ 'theme',
+ 'theme_more',
+ 'ai',
+ ])
+ })
+
+ it('no longer counts the rail’s brand kit and styles, which Theme replaced', () => {
+ trackBuilder('designer_panel_opened', { panel: 'brand_kit' as never })
+ trackBuilder('designer_panel_opened', { panel: 'styles' as never })
+ expect(named('designer_panel_opened')).toEqual([])
+ })
+})
+
+describe('the top bar', () => {
+ it('counts a device switched to, by name only', () => {
+ trackBuilder('designer_device_switched', { device: 'phone' })
+ trackBuilder('designer_device_switched', { device: 'watch' as never })
+ expect(named('designer_device_switched').map(([, props]) => props)).toEqual([
+ { device: 'phone' },
+ ])
+ })
+
+ it('counts a discarded draft by its number of changes', () => {
+ trackBuilder('designer_draft_discarded', { changes: 4 })
+ expect(named('designer_draft_discarded').map(([, props]) => props)).toEqual([{ changes: 4 }])
+ })
+})
+
+describe('designerAdvancedOpened', () => {
+ it('names the part whose Advanced was opened', () => {
+ designerAdvancedOpened('site:styles')
+ designerAdvancedOpened('hero')
+ expect(named('designer_advanced_opened').map(([, props]) => props)).toEqual([
+ { part: 'site:styles' },
+ { part: 'hero' },
+ ])
+ })
+})
+
+describe('partIdOf', () => {
+ it('names a Home block by its type, never its id', () => {
+ const design = defaultDesign()
+ design.pages.home.blocks = [{ id: 'abcdefgh12', type: 'links' } as never]
+ expect(partIdOf('block-abcdefgh12', design)).toBe('block:links')
+ expect(partIdOf('site:styles', design)).toBe('site:styles')
+ expect(partIdOf('site:brand', design)).toBe('site:brand')
+ expect(partIdOf('block-missing', design)).toBeNull()
+ })
+
+ it('names a menu part by its page, and a link only as a link, never its id or words', () => {
+ const design = defaultDesign()
+ expect(partIdOf('page:help', design)).toBe('page:help')
+ expect(partIdOf('page:post', design)).toBe('page:post')
+ expect(partIdOf('link:5f1c2d3e-aaaa-bbbb-cccc-111122223333', design)).toBe('link')
+ })
+})
+
+describe('settingId', () => {
+ it('is the only way to a setting id', () => {
+ expect(settingId('layout.band')).toBe('layout.band')
+ })
+})
diff --git a/apps/web/src/lib/client/__tests__/check-frame.test.ts b/apps/web/src/lib/client/__tests__/check-frame.test.ts
new file mode 100644
index 0000000000..39fcaed96a
--- /dev/null
+++ b/apps/web/src/lib/client/__tests__/check-frame.test.ts
@@ -0,0 +1,85 @@
+// @vitest-environment happy-dom
+/**
+ * The check reads the page a brand match changes (Home) at the screenshots'
+ * width, in a frame of its own off screen, so the builder's canvas stays on
+ * the page someone is looking at: the frame is laid out 1440px wide, gets
+ * the design once the portal says it is ready (and again after a reload),
+ * every later design too, and waits for the page's data to draw.
+ */
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { CHECK_VIEWPORT, contentDrawn, openCheckFrame } from '../check-frame'
+
+afterEach(() => {
+ document.body.innerHTML = ''
+})
+
+/** The portal in the frame saying it is ready, as portal-preview-listener does. */
+function ready(frame: HTMLIFrameElement) {
+ window.dispatchEvent(
+ new MessageEvent('message', {
+ data: { type: 'quackback:preview-ready', path: '/' },
+ origin: window.location.origin,
+ source: frame.contentWindow,
+ })
+ )
+}
+
+describe('openCheckFrame', () => {
+ it('lays out Home off screen at the screenshots’ width and sends it each design', async () => {
+ const preview = (d: { name: string }) => ({ css: d.name })
+ const opening = openCheckFrame({
+ src: 'about:blank',
+ design: { name: 'first' },
+ preview,
+ settle: async () => {},
+ })
+ const frame = document.querySelector('iframe[data-brand-check]')!
+ expect(frame).toBeTruthy()
+ expect(frame.getAttribute('src')).toBe('about:blank')
+ expect(frame.style.width).toBe(`${CHECK_VIEWPORT.width}px`)
+ expect(frame.style.height).toBe(`${CHECK_VIEWPORT.height}px`)
+ expect(frame.getAttribute('aria-hidden')).toBe('true')
+ expect(frame.tabIndex).toBe(-1)
+ const posted: unknown[] = []
+ vi.spyOn(frame.contentWindow!, 'postMessage').mockImplementation((m: unknown) => {
+ posted.push(m)
+ })
+ ready(frame)
+ const probe = await opening
+ expect(posted).toEqual([{ type: 'quackback:preview-design', design: { css: 'first' } }])
+ expect(probe.frame()).toBe(frame)
+ probe.show({ name: 'second' })
+ expect(posted.at(-1)).toEqual({ type: 'quackback:preview-design', design: { css: 'second' } })
+ // A reload starts from the saved design: the latest is sent again.
+ ready(frame)
+ expect(posted.at(-1)).toEqual({ type: 'quackback:preview-design', design: { css: 'second' } })
+ probe.close()
+ expect(document.querySelector('iframe[data-brand-check]')).toBeNull()
+ })
+
+ it('gives up, removing its frame, when the portal never says it is ready', async () => {
+ await expect(
+ openCheckFrame({
+ src: 'about:blank',
+ design: {},
+ preview: (d) => d,
+ timeoutMs: 20,
+ settle: async () => {},
+ })
+ ).rejects.toThrow(/ready/)
+ expect(document.querySelector('iframe[data-brand-check]')).toBeNull()
+ })
+})
+
+describe('contentDrawn', () => {
+ it('waits while the page still shows skeletons or busy regions', () => {
+ document.body.innerHTML = ''
+ expect(contentDrawn(document)).toBe(false)
+ document.body.innerHTML = '
'
+ expect(contentDrawn(document)).toBe(false)
+ document.body.innerHTML = ''
+ expect(contentDrawn(document)).toBe(false)
+ document.body.innerHTML = 'Post'
+ expect(contentDrawn(document)).toBe(true)
+ })
+})
diff --git a/apps/web/src/lib/client/__tests__/preview-capture.test.ts b/apps/web/src/lib/client/__tests__/preview-capture.test.ts
new file mode 100644
index 0000000000..e8f22e41b2
--- /dev/null
+++ b/apps/web/src/lib/client/__tests__/preview-capture.test.ts
@@ -0,0 +1,35 @@
+// @vitest-environment happy-dom
+/**
+ * The critic's picture is the frame's page drawn through an SVG, which is a
+ * still at its first moment: a list that fades in would be drawn invisible,
+ * so the picture holds animations and transitions at their end.
+ */
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { capturePreview } from '../preview-capture'
+
+afterEach(() => {
+ vi.restoreAllMocks()
+ document.body.innerHTML = ''
+})
+
+describe('capturePreview', () => {
+ it('draws the page with its fade-in animations held at their end', async () => {
+ const frame = document.createElement('iframe')
+ document.body.appendChild(frame)
+ const doc = frame.contentDocument!
+ doc.body.innerHTML =
+ '
A post
'
+ let drawn = ''
+ vi.spyOn(HTMLImageElement.prototype, 'decode').mockImplementation(function (
+ this: HTMLImageElement
+ ) {
+ drawn = decodeURIComponent(this.src)
+ return Promise.reject(new Error('stop here'))
+ })
+ expect(await capturePreview(frame)).toBeNull()
+ expect(drawn).toContain('A post')
+ expect(drawn).toMatch(
+ /\*, \*::before, \*::after \{ animation: none !important; transition: none !important; \}/
+ )
+ })
+})
diff --git a/apps/web/src/lib/client/__tests__/route-context-memo.test.ts b/apps/web/src/lib/client/__tests__/route-context-memo.test.ts
index d38f8ff3c3..42113fae0b 100644
--- a/apps/web/src/lib/client/__tests__/route-context-memo.test.ts
+++ b/apps/web/src/lib/client/__tests__/route-context-memo.test.ts
@@ -159,6 +159,24 @@ describe('parts of a new answer', () => {
})
})
+describe('keys', () => {
+ it('asks again for a different key, such as a preview link, and keeps one answer', async () => {
+ const context = memo.createRouteContextMemo()
+ const load = vi.fn().mockResolvedValueOnce('live').mockResolvedValueOnce('preview')
+
+ expect(await context.get(load)).toBe('live')
+ expect(await context.get(load, 'share:a')).toBe('preview')
+ expect(await context.get(load, 'share:a')).toBe('preview')
+ expect(load).toHaveBeenCalledTimes(2)
+ })
+
+ it('serves a seeded answer only to its own key', async () => {
+ const context = memo.createRouteContextMemo()
+ context.seed('preview', 'share:a')
+ expect(await context.get(async () => 'live')).toBe('live')
+ })
+})
+
describe('seed', () => {
it('serves the server-rendered context to the first navigation', async () => {
const context = memo.createRouteContextMemo()
diff --git a/apps/web/src/lib/client/__tests__/style-reader.test.ts b/apps/web/src/lib/client/__tests__/style-reader.test.ts
new file mode 100644
index 0000000000..cad607f7cb
--- /dev/null
+++ b/apps/web/src/lib/client/__tests__/style-reader.test.ts
@@ -0,0 +1,167 @@
+// @vitest-environment happy-dom
+/**
+ * The loop's verifier in the builder: a hover the page's CSS draws is copied
+ * to a forcing attribute (nested rules, layers and conditions kept; a
+ * "can this device hover" query dropped), an element is read in its state
+ * and the state taken off again, and an element the page does not show
+ * reads as not found. The builder's loop reads the drawn frame when it has
+ * one and the design when it has none, and adopts each step's draft.
+ */
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { defaultDesign, portalDesign } from '@/lib/shared/design/schema'
+import { STYLE_REGISTRY, targetKey } from '@/lib/shared/design/agent/style-registry'
+import {
+ FORCE_ATTR,
+ forcedStateCss,
+ forceSelector,
+ readStyleTargets,
+ type CssNode,
+} from '../style-reader'
+import { runBuilderEditLoop, type ServerStep } from '../edit-loop'
+
+afterEach(() => {
+ document.head.querySelectorAll('style').forEach((s) => s.remove())
+ document.body.innerHTML = ''
+})
+
+describe('forcing states', () => {
+ it('swaps each state for the forcing attribute', () => {
+ expect(forceSelector('a:hover, b:focus-visible, c:focus, d:active, e:focus-within')).toBe(
+ `a[${FORCE_ATTR}~="hover"], b[${FORCE_ATTR}~="focus"], c[${FORCE_ATTR}~="focus"], d[${FORCE_ATTR}~="pressed"], e:focus-within`
+ )
+ })
+
+ it('copies only rules that need a state, with their layers, nesting and conditions', () => {
+ const nodes: CssNode[] = [
+ {
+ kind: 'group',
+ prelude: '@layer utilities',
+ children: [
+ { kind: 'style', selector: '.p', declarations: 'padding: 1px;', children: [] },
+ {
+ kind: 'style',
+ selector: '.hover\\:bg',
+ declarations: '',
+ children: [
+ {
+ kind: 'style',
+ selector: '&:hover',
+ declarations: '',
+ children: [
+ {
+ kind: 'group',
+ prelude: '@media (hover: hover)',
+ children: [{ kind: 'decls', declarations: 'background-color: red;' }],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ {
+ kind: 'group',
+ prelude: '@media (min-width: 40rem)',
+ children: [
+ { kind: 'style', selector: 'a:hover', declarations: 'color: blue;', children: [] },
+ ],
+ },
+ ]
+ expect(forcedStateCss(nodes).split('\n')).toEqual([
+ `@layer utilities { :is(.hover\\:bg)[${FORCE_ATTR}~="hover"] { background-color: red; } }`,
+ `@media (min-width: 40rem) { a[${FORCE_ATTR}~="hover"] { color: blue; } }`,
+ ])
+ })
+})
+
+describe('reading targets', () => {
+ it('reads an element at rest and in a forced state, then takes the state off', () => {
+ const style = document.createElement('style')
+ style.textContent =
+ '[data-slot=button][data-variant=default] { background-color: rgb(79, 70, 229); color: rgb(255, 255, 255); } [data-slot=button][data-variant=default]:hover { background-color: rgb(55, 48, 163); }'
+ document.head.appendChild(style)
+ document.body.innerHTML =
+ ''
+ const button = document.querySelector('button')!
+ vi.spyOn(button, 'getBoundingClientRect').mockReturnValue({ width: 80, height: 30 } as DOMRect)
+ const base = { element: 'button', state: 'base' as const, block: '' }
+ const hover = { ...base, state: 'hover' as const }
+ const voted = { element: 'vote', state: 'current' as const, block: '' }
+ const readings = readStyleTargets(document, STYLE_REGISTRY, [base, hover, voted])
+ expect(readings[targetKey(base)]!.values.background).toBe('rgb(79, 70, 229)')
+ expect(readings[targetKey(hover)]!.values.background).toBe('rgb(55, 48, 163)')
+ expect(readings[targetKey(hover)]!.behind).toBe('#fafafa')
+ expect(readings[targetKey(voted)]!.found).toBe(false)
+ // Nothing left behind.
+ expect(document.querySelector(`[${FORCE_ATTR}]`)).toBeNull()
+ expect(document.querySelector('style[data-qb-forced-states]')).toBeNull()
+ })
+})
+
+describe('the builder’s loop', () => {
+ it('reads the design itself without a frame, and adopts each step’s draft', async () => {
+ const design = portalDesign.parse(defaultDesign())
+ const after = structuredClone(design)
+ after.layout.cards = 'raised'
+ const adopt = vi.fn()
+ const step = vi.fn(async (_body: unknown): Promise => ({
+ ok: true,
+ answer: {
+ plan: 'Raise the cards',
+ ops: [{ op: 'theme', target: '', key: 'cards', value: 'raised', says: '' }],
+ styles: [],
+ checks: [
+ {
+ element: 'card',
+ state: 'base',
+ block: '',
+ target: '',
+ property: 'shadow',
+ expect: 'present',
+ value: '',
+ },
+ ],
+ read: [],
+ look: false,
+ read_website: '',
+ match_website: '',
+ done: true,
+ reply: 'Cards are raised.',
+ },
+ outcome: {
+ applied: [
+ {
+ path: 'layout.cards',
+ before: design.layout.cards,
+ after: 'raised',
+ label: 'Cards to raised',
+ },
+ ],
+ done: ['Cards to raised'],
+ already: [],
+ refused: [],
+ outOfScope: [],
+ },
+ design: after,
+ report: { adjustments: [], errors: [], css: [] },
+ revision: 'r2',
+ }))
+ const result = await runBuilderEditLoop(
+ {
+ message: 'Raise the cards with a shadow',
+ scope: 'site',
+ history: [],
+ design,
+ targets: [],
+ budget: { steps: 4, ms: 20_000 },
+ },
+ { step, adopt, previewCss: () => '' }
+ )
+ expect(adopt).toHaveBeenCalledWith(after, { adjustments: [], errors: [], css: [] }, 'r2')
+ expect(step.mock.calls[0]![0]).toMatchObject({
+ message: 'Raise the cards with a shadow',
+ maxSteps: 4,
+ })
+ expect(result).toMatchObject({ ok: true, reply: 'Cards are raised.', stopped: 'done' })
+ })
+})
diff --git a/apps/web/src/lib/client/hooks/__tests__/use-branding-font.test.tsx b/apps/web/src/lib/client/hooks/__tests__/use-branding-font.test.tsx
new file mode 100644
index 0000000000..e5451c4626
--- /dev/null
+++ b/apps/web/src/lib/client/hooks/__tests__/use-branding-font.test.tsx
@@ -0,0 +1,37 @@
+// @vitest-environment happy-dom
+/**
+ * A legacy portal loads the one family its CSS names; a design loads its body
+ * and heading families as well, each once.
+ */
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { cleanup, renderHook } from '@testing-library/react'
+
+const loaded = vi.hoisted(() => ({ ids: [] as string[] }))
+vi.mock('@/lib/shared/theme', async (original) => ({
+ ...(await original()),
+ loadBrandingFont: (id: string) => {
+ loaded.ids.push(id)
+ return Promise.resolve()
+ },
+}))
+
+const { useBrandingFont } = await import('../use-branding-font')
+
+afterEach(() => {
+ cleanup()
+ loaded.ids = []
+})
+
+describe('useBrandingFont', () => {
+ it('loads the family a legacy theme names', () => {
+ renderHook(() => useBrandingFont('', '"Manrope", ui-sans-serif, system-ui, sans-serif'))
+ expect(loaded.ids).toEqual(['manrope'])
+ })
+
+ it('loads a design’s body and heading families once each', () => {
+ renderHook(() =>
+ useBrandingFont('', '"Fraunces", ui-serif, Georgia, serif', ['inter', 'fraunces', 'fraunces'])
+ )
+ expect(loaded.ids).toEqual(['inter', 'fraunces'])
+ })
+})
diff --git a/apps/web/src/lib/client/queries/portal-home-ideas.test.ts b/apps/web/src/lib/client/queries/portal-home-ideas.test.ts
new file mode 100644
index 0000000000..6518679b39
--- /dev/null
+++ b/apps/web/src/lib/client/queries/portal-home-ideas.test.ts
@@ -0,0 +1,51 @@
+/**
+ * Home's Top posts asks the server for just the posts it shows, from the
+ * boards it was set to, and a block without boards asks as it always did.
+ */
+import { describe, expect, it, vi } from 'vitest'
+
+const server = vi.hoisted(() => ({ fetchPublicPosts: vi.fn(async () => ({ items: [] })) }))
+vi.mock('@/lib/server/functions/portal', () => ({
+ fetchPublicPosts: server.fetchPublicPosts,
+ fetchPortalData: vi.fn(),
+ fetchPublicBoards: vi.fn(),
+ fetchBoardCapabilitiesFn: vi.fn(),
+ fetchPublicStatuses: vi.fn(),
+ fetchPublicTags: vi.fn(),
+ fetchAvatars: vi.fn(),
+ fetchPublicRoadmaps: vi.fn(),
+ fetchPublicRoadmapPosts: vi.fn(),
+}))
+
+import { portalQueries } from './portal'
+
+const BOARD = 'board_01h455vb4pex5vsknk084sn02q'
+const run = (options: ReturnType) =>
+ (options.queryFn as () => Promise)()
+
+describe('the Home Top posts query', () => {
+ it('asks for the boards chosen and as many posts as the block shows', async () => {
+ const options = portalQueries.homeIdeas({ sort: 'top', count: 10, boardIds: [BOARD] as never })
+ await run(options)
+ expect(server.fetchPublicPosts).toHaveBeenLastCalledWith({
+ data: { sort: 'top', limit: 10, boardIds: [BOARD] },
+ })
+ })
+
+ it('leaves boards out when none is chosen, so every board shows', async () => {
+ await run(portalQueries.homeIdeas({ sort: 'new', count: 3 }))
+ expect(server.fetchPublicPosts).toHaveBeenLastCalledWith({ data: { sort: 'new', limit: 3 } })
+ await run(portalQueries.homeIdeas({ sort: 'new', count: 3, boardIds: [] }))
+ expect(server.fetchPublicPosts).toHaveBeenLastCalledWith({ data: { sort: 'new', limit: 3 } })
+ })
+
+ it('keeps each choice of boards and count in its own cache entry', () => {
+ const key = (boardIds?: string[], count: 3 | 5 | 10 = 3) =>
+ JSON.stringify(
+ portalQueries.homeIdeas({ sort: 'top', count, boardIds: boardIds as never }).queryKey
+ )
+ expect(key([BOARD])).not.toBe(key())
+ expect(key(undefined, 10)).not.toBe(key())
+ expect(key([])).toBe(key())
+ })
+})
diff --git a/apps/web/src/lib/shared/words-editor/__tests__/extensions.test.ts b/apps/web/src/lib/shared/words-editor/__tests__/extensions.test.ts
new file mode 100644
index 0000000000..6a03f23633
--- /dev/null
+++ b/apps/web/src/lib/shared/words-editor/__tests__/extensions.test.ts
@@ -0,0 +1,377 @@
+// @vitest-environment happy-dom
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { Editor } from '@tiptap/core'
+import type { WordsKind } from '@/lib/shared/design/rich-words'
+import { fitDoc, headlineDoc, headlineFromDoc, type WordsDoc } from '@/lib/shared/design/rich-words'
+import { generateContentHTML } from '@/lib/shared/content-html'
+import {
+ cutToLimit,
+ hrefOf,
+ withinLimit,
+ runWordsCommand,
+ wordsExtensions,
+ wordsState,
+ type WordsEditorOptions,
+} from '../extensions'
+
+const editors: Editor[] = []
+afterEach(() => {
+ for (const editor of editors.splice(0)) editor.destroy()
+})
+
+function make(
+ kind: WordsKind,
+ content: object | string,
+ options: Partial = {}
+) {
+ const element = document.createElement('div')
+ document.body.append(element)
+ const editor = new Editor({
+ element,
+ content,
+ extensions: wordsExtensions({ kind, ...options }),
+ })
+ editors.push(editor)
+ return editor
+}
+
+const json = (editor: Editor) => editor.getJSON() as WordsDoc
+
+/** Selects the first place a phrase is in the editor's words. */
+function select(editor: Editor, phrase: string) {
+ let at = -1
+ editor.state.doc.descendants((node, pos) => {
+ if (at < 0 && node.isText && node.text!.includes(phrase)) at = pos + node.text!.indexOf(phrase)
+ })
+ editor.commands.setTextSelection({ from: at, to: at + phrase.length })
+}
+
+const press = (editor: Editor, key: string, init: KeyboardEventInit = {}) =>
+ editor.view.someProp('handleKeyDown', (handle) =>
+ handle(editor.view, new KeyboardEvent('keydown', { key, ...init }))
+ )
+
+describe('each kind’s schema', () => {
+ it('holds only the marks and blocks its words allow', () => {
+ const marks = (kind: WordsKind) => Object.keys(make(kind, '').schema.marks).sort()
+ expect(marks('headline')).toEqual(['accent'])
+ expect(marks('plain')).toEqual([])
+ expect(marks('rich')).toEqual(['bold', 'colour', 'italic', 'link'])
+ const nodes = Object.keys(make('rich', '').schema.nodes)
+ expect(nodes).toEqual(
+ expect.arrayContaining([
+ 'paragraph',
+ 'heading',
+ 'bulletList',
+ 'orderedList',
+ 'listItem',
+ 'hardBreak',
+ ])
+ )
+ for (const node of ['image', 'codeBlock', 'blockquote', 'table'])
+ expect(nodes).not.toContain(node)
+ expect(Object.keys(make('headline', '').schema.nodes).sort()).toEqual([
+ 'doc',
+ 'paragraph',
+ 'text',
+ ])
+ })
+})
+
+describe('the accent mark', () => {
+ it('reads the portal’s accent span and keeps one phrase per headline', () => {
+ const editor = make(
+ 'headline',
+ headlineDoc({ title: 'Help shape Acme', accent: { text: 'Acme', style: 'highlight' } })
+ )
+ expect(headlineFromDoc(json(editor)).accent).toEqual({ text: 'Acme', style: 'highlight' })
+ select(editor, 'Help')
+ runWordsCommand(editor, { type: 'accent', style: 'colour' })
+ expect(headlineFromDoc(json(editor))).toEqual({
+ title: 'Help shape Acme',
+ accent: { text: 'Help', style: 'colour' },
+ })
+ expect(editor.getHTML()).toContain('data-qb-accent="colour"')
+ })
+
+ it('takes the style off when it is applied again, and says what is on', () => {
+ const editor = make('headline', 'Build together')
+ select(editor, 'together')
+ runWordsCommand(editor, { type: 'accent', style: 'muted' })
+ expect(wordsState(editor, 'headline', 280).accent).toBe('muted')
+ runWordsCommand(editor, { type: 'accent', style: 'muted' })
+ expect(headlineFromDoc(json(editor)).accent).toBeNull()
+ })
+
+ it('refuses a phrase over 60 characters', () => {
+ const editor = make('headline', 'a'.repeat(80))
+ editor.commands.setTextSelection({ from: 1, to: 70 })
+ expect(runWordsCommand(editor, { type: 'accent', style: 'colour' })).toBe(false)
+ })
+})
+
+describe('keys', () => {
+ it('keeps a line on Enter and cancels on Escape', () => {
+ const onCommit = vi.fn()
+ const onCancel = vi.fn()
+ const editor = make('plain', 'Hello', { onCommit, onCancel })
+ press(editor, 'Enter')
+ expect(onCommit).toHaveBeenCalledTimes(1)
+ expect(editor.getText()).toBe('Hello')
+ press(editor, 'Escape')
+ expect(onCancel).toHaveBeenCalledTimes(1)
+ })
+
+ it('gives rich words new paragraphs on Enter, keeps them on Mod-Enter, and asks for a link on Mod-K', () => {
+ const onCommit = vi.fn()
+ const onLinkRequest = vi.fn()
+ const editor = make('rich', '