diff --git a/apps/web/src/components/admin/settings/appearance/__tests__/tune-section.test.tsx b/apps/web/src/components/admin/settings/appearance/__tests__/tune-section.test.tsx new file mode 100644 index 0000000000..d43b8e3904 --- /dev/null +++ b/apps/web/src/components/admin/settings/appearance/__tests__/tune-section.test.tsx @@ -0,0 +1,63 @@ +// @vitest-environment happy-dom +/** + * Appearance's exact values: each one typed sets the design's tune, held + * within its bounds; blank gives it back to the choices; clearing removes all. + */ +import { afterEach, describe, expect, it } from 'vitest' +import { useState } from 'react' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { defaultDesign, type PortalDesign } from '@/lib/shared/design/schema' +import { changeLabel } from '@/lib/shared/design/labels' +import { TuneSection } from '../tune-section' + +let latest: PortalDesign + +function Harness() { + const [design, setDesign] = useState(defaultDesign()) + latest = design + return ( + + setDesign((current) => { + const draft = structuredClone(current) + change(draft) + return draft + }) + } + /> + ) +} + +afterEach(cleanup) + +describe('Appearance’s exact values', () => { + it('sets a value typed, within its bounds, and blank gives it back to the choices', () => { + render() + fireEvent.click(screen.getByRole('button', { name: 'Fine-tune' })) + const size = screen.getByLabelText('Size', { selector: '#tune-headline-size' }) + fireEvent.change(size, { target: { value: '48' } }) + expect(latest.tune).toEqual({ headline: { size: 48 } }) + fireEvent.change(screen.getByLabelText('Height', { selector: '#tune-button-height' }), { + target: { value: '400' }, + }) + expect(latest.tune?.button).toEqual({ height: 72 }) + fireEvent.change(size, { target: { value: '' } }) + expect(latest.tune).toEqual({ button: { height: 72 } }) + }) + + it('clears every exact value at once', () => { + render() + fireEvent.click(screen.getByRole('button', { name: 'Fine-tune' })) + fireEvent.change(screen.getByLabelText('Corners', { selector: '#tune-card-radius' }), { + target: { value: '6' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Clear exact values' })) + expect(latest.tune).toBeUndefined() + }) + + it('names its changes for the review list', () => { + expect(changeLabel('tune.headline.size')).toBe('Headline exact values') + expect(changeLabel('tune')).toBe('Exact values') + }) +}) diff --git a/apps/web/src/components/admin/settings/appearance/appearance-view.tsx b/apps/web/src/components/admin/settings/appearance/appearance-view.tsx index d55d886d28..481e9987e6 100644 --- a/apps/web/src/components/admin/settings/appearance/appearance-view.tsx +++ b/apps/web/src/components/admin/settings/appearance/appearance-view.tsx @@ -163,9 +163,9 @@ export function AppearanceView({ scope={scope} busy={ai.busy} onClearScope={() => setScope('site')} - onAsk={(text, palette) => { + onAsk={(text, attachment) => { setTab('ask') - void ai.ask(text, scope, palette) + void ai.ask(text, scope, attachment) }} /> )} diff --git a/apps/web/src/components/admin/settings/appearance/brand-panel.tsx b/apps/web/src/components/admin/settings/appearance/brand-panel.tsx index b8d4db1804..8479caad59 100644 --- a/apps/web/src/components/admin/settings/appearance/brand-panel.tsx +++ b/apps/web/src/components/admin/settings/appearance/brand-panel.tsx @@ -6,12 +6,12 @@ import { getWordmarkUploadUrlFn } from '@/lib/server/functions/uploads' import { designAssetUrl } from '@/lib/shared/design/design-view' import { Link } from '@tanstack/react-router' import { Button } from '@/components/ui/button' -import { normaliseColor } from '@/lib/shared/design/color' import { DESIGN_OPTIONS } from '@/lib/shared/design/options' import type { PortalDesign } from '@/lib/shared/design/schema' -import { oklchToHex } from '@/lib/shared/theme/colors' import { applyPreset } from '@/lib/shared/design/presets' import { DesignTiles } from './design-tiles' +import { ColourField } from './colour-field' +import { TuneSection } from './tune-section' import { WordsSection } from './words-section' import { FontsSection } from './fonts-section' import { PresetGallery } from './preset-gallery' @@ -77,58 +77,6 @@ const presignWordmark = async (file: File) => data: { filename: file.name, contentType: file.type, fileSize: file.size }, }) -const toHex = (value: string | undefined) => - value ? (value.startsWith('#') ? value : oklchToHex(value)) : '#ffffff' - -/** A colour as a picker and its value; a typed value applies once it parses. */ -function ColourField({ - id, - label, - value, - placeholder, - onChange, -}: { - id: string - label: string - value: string | undefined - placeholder?: string - onChange: (value: string | undefined) => void -}) { - const [text, setText] = useState(value ?? '') - const [shown, setShown] = useState(value) - if (value !== shown) { - setShown(value) - setText(value ?? '') - } - return ( -
- - onChange(event.target.value)} - className="h-8 w-10 shrink-0 cursor-pointer rounded-field border border-border bg-transparent" - /> - { - const next = event.target.value - setText(next) - if (next.trim() === '' && placeholder !== undefined) onChange(undefined) - const colour = normaliseColor(next.trim()) - if (colour) onChange(colour) - }} - className="h-8 font-mono text-xs" - /> -
- ) -} - /** The Brand tab: the logo, mode, colours, fonts and corners. */ export function BrandPanel({ design, @@ -408,6 +356,8 @@ export function BrandPanel({ } /> + + ) diff --git a/apps/web/src/components/admin/settings/appearance/colour-field.tsx b/apps/web/src/components/admin/settings/appearance/colour-field.tsx new file mode 100644 index 0000000000..dae1c952eb --- /dev/null +++ b/apps/web/src/components/admin/settings/appearance/colour-field.tsx @@ -0,0 +1,56 @@ +import { useState } from 'react' +import { Input } from '@/components/ui/input' +import { normaliseColor } from '@/lib/shared/design/color' +import { oklchToHex } from '@/lib/shared/theme/colors' + +export const toHex = (value: string | undefined) => + value ? (value.startsWith('#') ? value : oklchToHex(value)) : '#ffffff' + +/** A colour as a picker and its value; a typed value applies once it parses. */ +export function ColourField({ + id, + label, + value, + placeholder, + onChange, +}: { + id: string + label: string + value: string | undefined + placeholder?: string + onChange: (value: string | undefined) => void +}) { + const [text, setText] = useState(value ?? '') + const [shown, setShown] = useState(value) + if (value !== shown) { + setShown(value) + setText(value ?? '') + } + return ( +
+ + onChange(event.target.value)} + className="h-8 w-10 shrink-0 cursor-pointer rounded-field border border-border bg-transparent" + /> + { + const next = event.target.value + setText(next) + if (next.trim() === '' && placeholder !== undefined) onChange(undefined) + const colour = normaliseColor(next.trim()) + if (colour) onChange(colour) + }} + className="h-8 font-mono text-xs" + /> +
+ ) +} diff --git a/apps/web/src/components/admin/settings/appearance/design-composer.tsx b/apps/web/src/components/admin/settings/appearance/design-composer.tsx index 2458815369..03a0141d0a 100644 --- a/apps/web/src/components/admin/settings/appearance/design-composer.tsx +++ b/apps/web/src/components/admin/settings/appearance/design-composer.tsx @@ -1,7 +1,7 @@ import { useState } from 'react' import { Button } from '@/components/ui/button' import { Textarea } from '@/components/ui/textarea' -import { imagePalette } from '@/lib/client/image-palette' +import { imageForModel, imagePalette } from '@/lib/client/image-palette' import type { DesignAiScope } from '@/lib/shared/design/apply-changes' import { PaletteSwatches } from './ask-ai-panel' @@ -30,16 +30,18 @@ export function DesignComposer({ scope: DesignAiScope busy: boolean onClearScope: () => void - onAsk: (text: string, palette?: string[]) => void + onAsk: (text: string, attachment?: { palette: string[]; image?: string }) => void }) { const [query, setQuery] = useState('') - const [image, setImage] = useState<{ name: string; palette: string[] } | null>(null) + const [image, setImage] = useState<{ name: string; palette: string[]; data?: string } | null>( + null + ) const text = query.trim() const send = () => { if (!text || busy) return setQuery('') setImage(null) - onAsk(text, image?.palette) + onAsk(text, image ? { palette: image.palette, image: image.data } : undefined) } return (
@@ -86,8 +88,9 @@ export function DesignComposer({ const file = event.target.files?.[0] event.target.value = '' if (!file) return - const palette = await imagePalette(file) - if (palette.length) setImage({ name: file.name, palette }) + // Its colours, and the picture itself for a model that reads images. + const [palette, data] = await Promise.all([imagePalette(file), imageForModel(file)]) + if (palette.length) setImage({ name: file.name, palette, ...(data && { data }) }) }} />
+ + { + const text = event.target.value + if (text === '') return onChange(undefined) + const number = Number(text) + if (Number.isFinite(number)) onChange(Math.min(max, Math.max(min, number))) + }} + className="h-8 flex-1" + /> + {unit && {unit}} +
+ ) +} + +/** + * Exact values under the choices above: a brand's headline size and weight, + * its buttons' height and corners, and so on. Each field left blank keeps + * what the choices give. Quackback AI fills them from a brand's website. + */ +export function TuneSection({ design, update }: { design: PortalDesign; update: Update }) { + const tune = design.tune ?? {} + const [open, setOpen] = useState(Object.keys(tune).length > 0) + const set = + (group: G, key: string) => + (value: unknown) => + update((draft) => { + const next = { ...(draft.tune ?? {}) } as Record< + string, + Record | undefined + > + const fields = { ...(next[group] ?? {}) } + if (value === undefined) delete fields[key] + else fields[key] = value + if (Object.keys(fields).length) next[group] = fields + else delete next[group] + if (Object.keys(next).length) draft.tune = next as Tune + else delete draft.tune + }) + const hex = (value: string | undefined) => (value ? toHex(value) : undefined) + + if (!open) + return ( +
+

Exact values

+ +
+ ) + return ( +
+

Exact values

+
+

Headline

+ + + + + set('headline', 'colour')(hex(value))} + /> +
+
+

Section headings

+ + + set('heading', 'colour')(hex(value))} + /> +
+
+

Buttons

+ + + set('button', 'fill')(hex(value))} + /> + set('button', 'ink')(hex(value))} + /> +
+
+

