Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
710b7f1
feat(design): exact values under the design's choices
mortondev Oct 31, 2026
a003e44
feat(design): the website start measures the brand's type and shapes
mortondev Oct 31, 2026
be75ebf
feat(design): exact colours and button type, where they read
mortondev Oct 31, 2026
77f8c45
fix(portal): keyboard focus and targets across the public portal
mortondev Oct 31, 2026
4837982
fix(portal): fields, band buttons and links meet focus and target sizes
mortondev Oct 31, 2026
9a07e9f
feat(design): type in proportion, light weights, and the closest bund…
mortondev Oct 31, 2026
f98c38a
fix(design): a font stack is matched in order, as a browser reads it
mortondev Oct 31, 2026
bd50691
fix(design): a generic fallback in a font stack is not the brand's face
mortondev Oct 31, 2026
80c9aaa
fix(design): measured card titles read at their size, and headlines l…
mortondev Oct 31, 2026
c372ef0
feat(design): Appearance shows and edits the exact values
mortondev Oct 31, 2026
028e0f7
fix(design): the reader measures the elements a person would, and the…
mortondev Oct 31, 2026
8225fc9
feat(design): the faithful look's band follows the site's hero, and e…
mortondev Oct 31, 2026
1638f7c
fix(design): the looks' answer stays within the grammar the provider …
mortondev Oct 31, 2026
d10edea
feat(design): Quackback AI's edit chat reads an attached picture, not…
mortondev Oct 31, 2026
ccc53da
fix(design): the website reader reads a site's whole stylesheet and c…
mortondev Oct 31, 2026
ea2e1b9
fix(design): a measured colour overrides the model only where what wa…
mortondev Oct 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,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 (
<TuneSection
design={design}
update={(change) =>
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(<Harness />)
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(<Harness />)
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')
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}}
/>
)}
Expand Down
58 changes: 4 additions & 54 deletions apps/web/src/components/admin/settings/appearance/brand-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 (
<div className="flex items-center gap-2">
<label htmlFor={id} className="w-32 shrink-0 text-[13px]">
{label}
</label>
<input
type="color"
aria-label={`${label} picker`}
value={toHex(value ?? placeholder)}
onChange={(event) => onChange(event.target.value)}
className="h-8 w-10 shrink-0 cursor-pointer rounded-field border border-border bg-transparent"
/>
<Input
id={id}
value={text}
placeholder={placeholder ? 'Automatic' : undefined}
onChange={(event) => {
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"
/>
</div>
)
}

/** The Brand tab: the logo, mode, colours, fonts and corners. */
export function BrandPanel({
design,
Expand Down Expand Up @@ -408,6 +356,8 @@ export function BrandPanel({
}
/>

<TuneSection design={design} update={update} />

<WordsSection design={design} update={update} />
</div>
)
Expand Down
56 changes: 56 additions & 0 deletions apps/web/src/components/admin/settings/appearance/colour-field.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex items-center gap-2">
<label htmlFor={id} className="w-32 shrink-0 text-[13px]">
{label}
</label>
<input
type="color"
aria-label={`${label} picker`}
value={toHex(value ?? placeholder)}
onChange={(event) => onChange(event.target.value)}
className="h-8 w-10 shrink-0 cursor-pointer rounded-field border border-border bg-transparent"
/>
<Input
id={id}
value={text}
placeholder={placeholder ? 'Automatic' : undefined}
onChange={(event) => {
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"
/>
</div>
)
}
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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 (
<div className="rounded-2xl border border-border bg-card p-3 shadow-float transition-[box-shadow,border-color] duration-300 ease-out focus-within:border-muted-foreground focus-within:shadow-float-focus motion-reduce:transition-none">
Expand Down Expand Up @@ -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 }) })
}}
/>
<svg viewBox="0 0 16 16" className="size-4" aria-hidden="true">
Expand Down
Loading
Loading