diff --git a/apps/web/src/components/admin/settings/appearance/__tests__/ask-ai-panel.test.tsx b/apps/web/src/components/admin/settings/appearance/__tests__/ask-ai-panel.test.tsx index 9b1fadfb79..9eaa046490 100644 --- a/apps/web/src/components/admin/settings/appearance/__tests__/ask-ai-panel.test.tsx +++ b/apps/web/src/components/admin/settings/appearance/__tests__/ask-ai-panel.test.tsx @@ -279,6 +279,67 @@ describe('AskAiPanel', () => { expect(document.querySelector('ol')?.hasAttribute('aria-live')).toBe(false) }) + it('says one row per operation, in words, with no swatch for a relative value', () => { + draft.layout.band = 'tint' + draft.style = { button: { hover: { background: 'oklch(from var(--primary) l c h)' } } } + mount({ + turns: [ + { + role: 'assistant', + text: 'Calmer.', + changes: [ + // One look, two fields, each carrying the look's name, as the critique saw it. + { path: 'layout.band', before: 'none', after: 'tint', label: 'Hero look: Tint' }, + { path: 'layout.bandArt', before: 'dots', after: null, label: 'Hero look: Tint' }, + { + path: 'style', + before: null, + after: { button: { hover: { background: 'oklch(from var(--primary) l c h)' } } }, + label: 'Main buttons on hover: background darker', + }, + ], + }, + ], + }) + fireEvent.click(screen.getByRole('button', { name: 'Show changes' })) + const rows = screen.getAllByRole('listitem').filter((row) => row.closest('[data-changes]')) + expect(rows.map((row) => row.textContent)).toEqual([ + 'Hero look: Tint', + 'Main buttons on hover: background darker', + ]) + expect(document.querySelectorAll('[data-changes] [data-swatch]')).toHaveLength(0) + }) + + it('offers Show on each row the canvas can point at, which goes to the change', () => { + draft.layout.band = 'tint' + draft.shape.corners = 'pill' + const go = vi.fn() + mount({ + show: { can: (at) => at.path.startsWith('layout.'), go }, + turns: [ + { + role: 'assistant', + text: 'Calmer.', + changes: [ + { path: 'layout.band', before: 'none', after: 'tint', label: 'Hero look: Tint' }, + { path: 'shape.corners', before: 'rounded', after: 'pill', label: 'Corners to pill' }, + ], + }, + ], + }) + fireEvent.click(screen.getByRole('button', { name: 'Show changes' })) + const shows = screen.getAllByRole('button', { name: /^Show / }) + expect(shows.map((button) => button.getAttribute('aria-label'))).toEqual([ + 'Show Hero look: Tint on the page', + ]) + expect(shows[0]!.textContent).toBe('Show') + fireEvent.click(shows[0]!) + expect(go).toHaveBeenCalledWith( + { path: 'layout.band', before: 'none', after: 'tint' }, + 'Hero look: Tint' + ) + }) + it('reads a switch as On and Off', () => { mount({ turns: [ diff --git a/apps/web/src/components/admin/settings/appearance/__tests__/change-rows.test.ts b/apps/web/src/components/admin/settings/appearance/__tests__/change-rows.test.ts new file mode 100644 index 0000000000..8fe8fdde61 --- /dev/null +++ b/apps/web/src/components/admin/settings/appearance/__tests__/change-rows.test.ts @@ -0,0 +1,119 @@ +/** + * Show changes says one row per operation, in words. Built from the real + * operations applied to a design (no model): a hero look that sets its fill + * and its art is one row, never a field per row named after the look, and a + * swatch shows only beside an actual colour. + */ +import { describe, expect, it } from 'vitest' +import { applyOps, type OpWorld } from '@/lib/shared/design/design-ops' +import { applyStep } from '@/lib/shared/design/agent/style-ops' +import { STYLE_REGISTRY } from '@/lib/shared/design/agent/style-registry' +import type { AppliedDesignChange } from '@/lib/shared/design/apply-changes' +import { defaultDesign } from '@/lib/shared/design/schema' +import { changeRows, type ChangeRow } from '../change-rows' + +const world: OpWorld = { + gates: { feedback: true, roadmap: true, changelog: true, help: true, support: true, status: true }, + places: { boards: [], roadmaps: [], categories: [] }, +} + +function green() { + const design = defaultDesign() + design.brand.light.accent = '#16a34a' + return design +} + +/** A row as it reads, with [swatch] where one shows. */ +const read = (row: ChangeRow) => + 'text' in row + ? `${row.text}${row.swatch ? ' [swatch]' : ''}` + : `${row.label}: ${row.before.words}${row.before.swatch ? ' [swatch]' : ''} → ${row.after.words}${row.after.swatch ? ' [swatch]' : ''}` + +describe('changeRows', () => { + it('says a hero look that sets several fields as one row', () => { + const result = applyOps(green(), world, [ + { op: 'set', target: 'hero', key: 'look', value: 'brand-waves' }, + ]) + expect(result.applied.map((change) => change.path)).toEqual(['layout.band', 'layout.bandArt']) + expect(changeRows(result.applied).map(read)).toEqual(['Hero look: Brand colour with waves']) + }) + + it('never repeats an operation’s name over each field it touched (the critique’s “Tint” rows)', () => { + // As the critique saw it: one look, two fields, both carrying the look's label. + const changes: AppliedDesignChange[] = [ + { path: 'layout.band', before: 'none', after: 'tint', label: 'Hero look: Tint' }, + { path: 'layout.bandColour', before: null, after: 'auto', label: 'Hero look: Tint' }, + ] + const rows = changeRows(changes).map(read) + expect(rows).toEqual(['Hero look: Tint']) + expect(rows.join(' ')).not.toMatch(/Automatic|None/) + }) + + it('says each of several operations once, in the order they were made', () => { + const result = applyStep( + green(), + world, + STYLE_REGISTRY, + [ + { op: 'theme', key: 'corners', value: 'pill' }, + { op: 'set', target: 'hero', key: 'art', value: 'dots' }, + ], + [ + { element: 'button', state: 'hover', property: 'background', value: 'darker', block: '' }, + { element: 'button', state: 'hover', property: 'color', value: 'page', block: '' }, + ] + ) + expect(changeRows(result.applied).map(read)).toEqual([ + 'Corners to pill', + 'Hero art: Dots', + // The darker shade is an actual colour: a swatch, and no codes in the words. + 'Main buttons on hover: background darker [swatch]', + // The page colour is a role, worked out from the theme: no swatch. + 'Main buttons on hover: text the page colour', + ]) + }) + + it('shows no swatch for a colour the design works out from another', () => { + const changes: AppliedDesignChange[] = [ + { + path: 'style', + before: null, + after: { button: { hover: { background: 'oklch(from var(--primary) calc(l - 0.1) c h)' } } }, + }, + { path: 'style.link.hover.color', before: null, after: 'accent' }, + ] + expect(changeRows(changes).map(read)).toEqual([ + 'Main buttons, hover background: Automatic → Worked out from the brand', + 'Links, hover text: Automatic → Brand', + ]) + }) + + it('reads changes no operation names field by field, a colour by its name with its swatch', () => { + const changes: AppliedDesignChange[] = [ + { path: 'brand.light.accent', before: '#ffffff', after: '#0f766e' }, + { path: 'layout.density', before: 'comfortable', after: 'compact' }, + { path: 'brand.fonts.heading', before: { kind: 'bundled', id: 'inter' }, after: null }, + ] + const rows = changeRows(changes).map(read) + expect(rows.slice(0, 2)).toEqual([ + 'Brand colour: White [swatch] → Teal [swatch]', + 'Density: Comfortable → Compact', + ]) + }) + + it('says an operation on Home’s blocks by its own words, as its list reads as no change', () => { + const design = green() + design.nav.home.on = true + design.pages.home.blocks = [ + { type: 'updates', id: 'updates1', count: 3, grid: { x: 0, y: 0, w: 6, h: 2 } }, + ] as typeof design.pages.home.blocks + const result = applyOps(design, world, [ + { op: 'set', target: 'updates1', key: 'count', value: '2' }, + ]) + expect(result.applied).toHaveLength(1) + const rows = changeRows(result.applied) + expect(rows).toHaveLength(1) + expect(read(rows[0]!)).toBe(result.applied[0]!.label) + expect(rows[0]!.path).toBe('pages.home.blocks') + }) +}) diff --git a/apps/web/src/components/admin/settings/appearance/__tests__/design-composer-stop.test.tsx b/apps/web/src/components/admin/settings/appearance/__tests__/design-composer-stop.test.tsx new file mode 100644 index 0000000000..596a458bf9 --- /dev/null +++ b/apps/web/src/components/admin/settings/appearance/__tests__/design-composer-stop.test.tsx @@ -0,0 +1,72 @@ +// @vitest-environment happy-dom +/** + * While a request runs, Stop takes Send's place: the same button, reached + * the same way, and Escape anywhere in the composer stops too. + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' + +vi.mock('@/lib/client/image-palette', () => ({ imagePalette: async () => [] })) +vi.mock('@/lib/client/brand-shots', () => ({ readBrandShot: async () => null })) +vi.mock('@/lib/server/functions/portal-design-ai', () => ({ readBrandShotsFn: vi.fn() })) + +const { DesignComposer } = await import('../design-composer') + +afterEach(cleanup) + +const composer = (busy: boolean, onStop = vi.fn(), onAsk = vi.fn()) => { + const view = render( + {}} + onAsk={onAsk} + onStop={onStop} + /> + ) + return { onStop, onAsk, view } +} + +describe('Stop in the composer', () => { + it('takes Send’s place while a request runs, and stops it', () => { + const { onStop } = composer(true) + expect(screen.queryByRole('button', { name: 'Send' })).toBeNull() + const stop = screen.getByRole('button', { name: 'Stop' }) as HTMLButtonElement + expect(stop.disabled).toBe(false) + fireEvent.click(stop) + expect(onStop).toHaveBeenCalledOnce() + }) + + it('is the same button as Send, so focus stays on it', () => { + const { view, onStop } = composer(false) + const send = screen.getByRole('button', { name: 'Send' }) + view.rerender( + {}} onAsk={vi.fn()} onStop={onStop} /> + ) + expect(screen.getByRole('button', { name: 'Stop' })).toBe(send) + }) + + it('stops on Escape from the box, and the Escape goes no further', () => { + const { onStop } = composer(true) + const event = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }) + screen.getByLabelText('Ask Quackback AI').dispatchEvent(event) + expect(onStop).toHaveBeenCalledOnce() + expect(event.defaultPrevented).toBe(true) + }) + + it('leaves Escape alone when nothing runs', () => { + const { onStop } = composer(false) + const event = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }) + screen.getByLabelText('Ask Quackback AI').dispatchEvent(event) + expect(onStop).not.toHaveBeenCalled() + expect(event.defaultPrevented).toBe(false) + }) + + it('shows Send while nothing runs, and never sends while a request runs', () => { + const { onAsk } = composer(true) + const box = screen.getByLabelText('Ask Quackback AI') + fireEvent.change(box, { target: { value: 'Warmer' } }) + fireEvent.keyDown(box, { key: 'Enter' }) + expect(onAsk).not.toHaveBeenCalled() + }) +}) diff --git a/apps/web/src/components/admin/settings/appearance/__tests__/use-design-ai.test.tsx b/apps/web/src/components/admin/settings/appearance/__tests__/use-design-ai.test.tsx index c1ef5ec450..fea7097b42 100644 --- a/apps/web/src/components/admin/settings/appearance/__tests__/use-design-ai.test.tsx +++ b/apps/web/src/components/admin/settings/appearance/__tests__/use-design-ai.test.tsx @@ -9,8 +9,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { act, renderHook } from '@testing-library/react' import { defaultDesign } from '@/lib/shared/design/schema' -const server = vi.hoisted(() => ({ ask: vi.fn() })) -vi.mock('@/lib/server/functions/portal-design-ai', () => ({ askPortalDesignAiFn: server.ask })) +const server = vi.hoisted(() => ({ ask: vi.fn(), step: vi.fn() })) +vi.mock('@/lib/server/functions/portal-design-ai', () => ({ + askPortalDesignAiFn: server.ask, + designAgentStepFn: server.step, +})) +const loop = vi.hoisted(() => ({ run: vi.fn() })) +vi.mock('@/lib/client/edit-loop', () => ({ runBuilderEditLoop: loop.run })) const logo = vi.hoisted(() => ({ change: vi.fn() })) vi.mock('@/lib/client/brand-logo', () => ({ brandLogoChange: logo.change })) const stream = vi.hoisted(() => ({ match: vi.fn() })) @@ -269,6 +274,118 @@ describe('useDesignAi', () => { }) }) +describe('how far a request reaches, and what its reply leaves to Show changes', () => { + const checkHooks = () => ({ + begin: vi.fn(), + end: vi.fn(), + replace: vi.fn(), + frame: () => null, + current: () => defaultDesign(), + }) + const loopHook = () => + renderHook(() => + useDesignAi({ + flush: async () => {}, + adopt: vi.fn(), + update, + workspaceName: 'Acme', + check: checkHooks(), + }) + ) + const looped = (over: object = {}) => ({ + ok: true, + reply: 'The hero’s button is darker on hover.', + design: defaultDesign(), + applied: [{ path: 'overrides', before: null, after: [] }], + steps: [], + unmet: [], + refused: [], + notes: ['Left alone, as it is outside what was picked: cards.'], + stopped: 'done', + ms: 10, + ...over, + }) + + beforeEach(() => loop.run.mockReset()) + + it('sends the chip’s reach with a request about one part, and never for the whole site', async () => { + server.ask.mockResolvedValue({ ok: false, code: 'AI_FAILED', message: 'No.' }) + const { result } = hook({ page: 'home', locale: 'en' }) + await act(() => + result.current.ask('Calmer', 'band', undefined, undefined, undefined, undefined, 'page') + ) + expect(server.ask.mock.calls[0]![0].data).toMatchObject({ scope: 'band', within: 'page' }) + await act(() => + result.current.ask('Calmer', 'site', undefined, undefined, undefined, undefined, 'page') + ) + expect(server.ask.mock.calls[1]![0].data).not.toHaveProperty('within') + }) + + it('keeps a one-call reply’s notes for Show changes', async () => { + server.ask.mockResolvedValue({ + ok: true, + reply: 'I changed the contact heading.', + applied: [], + readability: [], + notes: ['Part of that is outside the Contact block on Home and was left alone.'], + outOfScope: false, + design: defaultDesign(), + }) + const { result } = hook() + await act(() => result.current.ask('Friendlier', 'home', undefined, 'contactbbb')) + const reply = result.current.turns.at(-1) + expect(reply).toMatchObject({ + text: 'I changed the contact heading.', + notes: ['Part of that is outside the Contact block on Home and was left alone.'], + }) + expect(reply).not.toHaveProperty('widen') + }) + + it('offers match_website to the loop only for the whole site', async () => { + loop.run.mockResolvedValue(looped()) + const { result } = loopHook() + await act(() => + result.current.ask( + 'Make the main button feel like stripe.com when hovered', + 'band', + undefined, + undefined, + undefined, + undefined, + 'every' + ) + ) + const [request, deps] = loop.run.mock.calls[0]! + expect(request).toMatchObject({ scope: 'band', within: 'every' }) + expect(deps.matchWebsite).toBeUndefined() + expect(deps.readWebsite).toBeTypeOf('function') + // What the loop left alone goes under Show changes, not into the reply. + expect(result.current.turns.at(-1)).toMatchObject({ + text: 'The hero’s button is darker on hover.', + notes: ['Left alone, as it is outside what was picked: cards.'], + }) + await act(() => result.current.ask('Make it feel like stripe.com on hover', 'site')) + expect(loop.run.mock.calls[1]![1].matchWebsite).toBeTypeOf('function') + }) + + it('shows a loop that stopped after a change with its changes and Undo, never as failed', async () => { + loop.run.mockResolvedValue( + looped({ + reply: 'I changed main buttons on hover, but I stopped before checking everything.', + stopped: 'failed', + notes: [], + }) + ) + const { result } = loopHook() + await act(() => result.current.ask('Make the buttons darker on hover', 'site')) + const reply = result.current.turns.at(-1) + expect(reply).toMatchObject({ + text: 'I changed main buttons on hover, but I stopped before checking everything.', + changes: [{ path: 'overrides' }], + }) + }) +}) + describe('the check after a brand match', () => { const shot = { id: 's1', jpeg: 'data:image/jpeg;base64,AAAA', width: 1440, height: 900 } as never const brand = { shots: [shot], name: 'Acme', icon: null } @@ -522,3 +639,62 @@ describe('startBrandMatch', () => { expect(result.current.turns).toEqual([]) }) }) + +describe('Stop', () => { + it('aborts the request it sent and says so, nothing changed', async () => { + let seen: AbortSignal | undefined + server.ask.mockImplementation( + ({ signal }: { signal?: AbortSignal }) => + new Promise((_, reject) => { + seen = signal + signal?.addEventListener('abort', () => + reject(new DOMException('The operation was aborted.', 'AbortError')) + ) + }) + ) + const { result } = hook() + let asked: Promise | undefined + act(() => { + asked = result.current.ask('Warmer', 'site') + }) + await vi.waitFor(() => expect(seen).toBeDefined()) + expect(result.current.busy).toBe(true) + act(() => result.current.stop()) + await act(() => asked!) + expect(seen?.aborted).toBe(true) + expect(result.current.busy).toBe(false) + expect(result.current.turns.at(-1)).toEqual({ + role: 'assistant', + text: 'Stopped. Nothing changed.', + }) + }) + + it('does nothing when nothing is running', () => { + const { result } = hook() + act(() => result.current.stop()) + expect(result.current.turns).toEqual([]) + }) + + it('aborts a brand match’s stream', async () => { + let seen: AbortSignal | undefined + stream.match.mockImplementation( + (_body: unknown, _onStep: unknown, _fetcher: unknown, signal?: AbortSignal) => + new Promise((_, reject) => { + seen = signal + signal?.addEventListener('abort', () => + reject(new DOMException('The operation was aborted.', 'AbortError')) + ) + }) + ) + const { result } = hook() + let asked: Promise | undefined + act(() => { + asked = result.current.ask('Match https://example.com', 'site') + }) + await vi.waitFor(() => expect(seen).toBeDefined()) + act(() => result.current.stop()) + await act(() => asked!) + expect(seen?.aborted).toBe(true) + expect(result.current.turns.at(-1)?.text).toBe('Stopped. Nothing changed.') + }) +}) diff --git a/apps/web/src/components/admin/settings/appearance/__tests__/use-design-draft.test.tsx b/apps/web/src/components/admin/settings/appearance/__tests__/use-design-draft.test.tsx index 862712dd6d..f02498d29f 100644 --- a/apps/web/src/components/admin/settings/appearance/__tests__/use-design-draft.test.tsx +++ b/apps/web/src/components/admin/settings/appearance/__tests__/use-design-draft.test.tsx @@ -307,6 +307,24 @@ describe('useDesignDraft', () => { expect(result.current.design).toEqual(original) }) + it('makes a change by hand while the AI works its own undo step', () => { + const { result } = setup() + const original = structuredClone(result.current.design) + act(() => result.current.begin('Make it calmer', 'ai')) + const first = structuredClone(result.current.design) + first.layout.width = 'wide' + act(() => result.current.adopt(first, undefined, 'Make it calmer', 'r2')) + act(() => result.current.update((draft) => void (draft.layout.density = 'compact'), 'Density')) + act(() => result.current.end('ai')) + expect(result.current.undoLabel).toBe('Density') + act(() => result.current.undo()) + expect(result.current.design.layout.density).toBe(original.layout.density) + expect(result.current.design.layout.width).toBe('wide') + expect(result.current.undoLabel).toBe('Make it calmer') + act(() => result.current.undo()) + expect(result.current.design).toEqual(original) + }) + it('starts again when another draft is loaded', async () => { const { result } = setup() act(() => result.current.update((draft) => void (draft.layout.width = 'wide'), 'Width')) diff --git a/apps/web/src/components/admin/settings/appearance/ask-ai-panel.tsx b/apps/web/src/components/admin/settings/appearance/ask-ai-panel.tsx index 764add6af3..7a456f4001 100644 --- a/apps/web/src/components/admin/settings/appearance/ask-ai-panel.tsx +++ b/apps/web/src/components/admin/settings/appearance/ask-ai-panel.tsx @@ -1,6 +1,6 @@ import { useState, type ReactNode } from 'react' import { ChevronDownIcon } from '@heroicons/react/16/solid' -import { colourName, plainWords } from '@/lib/shared/design/colour-names' +import { plainWords } from '@/lib/shared/design/colour-names' import { matchSummary } from '@/lib/shared/design/step-summary' import { overrideChangeLines } from '@/lib/shared/design/override-cascade' import type { StyleOverride } from '@/lib/shared/design/style-overrides' @@ -14,69 +14,59 @@ import { } from '@/lib/shared/design/apply-changes' import type { PortalDesign } from '@/lib/shared/design/schema' import { cn } from '@/lib/shared/utils' -import { ChangeValue, changeLabel, shownValue } from './change-list' +import { changeRows, type ChangeRow, type RowChange, type RowSide } from './change-rows' import { HeroPhotoChip } from './hero-photo-chip' import type { DesignAiTurn } from './use-design-ai' /** Changes the draft, naming the change for undo when a name is given. */ type Update = (change: (draft: PortalDesign) => void, label?: string) => void -/** A value as a row says it: a colour by its name beside a swatch, never its code. */ -function RowValue({ value }: { value: unknown }) { - if (typeof value === 'string' && isColour(value)) - return ( - - - ) - return +/** Where a row's change shows on the canvas: whether it can be pointed at, and going there. */ +export interface ShowChange { + can: (at: RowChange) => boolean + /** Shows the change on the canvas; `words` is the row as it reads. */ + go: (at: RowChange, words: string) => void } -const isColour = (value: string) => /^(#[0-9a-f]{3}|#[0-9a-f]{6}|oklch\()/i.test(value) -const capital = (text: string) => text.charAt(0).toUpperCase() + text.slice(1) - -/** What a value reads as, for leaving out a row whose two sides read the same. */ -const readAs = (value: unknown) => - typeof value === 'string' && isColour(value) ? colourName(value) : JSON.stringify(value ?? null) +/** A row as it reads, for its Show's name and what is said once it is shown. */ +const rowWords = (row: ChangeRow) => + 'text' in row ? row.text : `${row.label}: ${row.before.words} → ${row.after.words}` -type Row = { key: string; label: string; before: unknown; after: unknown } +/** A row's Show: to the page the change is on, its part outlined. */ +function ShowButton({ row, onShow }: { row: ChangeRow; onShow: ShowChange['go'] }) { + const words = rowWords(row) + return ( + + ) +} -/** - * A value a person cannot read as a change: a list or object that would only - * say "Changed", or an exact size the builder set (sizes show on the page). - */ -const technical = (value: unknown) => - (!!value && typeof value === 'object') || - (typeof value === 'string' && /^-?[\d.]+ (px|em)$/.test(value)) +/** One side of a row: its words, beside a swatch only when it is an actual colour. */ +function RowValue({ side }: { side: RowSide }) { + if (!side.swatch) return {side.words} + return ( + + + {side.words} + + ) +} -/** - * The rows a change makes: one for a plain value, one for each value inside - * an object that reads only as "Changed", and none where both sides read the same. - */ -function rowsOf(path: string, before: unknown, after: unknown, label?: string, depth = 0): Row[] { - const was = shownValue(path, before) - const now = shownValue(path, after) - const nested = (v: unknown) => !!v && typeof v === 'object' && !Array.isArray(v) - if (depth < 4 && (nested(was) || nested(now))) { - const keys = new Set([ - ...Object.keys(nested(before) ? (before as object) : {}), - ...Object.keys(nested(after) ? (after as object) : {}), - ]) - const at = (v: unknown, key: string) => - nested(v) ? (v as Record)[key] : undefined - return [...keys].flatMap((key) => - rowsOf(`${path}.${key}`, at(before, key), at(after, key), undefined, depth + 1) - ) - } - if (readAs(was) === readAs(now) || [was, now].some(technical)) return [] - return [ - { key: path, label: (label ?? changeLabel(path)).replace(': ', ' '), before: was, after: now }, - ] +function Swatch({ colour }: { colour: string }) { + return ( +