From 2a0a9cbb37a1b20da0881400954afc674458e808 Mon Sep 17 00:00:00 2001 From: James Morton Date: Sat, 7 Nov 2026 23:35:35 +0000 Subject: [PATCH 01/10] feat(designer): fit Ask AI's starters to the picked part and the page The starters ignored the selection and the page, and the block registry's example requests went unused. A picked hero, Home block or page section now offers up to three of its own registry examples; with nothing picked the starters keep to the page open (its sections' examples first, and Home-only suggestions only on Home). Match my brand still leads until a match has run. --- .../__tests__/designer-ai-starters.test.tsx | 68 +++++++++++++++++++ .../src/components/designer/designer-ai.tsx | 21 ++++-- .../components/designer/designer-shell.tsx | 1 + .../design/__tests__/ai-starters.test.ts | 63 +++++++++++++++++ apps/web/src/lib/shared/design/ai-starters.ts | 58 ++++++++++++++-- 5 files changed, 201 insertions(+), 10 deletions(-) create mode 100644 apps/web/src/components/designer/__tests__/designer-ai-starters.test.tsx diff --git a/apps/web/src/components/designer/__tests__/designer-ai-starters.test.tsx b/apps/web/src/components/designer/__tests__/designer-ai-starters.test.tsx new file mode 100644 index 0000000000..fca1c7fc80 --- /dev/null +++ b/apps/web/src/components/designer/__tests__/designer-ai-starters.test.tsx @@ -0,0 +1,68 @@ +// @vitest-environment happy-dom +/** + * Ask AI's first suggestions follow where the person is: the picked part's + * own examples, else the page open. + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, render, screen, within } from '@testing-library/react' + +vi.mock('@tanstack/react-query', () => ({ + useSuspenseQuery: () => ({ data: { available: true } }), + useQuery: () => ({ data: undefined }), +})) +vi.mock('@/lib/server/functions/site-capture', () => ({ siteCaptureUsageFn: vi.fn() })) +vi.mock('@/lib/client/queries/portal-design', () => ({ + portalDesignQueries: { aiStatus: () => ({}) }, +})) +vi.mock('@/lib/client/image-palette', () => ({})) +vi.mock('@/components/admin/settings/appearance/ask-ai-panel', () => ({ + AskAiPanel: () => null, + PaletteSwatches: () => null, +})) + +const { DesignerAi } = await import('../designer-ai') +const { defaultDesign } = await import('@/lib/shared/design/schema') + +afterEach(cleanup) + +function matched() { + const design = defaultDesign() + design.brand.matchedAt = '2026-11-07T12:00:00.000Z' + design.nav.home.on = true + return design +} + +function starters(page: 'home' | 'roadmap', selection: string | null, name: string | null) { + render( + + ) + return within(screen.getByRole('list', { name: 'Suggestions' })) + .getAllByRole('button') + .map((button) => button.textContent) +} + +describe('Ask AI’s starters', () => { + it('offers the picked part’s examples', () => { + expect(starters('home', 'hero', 'Hero')).toEqual([ + 'Make the hero louder', + 'Give the hero more room and a dotted pattern', + 'Just the headline up top, no post box there', + ]) + }) + + it('keeps to the page open when nothing is picked', () => { + const shown = starters('roadmap', null, null) + expect(shown[0]).toBe('Lay the roadmap out in columns, with votes on its cards') + expect(shown).not.toContain('Show the roadmap on Home') + }) +}) diff --git a/apps/web/src/components/designer/designer-ai.tsx b/apps/web/src/components/designer/designer-ai.tsx index 7b883ca811..9e9ae90105 100644 --- a/apps/web/src/components/designer/designer-ai.tsx +++ b/apps/web/src/components/designer/designer-ai.tsx @@ -13,7 +13,7 @@ import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { portalDesignQueries } from '@/lib/client/queries/portal-design' import type { AiElementPick, DesignAiScope } from '@/lib/shared/design/apply-changes' -import type { Selection } from '@/lib/shared/design/outline' +import type { DesignerPage, Selection } from '@/lib/shared/design/outline' import { heroOf, writeHero } from '@/lib/shared/design/hero' import { isMenuSelection } from '@/lib/shared/design/menu' import { siteCaptureUsageFn } from '@/lib/server/functions/site-capture' @@ -54,17 +54,21 @@ export function aiScopeOf(selection: Selection | null): { /** The id of Ask AI's request box, which "/" focuses. */ export const AI_BOX_ID = 'designer-ai-box' -/** Before the first request: one line, and what to start from for this portal. */ +/** Before the first request: one line, and what to start from for this portal, page and pick. */ function AiEmpty({ design, gates, + page, + selection, onStarter, }: { design: PortalDesign gates?: Partial + page?: DesignerPage + selection: Selection | null onStarter: (text: string) => void }) { - const starters = aiStarters(design, gates) + const starters = aiStarters(design, gates, { ...(page ? { page } : {}), selection }) return (
@@ -133,6 +137,7 @@ export function DesignerAi({ selectionName, element = null, elementName = null, + page, pageName, update, onClearSelection, @@ -150,6 +155,8 @@ export function DesignerAi({ element?: AiElementPick | null /** The picked element's own name, "Call to action", which the chip leads with. */ elementName?: string | null + /** The page shown, which the starters keep to with nothing picked. */ + page?: DesignerPage /** The page shown, which the picked part is on. */ pageName: string update: Update @@ -212,7 +219,13 @@ export function DesignerAi({ className="min-h-0 flex-1 overflow-y-auto px-4 py-3" > {available && ai.turns.length === 0 ? ( - + ) : ( setSelection(null)} diff --git a/apps/web/src/lib/shared/design/__tests__/ai-starters.test.ts b/apps/web/src/lib/shared/design/__tests__/ai-starters.test.ts index 319ade748a..5232d6df45 100644 --- a/apps/web/src/lib/shared/design/__tests__/ai-starters.test.ts +++ b/apps/web/src/lib/shared/design/__tests__/ai-starters.test.ts @@ -49,3 +49,66 @@ describe('aiStarters', () => { ]) }) }) + +describe('aiStarters for the page and the pick', () => { + it('offers the picked part’s own examples, up to three', () => { + const starters = aiStarters(branded(), undefined, { page: 'home', selection: 'hero' }) + expect(starters).toEqual([ + 'Make the hero louder', + 'Give the hero more room and a dotted pattern', + 'Just the headline up top, no post box there', + ]) + }) + + it('offers a picked Home block’s examples, by its type', () => { + const design = branded() + design.pages.home.blocks = [ + { type: 'updates', id: 'updates1', count: 3, grid: { x: 0, y: 0, w: 6, h: 2 } }, + ] as PortalDesign['pages']['home']['blocks'] + expect(aiStarters(design, undefined, { page: 'home', selection: 'block-updates1' })).toEqual([ + 'Show just 2 updates', + ]) + // A block id the design does not hold offers the page's starters. + expect(aiStarters(design, undefined, { page: 'home', selection: 'block-gone0000' })).toEqual( + aiStarters(design, undefined, { page: 'home' }) + ) + }) + + it('offers a page section’s examples when it is picked', () => { + expect(aiStarters(branded(), undefined, { page: 'roadmap', selection: 'board' })).toEqual([ + 'Lay the roadmap out in columns, with votes on its cards', + ]) + }) + + it('keeps to the page open when nothing is picked', () => { + const design = branded() + design.nav.home.on = true + const roadmap = aiStarters(design, undefined, { page: 'roadmap', selection: null }) + expect(roadmap[0]).toBe('Lay the roadmap out in columns, with votes on its cards') + expect(roadmap).not.toContain('Show the roadmap on Home') + expect(roadmap.length).toBeLessThanOrEqual(3) + const help = aiStarters(design, undefined, { page: 'help' }) + expect(help).toEqual([ + 'List the help topics instead of cards', + 'Bring back popular articles on the help center', + 'Give the help center’s search box its usual size', + ]) + expect(aiStarters(design, undefined, { page: 'home' })).toEqual(aiStarters(design)) + }) + + it('falls back to the page’s starters for a pick with no examples', () => { + const design = branded() + expect(aiStarters(design, undefined, { page: 'feedback', selection: 'filters' })).toEqual( + aiStarters(design, undefined, { page: 'feedback' }) + ) + expect(aiStarters(design, undefined, { page: 'feedback' })).not.toContain( + 'Show the roadmap on Home' + ) + }) + + it('offers only Match my brand before a match, whatever is picked', () => { + expect(aiStarters(defaultDesign(), undefined, { page: 'roadmap', selection: 'board' })).toEqual( + [MATCH_STARTER] + ) + }) +}) diff --git a/apps/web/src/lib/shared/design/ai-starters.ts b/apps/web/src/lib/shared/design/ai-starters.ts index 5160e50034..fb03748c76 100644 --- a/apps/web/src/lib/shared/design/ai-starters.ts +++ b/apps/web/src/lib/shared/design/ai-starters.ts @@ -1,10 +1,23 @@ /** * What Ask AI offers before the first request, from the design itself: to * match the brand until a match has run, and after that a few next edits - * the design can take, none assuming a brand colour. + * the design can take, none assuming a brand colour. A part picked offers + * its own examples from the block registry; with nothing picked, the + * starters keep to the page open. */ +import { blockOf, SECTION_TYPES } from './blocks' +import { sectionTypeOf } from './builder-chrome' import type { PortalDesign } from './schema' -import type { PortalPageGates } from './paths' +import type { DesignerPage, PortalPageGates } from './paths' + +/** Where the person is: the page open and the part picked on it, if any. */ +export interface StarterPlace { + page?: DesignerPage + selection?: string | null +} + +/** Most starters offered at once. */ +const MOST = 3 /** The chip that starts a brand match: it points at the box, it sends nothing. */ export const MATCH_STARTER = 'Match my brand' @@ -20,14 +33,45 @@ export function hasMatched(design: PortalDesign): boolean { return !!design.brand.matchedAt } +/** The registry's type for a pick: the hero, a Home block by its type, or a page's section. */ +function typeOfPick(design: PortalDesign, selection: string): string | null { + if (selection === 'hero') return 'hero' + if (selection.startsWith('block-')) { + const id = selection.slice('block-'.length) + return design.pages.home.blocks.find((block) => block.id === id)?.type ?? null + } + return sectionTypeOf(selection) +} + +/** The example requests the registry gives for the part picked; none for a part it has none for. */ +function pickedExamples(design: PortalDesign, selection: string | null | undefined): string[] { + const type = selection ? typeOfPick(design, selection) : null + return (type ? (blockOf(type)?.examples ?? []) : []).map((example) => example.request) +} + /** Up to three requests to start from; none is fine when nothing fits. */ -export function aiStarters(design: PortalDesign, gates?: Partial): string[] { +export function aiStarters( + design: PortalDesign, + gates?: Partial, + at: StarterPlace = {} +): string[] { if (!hasMatched(design)) return [MATCH_STARTER] + const picked = pickedExamples(design, at.selection) + if (picked.length) return picked.slice(0, MOST) + const page = at.page ?? 'home' const roadmapOnHome = design.pages.home.blocks.some((block) => block.type === 'roadmap') const corners = design.shape.corners + // The page's own sections, each by its first example, before what every page can take. + const sections = + page === 'home' + ? [] + : SECTION_TYPES.filter((type) => blockOf(type)?.pages.includes(page)).flatMap( + (type) => blockOf(type)?.examples[0]?.request ?? [] + ) return [ - design.mode !== 'dark' ? 'Try a dark hero' : null, - design.nav.home.on && !roadmapOnHome && gates?.roadmap !== false + ...sections, + design.mode !== 'dark' && page !== 'status' && page !== 'post' ? 'Try a dark hero' : null, + page === 'home' && design.nav.home.on && !roadmapOnHome && gates?.roadmap !== false ? 'Show the roadmap on Home' : null, corners === 'sharp' @@ -35,5 +79,7 @@ export function aiStarters(design: PortalDesign, gates?: Partial !!starter) + ] + .filter((starter): starter is string => !!starter) + .slice(0, MOST) } From 85da62d8f3a9a61f1227a6b99385a65b7059a831 Mon Sep 17 00:00:00 2001 From: James Morton Date: Sat, 7 Nov 2026 23:39:28 +0000 Subject: [PATCH 02/10] fix(designer): say Show changes as one row per operation, in words MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show changes made a row per changed field and named each after the operation that touched it, so one hero look read as "Tint: None → Tint" and "Tint: None → Automatic". A style's { background: "#12893c" } was also read as words by language, giving "Element styles: Automatic → Colour" with an empty swatch. Rows now come one per operation, said as the operation is (codes stripped, a style's property by its panel name), and changes no operation names still read field by field. A swatch shows only beside an actual colour, never beside a role or a colour worked out from another. Words-by-language detection now needs every key to be a language. --- .../__tests__/ask-ai-panel.test.tsx | 31 ++++ .../appearance/__tests__/change-rows.test.ts | 119 ++++++++++++ .../settings/appearance/ask-ai-panel.tsx | 107 ++++------- .../admin/settings/appearance/change-list.tsx | 26 ++- .../admin/settings/appearance/change-rows.ts | 173 ++++++++++++++++++ 5 files changed, 383 insertions(+), 73 deletions(-) create mode 100644 apps/web/src/components/admin/settings/appearance/__tests__/change-rows.test.ts create mode 100644 apps/web/src/components/admin/settings/appearance/change-rows.ts 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..20489b2b58 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,37 @@ 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('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/ask-ai-panel.tsx b/apps/web/src/components/admin/settings/appearance/ask-ai-panel.tsx index 764add6af3..0815900b26 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,33 @@ 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 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 +/** 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} + + ) } -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) - -type Row = { key: string; label: string; before: unknown; after: unknown } - -/** - * 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)) - -/** - * 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 ( +