Cards

+ + +
+ + +
+
+ +
+ ) +} diff --git a/apps/web/src/components/admin/settings/appearance/use-design-ai.ts b/apps/web/src/components/admin/settings/appearance/use-design-ai.ts index 11ccfc63ee..540a58cff2 100644 --- a/apps/web/src/components/admin/settings/appearance/use-design-ai.ts +++ b/apps/web/src/components/admin/settings/appearance/use-design-ai.ts @@ -57,14 +57,25 @@ export function useDesignAi({ const say = (turn: DesignAiTurn) => setTurns((previous) => [...previous, turn]) const ask = useCallback( - async (text: string, scope: DesignAiScope, palette?: string[]) => { + async ( + text: string, + scope: DesignAiScope, + attachment?: { palette: string[]; image?: string } + ) => { + const palette = attachment?.palette setBusy(true) const history = turns.map((turn) => ({ role: turn.role, content: turn.text })) say({ role: 'user', text, ...(palette?.length ? { palette } : {}) }) try { await flush() const result = await askPortalDesignAiFn({ - data: { message: text, scope, history, ...(palette?.length ? { palette } : {}) }, + data: { + message: text, + scope, + history, + ...(palette?.length ? { palette } : {}), + ...(attachment?.image ? { image: attachment.image } : {}), + }, }) if (!result.ok) { say({ role: 'assistant', text: result.message }) diff --git a/apps/web/src/components/help-center/help-center-popular-articles.tsx b/apps/web/src/components/help-center/help-center-popular-articles.tsx index b6ce9114d0..2f87f0336b 100644 --- a/apps/web/src/components/help-center/help-center-popular-articles.tsx +++ b/apps/web/src/components/help-center/help-center-popular-articles.tsx @@ -22,7 +22,11 @@ export function HelpCenterPopularArticles({ articles }: HelpCenterPopularArticle return (
-
diff --git a/apps/web/src/components/portal/changelog/changelog-entry-card.tsx b/apps/web/src/components/portal/changelog/changelog-entry-card.tsx index 3aea10e427..66a98a5b80 100644 --- a/apps/web/src/components/portal/changelog/changelog-entry-card.tsx +++ b/apps/web/src/components/portal/changelog/changelog-entry-card.tsx @@ -96,7 +96,12 @@ export function ChangelogEntryCard({ params={{ entryId: id }} className="group inline-flex items-center gap-2" > -

{title}

+

+ {title} +

diff --git a/apps/web/src/components/public/__tests__/portal-header-logo.test.tsx b/apps/web/src/components/public/__tests__/portal-header-logo.test.tsx index 24a23bd8ca..19cadaff41 100644 --- a/apps/web/src/components/public/__tests__/portal-header-logo.test.tsx +++ b/apps/web/src/components/public/__tests__/portal-header-logo.test.tsx @@ -91,6 +91,8 @@ describe('the header logo a design chooses', () => { expect(files[0]!.className).toContain('dark:hidden') expect(files[1]!.className).toContain('dark:block') expect(logo.textContent).toBe('') + // The link says whose portal it is, whatever the file shows. + expect(logo.getAttribute('aria-label')).toBe('Harbor') }) it('keeps the one wordmark file in dark mode when there is no dark file', async () => { diff --git a/apps/web/src/components/public/author-hover-card.tsx b/apps/web/src/components/public/author-hover-card.tsx index cf001e936d..8cafb967db 100644 --- a/apps/web/src/components/public/author-hover-card.tsx +++ b/apps/web/src/components/public/author-hover-card.tsx @@ -117,7 +117,8 @@ export function AuthorHoverCard({ onFocus={scheduleOpen} onBlur={scheduleClose} className={cn( - 'cursor-pointer rounded-sm hover:underline focus:outline-none focus-visible:underline', + // A 24px target, and a ring for keyboard focus, as the portal's other links have. + 'relative cursor-pointer rounded-sm hover:underline after:absolute after:-inset-y-1 after:inset-x-0 focus:outline-none focus-visible:underline focus-visible:ring-2 focus-visible:ring-ring', className )} > diff --git a/apps/web/src/components/public/feedback/feedback-header-animated.tsx b/apps/web/src/components/public/feedback/feedback-header-animated.tsx index 732a6b4963..e187944a42 100644 --- a/apps/web/src/components/public/feedback/feedback-header-animated.tsx +++ b/apps/web/src/components/public/feedback/feedback-header-animated.tsx @@ -329,7 +329,8 @@ export function FeedbackHeaderAnimated({ data-qb-part="composer" data-state={expanded ? 'open' : undefined} className={cn( - 'bg-card border border-border overflow-hidden', + // Typing in it shows as a ring on the whole composer, the way a field's focus does. + 'bg-card border border-border overflow-hidden focus-within:border-ring focus-within:outline-1 focus-within:outline-ring', place === 'feed' ? 'rounded-lg mb-5 shadow-sm' : // The brand's composer shape, eased to a card's corners once it opens. @@ -573,7 +574,7 @@ export function FeedbackHeaderAnimated({